fix: make mobile sidebar swipes track touch gestures
This commit is contained in:
@@ -708,6 +708,14 @@
|
||||
pointer-events: none;
|
||||
transition-delay: 0s, var(--sidebar-motion-duration);
|
||||
}
|
||||
/* Preview is visual only: closed content stays inert until committed.
|
||||
Pointer events remain with the original touch target during the drag. */
|
||||
.app-shell.sidebar-swiping .sidebar-frame {
|
||||
transform: translateX(var(--sidebar-swipe-offset));
|
||||
visibility: visible;
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-frame .sidebar-frame-content,
|
||||
.sidebar-frame.folded .sidebar-frame-content {
|
||||
transform: none;
|
||||
|
||||
@@ -15,19 +15,27 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
||||
function fixture(html = "<p>Page content</p>") {
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
const target = node.firstElementChild!;
|
||||
const panel = document.createElement("aside");
|
||||
panel.className = "sidebar-frame";
|
||||
node.append(panel);
|
||||
document.body.append(node);
|
||||
let enabled = true;
|
||||
let isOpen = initiallyOpen;
|
||||
let route = "/account";
|
||||
vi.spyOn(panel, "getBoundingClientRect").mockImplementation(() => ({
|
||||
width: 300,
|
||||
left: isOpen ? 0 : -300,
|
||||
} as DOMRect));
|
||||
const open = vi.fn((next: boolean) => {
|
||||
isOpen = next;
|
||||
action.update(options());
|
||||
});
|
||||
const options = () => ({ enabled, open: isOpen, route, setOpen: open });
|
||||
const listeners = vi.spyOn(node, "addEventListener");
|
||||
destroy = swipeSidebar(node, {
|
||||
enabled: () => enabled,
|
||||
isOpen: () => isOpen,
|
||||
setOpen: open,
|
||||
}).destroy;
|
||||
const target = node.firstElementChild!;
|
||||
const action = swipeSidebar(node, options());
|
||||
destroy = action.destroy;
|
||||
const clientX = (x: number) => initiallyOpen ? window.innerWidth - x : x;
|
||||
const send = (
|
||||
type: string,
|
||||
x: number,
|
||||
@@ -35,77 +43,156 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
||||
time = 100,
|
||||
count = 1,
|
||||
id = 1,
|
||||
cancelable = true,
|
||||
) => {
|
||||
const touch = {
|
||||
identifier: id,
|
||||
clientX: initiallyOpen ? window.innerWidth - x : x,
|
||||
clientY: y,
|
||||
};
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touch = { identifier: id, clientX: clientX(x), clientY: y };
|
||||
const event = new Event(type, { bubbles: true, cancelable });
|
||||
Object.defineProperties(event, {
|
||||
touches: { value: type === "touchend" ? [] : Array(count).fill(touch) },
|
||||
changedTouches: { value: [touch] },
|
||||
timeStamp: { value: time },
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
return event;
|
||||
};
|
||||
const click = (x: number, y: number, detail = 1) => {
|
||||
const event = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail,
|
||||
clientX: clientX(x),
|
||||
clientY: y,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
return {
|
||||
node,
|
||||
target,
|
||||
panel,
|
||||
open,
|
||||
send,
|
||||
click,
|
||||
listeners,
|
||||
flipState: () => {
|
||||
isOpen = !isOpen;
|
||||
action.update(options());
|
||||
},
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
action.update(options());
|
||||
},
|
||||
navigate: () => {
|
||||
route = "/other";
|
||||
action.update(options());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("intentional swipe changes state on release without claiming native touch events", () => {
|
||||
test("horizontal intent previews position, then settles only on release", async () => {
|
||||
const { node, open, send, listeners } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 150, 203, 150);
|
||||
send("touchmove", 230, 205, 200);
|
||||
expect(send("touchstart", 120, 200).defaultPrevented).toBe(false);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(send("touchmove", 150, 203, 150).defaultPrevented).toBe(true);
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
|
||||
initiallyOpen ? "-30px" : "-270px",
|
||||
);
|
||||
send("touchmove", 270, 205, 500);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
send("touchend", 230, 205, 250);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledWith(!initiallyOpen);
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
|
||||
"-150px",
|
||||
);
|
||||
send("touchend", 270, 205, 900);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
await Promise.resolve();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe("");
|
||||
expect(node.style.touchAction).toBe("");
|
||||
for (const [name, , options] of listeners.mock.calls) {
|
||||
if (String(name).startsWith("touch")) {
|
||||
expect(options).toEqual({ passive: true });
|
||||
}
|
||||
}
|
||||
expect(listeners.mock.calls.find(([name]) => name === "touchmove")?.[2])
|
||||
.toEqual({ passive: false });
|
||||
});
|
||||
|
||||
test("ambiguous start remains pending; horizontal lock tolerates an arcing thumb", () => {
|
||||
const { node, open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
expect(send("touchmove", 132, 210, 120).defaultPrevented).toBe(false);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
expect(send("touchmove", 160, 214, 170).defaultPrevented).toBe(true);
|
||||
send("touchmove", 280, 260, 500);
|
||||
send("touchend", 280, 260, 700);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("short fast flick commits without the former 80px requirement", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 132, 202, 115);
|
||||
send("touchmove", 156, 203, 150);
|
||||
send("touchend", 156, 203, 160);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("a noncancelable release still settles an already claimed drag", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 280, 203, 300);
|
||||
send("touchend", 280, 203, 400, 1, 1, false);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
test("slow long drag still commits after 700ms", () => {
|
||||
const { open, send } = fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 150, 202, 400);
|
||||
send("touchmove", 280, 203, 1500);
|
||||
send("touchend", 280, 203, 1800);
|
||||
expect(open).toHaveBeenCalledExactlyOnceWith(!initiallyOpen);
|
||||
});
|
||||
|
||||
for (
|
||||
const [name, points] of [
|
||||
["tap", [[120, 200], [122, 201]]],
|
||||
["short horizontal motion", [[120, 200], [170, 202]]],
|
||||
["vertical scroll", [[120, 200], [125, 250], [240, 252]]],
|
||||
["diagonal motion", [[120, 200], [140, 214], [230, 218]]],
|
||||
["opposite direction", [[240, 200], [210, 202], [120, 203]]],
|
||||
["vertical excursion before returning", [
|
||||
[120, 200],
|
||||
[150, 202],
|
||||
[230, 240],
|
||||
[240, 201],
|
||||
]],
|
||||
["browser left edge", [[10, 200], [150, 201]]],
|
||||
["tap", [[120, 200, 100], [122, 201, 130]]],
|
||||
["short slow drag", [[120, 200, 100], [160, 202, 500]]],
|
||||
["tiny fast flick", [[120, 200, 100], [138, 202, 120]]],
|
||||
["vertical intent", [[120, 200, 100], [125, 240, 150], [280, 242, 200]]],
|
||||
["ambiguous diagonal throughout", [[120, 200, 100], [135, 215, 130], [
|
||||
200,
|
||||
270,
|
||||
200,
|
||||
]]],
|
||||
["opposite direction", [[240, 200, 100], [210, 202, 140], [
|
||||
120,
|
||||
203,
|
||||
200,
|
||||
]]],
|
||||
["browser edge", [[10, 200, 100], [180, 201, 300]]],
|
||||
["fast motion followed by a pause", [[120, 200, 100], [160, 202, 150], [
|
||||
160,
|
||||
202,
|
||||
600,
|
||||
]]],
|
||||
["drag returned to start", [[120, 200, 100], [300, 202, 300], [
|
||||
125,
|
||||
203,
|
||||
900,
|
||||
]]],
|
||||
["reverse flick overrides distance", [[120, 200, 100], [320, 202, 200], [
|
||||
320,
|
||||
202,
|
||||
380,
|
||||
], [260, 203, 430]]],
|
||||
] as const
|
||||
) {
|
||||
test(`${name} does not open the sidebar`, () => {
|
||||
const { open, send } = fixture();
|
||||
points.forEach(([x, y], i) =>
|
||||
send(i === 0 ? "touchstart" : "touchmove", x, y, 100 + i * 30)
|
||||
test(`${name} leaves the sidebar in its original state`, async () => {
|
||||
const { node, open, send } = fixture();
|
||||
points.forEach(([x, y, time], i) =>
|
||||
send(i === 0 ? "touchstart" : "touchmove", x, y, time)
|
||||
);
|
||||
const [x, y] = points[points.length - 1];
|
||||
send("touchend", x, y, 300);
|
||||
const [x, y, time] = points.at(-1)!;
|
||||
send("touchend", x, y, time + 10);
|
||||
await Promise.resolve();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,27 +201,32 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
||||
'<input type="range">',
|
||||
"<textarea></textarea>",
|
||||
"<select></select>",
|
||||
"<button>Action</button>",
|
||||
'<div contenteditable="true">Editor</div>',
|
||||
'<div role="slider">Slider</div>',
|
||||
"<dialog open>Dialog</dialog>",
|
||||
"<div data-sidebar-swipe-ignore>Custom gesture</div>",
|
||||
]
|
||||
) {
|
||||
test(`does not claim control gestures: ${html}`, () => {
|
||||
const { open, send } = fixture(html);
|
||||
test(`keeps native controls unclaimed: ${html}`, () => {
|
||||
const { open, send, listeners } = fixture(html);
|
||||
send("touchstart", 120, 200);
|
||||
send("touchend", 240, 201, 300);
|
||||
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
|
||||
send("touchend", 290, 201, 310);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(listeners.mock.calls.some(([name]) => name === "touchmove")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("horizontal scroll container remains excluded even at its boundary", () => {
|
||||
const { node, open, send } = fixture();
|
||||
node.style.overflowX = "auto";
|
||||
Object.defineProperty(node, "scrollWidth", { value: 800 });
|
||||
Object.defineProperty(node, "clientWidth", { value: 300 });
|
||||
test("nested horizontal scrolling remains excluded even at its boundary", () => {
|
||||
const { target, open, send } = fixture();
|
||||
(target as HTMLElement).style.overflowX = "auto";
|
||||
Object.defineProperty(target, "scrollWidth", { value: 800 });
|
||||
Object.defineProperty(target, "clientWidth", { value: 300 });
|
||||
send("touchstart", 120, 200);
|
||||
send("touchend", 240, 201, 300);
|
||||
expect(send("touchmove", 290, 201, 300).defaultPrevented).toBe(false);
|
||||
send("touchend", 290, 201, 310);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -145,51 +237,76 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
||||
"multitouch",
|
||||
"disabled",
|
||||
"state-change",
|
||||
"held",
|
||||
"navigation",
|
||||
"resize",
|
||||
"hidden",
|
||||
"identifier",
|
||||
"destroy",
|
||||
"selection",
|
||||
"native-claimed",
|
||||
]
|
||||
) {
|
||||
test(`abandons the gesture after ${reason}`, () => {
|
||||
const { target, open, send, disable, flipState } = fixture();
|
||||
test(`cancels preview after ${reason}`, () => {
|
||||
const { node, target, open, send, disable, flipState, navigate } =
|
||||
fixture();
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 160, 202, 150);
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(true);
|
||||
if (reason === "scroll" || reason === "touchcancel") {
|
||||
target.dispatchEvent(
|
||||
new Event(reason, { bubbles: reason === "touchcancel" }),
|
||||
);
|
||||
}
|
||||
if (reason === "multitouch") {
|
||||
send("touchstart", 160, 202, 160, 2);
|
||||
}
|
||||
if (reason === "multitouch") send("touchstart", 160, 202, 160, 2);
|
||||
if (reason === "disabled") disable();
|
||||
if (reason === "state-change") flipState();
|
||||
if (reason === "navigation") navigate();
|
||||
if (reason === "resize") window.dispatchEvent(new Event("resize"));
|
||||
if (reason === "hidden") {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
if (reason === "destroy") destroy?.();
|
||||
if (reason === "selection") {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
document.getSelection()?.addRange(range);
|
||||
}
|
||||
send(
|
||||
"touchend",
|
||||
240,
|
||||
203,
|
||||
reason === "held" ? 1000 : 300,
|
||||
1,
|
||||
reason === "identifier" ? 2 : 1,
|
||||
);
|
||||
if (reason === "native-claimed") {
|
||||
send("touchmove", 200, 203, 200, 1, 1, false);
|
||||
}
|
||||
send("touchend", 290, 203, 300, 1, reason === "identifier" ? 2 : 1);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test("a tap on a link is untouched while an intentional swipe over text can open", () => {
|
||||
const { target, open, send } = fixture('<a href="#link">Link text</a>');
|
||||
const click = new Event("click", { bubbles: true, cancelable: true });
|
||||
target.dispatchEvent(click);
|
||||
expect(click.defaultPrevented).toBe(false);
|
||||
for (const html of ['<a href="#link">Link</a>', "<button>Action</button>"]) {
|
||||
test(`swipe from ${html} suppresses only its click, not later taps or keyboard activation`, async () => {
|
||||
const { open, send, click, target } = fixture(html);
|
||||
const activate = vi.fn();
|
||||
target.addEventListener("click", activate);
|
||||
send("touchstart", 120, 200);
|
||||
send("touchend", 240, 201, 300);
|
||||
send("touchmove", 156, 202, 140);
|
||||
send("touchend", 156, 202, 150);
|
||||
await Promise.resolve();
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
expect(click(156, 202).defaultPrevented).toBe(true);
|
||||
expect(activate).not.toHaveBeenCalled();
|
||||
expect(click(156, 202, 0).defaultPrevented).toBe(false);
|
||||
send("touchstart", 160, 200, 200);
|
||||
send("touchend", 160, 200, 250);
|
||||
expect(click(160, 200).defaultPrevented).toBe(false);
|
||||
expect(activate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
}
|
||||
|
||||
test("a claimed but incomplete swipe also suppresses accidental activation", async () => {
|
||||
const { open, send, click } = fixture("<button>Action</button>");
|
||||
send("touchstart", 120, 200);
|
||||
send("touchmove", 160, 202, 400);
|
||||
send("touchend", 160, 202, 500);
|
||||
await Promise.resolve();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(click(160, 202).defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,57 @@
|
||||
type Options = {
|
||||
enabled: () => boolean;
|
||||
isOpen: () => boolean;
|
||||
enabled: boolean;
|
||||
open: boolean;
|
||||
route: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
// Observe, never claim, the touch stream: native scrolling/zoom and child controls
|
||||
// retain their defaults. A deliberate right swipe opens; a left swipe closes.
|
||||
type Sample = { x: number; time: number };
|
||||
const SLOP = 10;
|
||||
const AXIS_RATIO = 1.5;
|
||||
const MIN_FLING_DISTANCE = 24;
|
||||
const FLING_VELOCITY = 450; // CSS px/s, measured over the last 120ms.
|
||||
const COMMIT_FRACTION = 0.4;
|
||||
|
||||
// Leave undecided/vertical gestures to the browser. Only a confirmed horizontal
|
||||
// drag claims touchmove; no ancestor touch-action restriction disables nested
|
||||
// horizontal scrolling or pinch zoom. Preview never changes admission/focus.
|
||||
export function swipeSidebar(node: HTMLElement, options: Options) {
|
||||
let gesture: {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
started: number;
|
||||
horizontal: boolean;
|
||||
wasOpen: boolean;
|
||||
width: number;
|
||||
startOffset: number;
|
||||
offset: number;
|
||||
peak: number;
|
||||
samples: Sample[];
|
||||
} | null = null;
|
||||
const cancel = () => {
|
||||
gesture = null;
|
||||
};
|
||||
let suppressedClick: { x: number; y: number; until: number } | null = null;
|
||||
let cleanupVersion = 0;
|
||||
const view = node.ownerDocument.defaultView;
|
||||
const selectedText = () =>
|
||||
node.ownerDocument.getSelection()?.isCollapsed === false;
|
||||
const clearPreview = () => {
|
||||
node.classList.remove("sidebar-swiping");
|
||||
node.style.removeProperty("--sidebar-swipe-offset");
|
||||
};
|
||||
const cancel = () => {
|
||||
gesture = null;
|
||||
node.removeEventListener("touchmove", move);
|
||||
cleanupVersion++;
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
function excluded(target: EventTarget | null): boolean {
|
||||
let element = target instanceof Element ? target : null;
|
||||
if (
|
||||
!element || element.closest(
|
||||
'input, textarea, select, button, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="spinbutton"], [role="scrollbar"], [role="dialog"], [role="menu"], [role="listbox"], dialog, video, audio, canvas, [draggable="true"]',
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="spinbutton"], [role="scrollbar"], [role="dialog"], [role="menu"], [role="listbox"], dialog, video, audio, canvas, [draggable="true"], [data-sidebar-swipe-ignore]',
|
||||
)
|
||||
) return true;
|
||||
// Do not steal a horizontal pan even at a scroll container's left/right end.
|
||||
// A horizontal scroll surface owns the whole gesture, including its edges.
|
||||
for (; element; element = element.parentElement) {
|
||||
if (
|
||||
element.scrollWidth > element.clientWidth + 1 &&
|
||||
@@ -41,47 +64,91 @@ export function swipeSidebar(node: HTMLElement, options: Options) {
|
||||
|
||||
function start(event: TouchEvent) {
|
||||
cancel();
|
||||
suppressedClick = null; // A new tap must not inherit the previous swipe.
|
||||
if (
|
||||
!options.enabled() || event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
selectedText() || excluded(event.target)
|
||||
!options.enabled || event.defaultPrevented ||
|
||||
event.touches.length !== 1 || selectedText() || excluded(event.target)
|
||||
) return;
|
||||
const touch = event.touches[0];
|
||||
const width = node.ownerDocument.defaultView?.innerWidth ?? 0;
|
||||
// Leave OS/browser edge-navigation gestures alone.
|
||||
if (touch.clientX < 24 || touch.clientX > width - 24) return;
|
||||
const viewportWidth = view?.innerWidth ?? 0;
|
||||
if (touch.clientX < 24 || touch.clientX > viewportWidth - 24) return;
|
||||
const panel = node.querySelector<HTMLElement>(".sidebar-frame");
|
||||
if (!panel) return;
|
||||
const bounds = panel.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const offset = Math.min(
|
||||
0,
|
||||
Math.max(-bounds.width, bounds.left - node.getBoundingClientRect().left),
|
||||
);
|
||||
gesture = {
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
started: event.timeStamp,
|
||||
horizontal: false,
|
||||
wasOpen: options.isOpen(),
|
||||
wasOpen: options.open,
|
||||
width: bounds.width,
|
||||
startOffset: offset,
|
||||
offset,
|
||||
peak: 0,
|
||||
samples: [{ x: 0, time: event.timeStamp }],
|
||||
};
|
||||
// Install a potentially blocking listener only for an eligible gesture.
|
||||
node.addEventListener("touchmove", move, { passive: false });
|
||||
}
|
||||
|
||||
function track(event: TouchEvent, touch: Touch): boolean {
|
||||
if (!gesture) return false;
|
||||
const dx = (touch.clientX - gesture.x) * (gesture.wasOpen ? -1 : 1);
|
||||
const dy = Math.abs(touch.clientY - gesture.y);
|
||||
const current = gesture;
|
||||
if (!current) return false;
|
||||
if (
|
||||
!options.enabled() || options.isOpen() !== gesture.wasOpen ||
|
||||
!options.enabled || options.open !== current.wasOpen ||
|
||||
event.defaultPrevented || selectedText() ||
|
||||
touch.identifier !== gesture.id ||
|
||||
event.timeStamp - gesture.started > 700 ||
|
||||
dy > 32 || dx < -12
|
||||
touch.identifier !== current.id
|
||||
) {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
if (!gesture.horizontal && Math.hypot(dx, dy) >= 12) {
|
||||
if (dx < 2.5 * dy) {
|
||||
const rawDx = touch.clientX - current.x;
|
||||
const dx = rawDx * (current.wasOpen ? -1 : 1);
|
||||
const dy = Math.abs(touch.clientY - current.y);
|
||||
if (!current.horizontal) {
|
||||
if (
|
||||
(dy >= SLOP && dy > Math.abs(dx) * AXIS_RATIO) ||
|
||||
(dx <= -SLOP && -dx > dy * AXIS_RATIO)
|
||||
) {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
gesture.horizontal = true;
|
||||
// An ambiguous diagonal is not a failed swipe. Keep observing until
|
||||
// intent becomes clear, unless the browser starts scrolling first.
|
||||
if (dx < SLOP || dx < dy * AXIS_RATIO) return false;
|
||||
if (!event.cancelable) {
|
||||
cancel(); // Native scrolling already owns this stream.
|
||||
return false;
|
||||
}
|
||||
return gesture.horizontal && dx >= 80 && dx >= 2.5 * dy;
|
||||
current.horizontal = true;
|
||||
}
|
||||
if (!event.cancelable && event.type !== "touchend") {
|
||||
cancel();
|
||||
return false;
|
||||
}
|
||||
if (event.cancelable) event.preventDefault();
|
||||
current.offset = Math.min(
|
||||
0,
|
||||
Math.max(-current.width, current.startOffset + rawDx),
|
||||
);
|
||||
current.peak = Math.max(current.peak, dx);
|
||||
current.samples.push({ x: dx, time: event.timeStamp });
|
||||
current.samples = current.samples.filter((sample) =>
|
||||
sample.time >= event.timeStamp - 120
|
||||
);
|
||||
suppressedClick = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
until: performance.now() + 750,
|
||||
};
|
||||
node.style.setProperty("--sidebar-swipe-offset", `${current.offset}px`);
|
||||
node.classList.add("sidebar-swiping");
|
||||
return true;
|
||||
}
|
||||
|
||||
function move(event: TouchEvent) {
|
||||
@@ -93,28 +160,80 @@ export function swipeSidebar(node: HTMLElement, options: Options) {
|
||||
}
|
||||
|
||||
function end(event: TouchEvent) {
|
||||
const nextOpen = !gesture?.wasOpen;
|
||||
const accepted = event.touches.length === 0 &&
|
||||
event.changedTouches.length === 1 &&
|
||||
track(event, event.changedTouches[0]);
|
||||
// Do not turn an unclaimed touch into a swipe only after the finger lifts.
|
||||
if (
|
||||
!gesture?.horizontal || event.touches.length !== 0 ||
|
||||
event.changedTouches.length !== 1 ||
|
||||
!track(event, event.changedTouches[0])
|
||||
) {
|
||||
cancel();
|
||||
if (accepted) options.setOpen(nextOpen);
|
||||
return;
|
||||
}
|
||||
const current = gesture;
|
||||
const last = current.samples.at(-1)!;
|
||||
const first = current.samples[0];
|
||||
const duration = last.time - first.time;
|
||||
const velocity = duration >= 8 ? (last.x - first.x) * 1000 / duration : 0;
|
||||
const progress = current.wasOpen
|
||||
? -current.offset
|
||||
: current.width + current.offset;
|
||||
const reversed = velocity <= -FLING_VELOCITY &&
|
||||
current.peak - last.x >= MIN_FLING_DISTANCE;
|
||||
const commit = !reversed && (
|
||||
progress >= current.width * COMMIT_FRACTION ||
|
||||
(last.x >= MIN_FLING_DISTANCE && velocity >= FLING_VELOCITY)
|
||||
);
|
||||
gesture = null;
|
||||
node.removeEventListener("touchmove", move);
|
||||
if (commit) options.setOpen(!current.wasOpen);
|
||||
// Let Svelte apply the final open/closed class before removing the preview;
|
||||
// CSS then settles from the actual dragged position, including snap-back.
|
||||
const version = ++cleanupVersion;
|
||||
queueMicrotask(() => {
|
||||
if (version === cleanupVersion) clearPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function click(event: MouseEvent) {
|
||||
if (!suppressedClick || performance.now() > suppressedClick.until) return;
|
||||
// Keep keyboard activation and a subsequent deliberate tap intact.
|
||||
if (event.detail === 0) return;
|
||||
if (
|
||||
Math.hypot(
|
||||
event.clientX - suppressedClick.x,
|
||||
event.clientY - suppressedClick.y,
|
||||
) > 32
|
||||
) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
suppressedClick = null;
|
||||
}
|
||||
|
||||
const passive = { passive: true };
|
||||
node.addEventListener("touchstart", start, passive);
|
||||
node.addEventListener("touchmove", move, passive);
|
||||
node.addEventListener("touchend", end, passive);
|
||||
node.addEventListener("touchend", end, { passive: false });
|
||||
node.addEventListener("touchcancel", cancel, passive);
|
||||
node.addEventListener("scroll", cancel, { passive: true, capture: true });
|
||||
node.addEventListener("click", click, true);
|
||||
view?.addEventListener("resize", cancel);
|
||||
node.ownerDocument.addEventListener("visibilitychange", cancel);
|
||||
return {
|
||||
update(next: Options) {
|
||||
if (
|
||||
!next.enabled || next.open !== options.open ||
|
||||
next.route !== options.route
|
||||
) cancel();
|
||||
options = next;
|
||||
},
|
||||
destroy() {
|
||||
cancel();
|
||||
node.removeEventListener("touchstart", start);
|
||||
node.removeEventListener("touchmove", move);
|
||||
node.removeEventListener("touchend", end);
|
||||
node.removeEventListener("touchcancel", cancel);
|
||||
node.removeEventListener("scroll", cancel, true);
|
||||
node.removeEventListener("click", click, true);
|
||||
view?.removeEventListener("resize", cancel);
|
||||
node.ownerDocument.removeEventListener("visibilitychange", cancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,8 +114,9 @@
|
||||
{/if}
|
||||
<div
|
||||
use:swipeSidebar={{
|
||||
enabled: () => mobileLayout.current && authenticated,
|
||||
isOpen: () => sidebarOpen,
|
||||
enabled: mobileLayout.current && authenticated,
|
||||
open: sidebarOpen,
|
||||
route: page.url.href,
|
||||
setOpen: (open) => { sidebarTransientOpen = open; },
|
||||
}}
|
||||
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
/// <reference lib="dom" />
|
||||
// Native Chromium touch input against the production shell; HTTP uses the auth fixture.
|
||||
import { assertEquals } from "@std/assert";
|
||||
import type { Browser } from "playwright";
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import type { Browser, Page } from "playwright";
|
||||
|
||||
async function painted(page: Page) {
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function sidebarSwipe(
|
||||
t: Deno.TestContext,
|
||||
browser: Browser,
|
||||
origin: string,
|
||||
) {
|
||||
for (
|
||||
const scenario of [
|
||||
"right",
|
||||
const kinds = [
|
||||
"drag",
|
||||
"flick",
|
||||
"slow",
|
||||
"short",
|
||||
"arc",
|
||||
"ambiguous",
|
||||
"return",
|
||||
"reverse",
|
||||
"vertical",
|
||||
"diagonal",
|
||||
"short",
|
||||
"horizontal-scroll",
|
||||
"horizontal-edge",
|
||||
"input",
|
||||
"button",
|
||||
"button-short",
|
||||
"link",
|
||||
"edge",
|
||||
"cancel",
|
||||
"multitouch",
|
||||
"resize",
|
||||
];
|
||||
for (
|
||||
const scenario of [
|
||||
...kinds,
|
||||
...kinds.map((kind) => `close-${kind}`),
|
||||
"desktop",
|
||||
"anonymous",
|
||||
"close",
|
||||
"close-vertical",
|
||||
"close-diagonal",
|
||||
"close-short",
|
||||
"close-horizontal-scroll",
|
||||
"close-input",
|
||||
"close-opposite",
|
||||
]
|
||||
) {
|
||||
await t.step(`native touch swipe: ${scenario}`, async () => {
|
||||
@@ -32,7 +51,7 @@ export async function sidebarSwipe(
|
||||
viewport: { width: scenario === "desktop" ? 1200 : 390, height: 844 },
|
||||
hasTouch: true,
|
||||
isMobile: scenario !== "desktop",
|
||||
reducedMotion: "reduce",
|
||||
reducedMotion: scenario.endsWith("drag") ? "no-preference" : "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
@@ -54,86 +73,201 @@ export async function sidebarSwipe(
|
||||
if (scenario === "desktop") {
|
||||
await page.getByRole("button", { name: "Unpin sidebar" }).click();
|
||||
}
|
||||
const closing = scenario.startsWith("close");
|
||||
const closing = scenario.startsWith("close-");
|
||||
const kind = closing ? scenario.slice(6) : scenario;
|
||||
if (closing) {
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true })
|
||||
.click();
|
||||
await page.waitForFunction(() =>
|
||||
Math.abs(
|
||||
document.querySelector(".sidebar-frame")!.getBoundingClientRect()
|
||||
.left,
|
||||
) < 1
|
||||
);
|
||||
}
|
||||
const surface = closing ? ".sidebar-frame-content" : "main";
|
||||
// Supply deterministic scroll/control surfaces inside the actual shell.
|
||||
await page.locator(surface).evaluate((main) => {
|
||||
const area = document.createElement("div");
|
||||
area.innerHTML =
|
||||
'<div id="swipe-plain" style="height:180px">Swipe surface</div><div id="swipe-horizontal" style="overflow-x:auto;width:280px"><div style="width:900px;height:100px">Horizontal content</div></div><input id="swipe-input" type="range" style="width:280px"><div style="height:1800px"></div>';
|
||||
'<div id="swipe-plain" style="height:180px">Swipe surface</div><div id="swipe-horizontal" style="overflow-x:auto;width:280px"><div style="width:900px;height:100px">Horizontal content</div></div><input id="swipe-input" type="range" style="width:280px"><button id="swipe-button" style="display:block;width:280px;height:70px">Ordinary button</button><a id="swipe-link" href="#unexpected" style="display:block;width:280px;height:70px">Ordinary link</a><div style="height:1800px"></div>';
|
||||
for (const control of area.querySelectorAll("button, a")) {
|
||||
control.addEventListener("click", () => {
|
||||
area.dataset.clicked = "true";
|
||||
});
|
||||
}
|
||||
area.id = "swipe-fixture";
|
||||
main.prepend(area);
|
||||
});
|
||||
const target = kind === "horizontal-scroll"
|
||||
const target = kind.startsWith("horizontal-")
|
||||
? "#swipe-horizontal"
|
||||
: kind === "input"
|
||||
? "#swipe-input"
|
||||
: kind.startsWith("button")
|
||||
? "#swipe-button"
|
||||
: kind === "link"
|
||||
? "#swipe-link"
|
||||
: "#swipe-plain";
|
||||
const box = (await page.locator(target).boundingBox())!;
|
||||
if (kind === "horizontal-scroll") {
|
||||
await page.locator(target).evaluate((node) => {
|
||||
node.scrollLeft = 200;
|
||||
});
|
||||
}
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
);
|
||||
const client = await context.newCDPSession(page);
|
||||
const startX = box.x + (closing ? 210 : 60);
|
||||
await painted(page);
|
||||
const box = (await page.locator(target).boundingBox())!;
|
||||
const startX = kind === "edge"
|
||||
? (closing ? 380 : 10)
|
||||
: box.x + (closing ? 245 : 35);
|
||||
const startY = box.y + (kind === "vertical" ? 150 : box.height / 2);
|
||||
const dx = (closing && kind !== "opposite" ? -1 : 1) *
|
||||
(kind === "vertical" ? 8 : kind === "short" ? 40 : 150);
|
||||
const dy = kind === "vertical" ? -130 : kind === "diagonal" ? 100 : 3;
|
||||
const direction = closing ? -1 : 1;
|
||||
// dx is measured toward the requested state. Explicit event timestamps
|
||||
// make flick/hold tests independent of CDP roundtrip speed.
|
||||
let points = [[20, 1, 40], [80, 2, 160], [200, 3, 400]];
|
||||
if (kind === "flick") points = [[12, 1, 15], [40, 2, 50]];
|
||||
if (kind === "slow") {
|
||||
points = [[20, 1, 400], [80, 2, 900], [200, 3, 1600]];
|
||||
}
|
||||
if (kind === "short" || kind === "button-short") {
|
||||
points = [[20, 1, 200], [45, 2, 400]];
|
||||
}
|
||||
if (kind === "arc") {
|
||||
points = [[20, 2, 40], [80, 35, 160], [200, 65, 400]];
|
||||
}
|
||||
if (kind === "ambiguous") {
|
||||
points = [[8, 7, 20], [25, 10, 60], [200, 15, 400]];
|
||||
}
|
||||
if (kind === "return") {
|
||||
points = [[20, 1, 40], [200, 2, 200], [5, 3, 600]];
|
||||
}
|
||||
if (kind === "reverse") {
|
||||
// Stationary CDP points do not emit touchmove. Sample both moving
|
||||
// points of the reversal so its release velocity is observable.
|
||||
points = [[20, 1, 40], [250, 2, 200], [230, 2, 530], [180, 3, 560]];
|
||||
}
|
||||
if (kind === "vertical") {
|
||||
points = [[2, -20, 40], [4, -70, 160], [8, -130, 400]];
|
||||
}
|
||||
if (kind === "diagonal") {
|
||||
points = [[15, -15, 40], [50, -50, 160], [100, -100, 400]];
|
||||
}
|
||||
const client = await context.newCDPSession(page);
|
||||
const base = Date.now() / 1000;
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
timestamp: base,
|
||||
touchPoints: [{ x: startX, y: startY, id: 1 }],
|
||||
});
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
for (const [dx, dy, time] of points) {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
timestamp: base + time / 1000,
|
||||
touchPoints: [{
|
||||
x: startX + dx * i / 8,
|
||||
y: startY + dy * i / 8,
|
||||
x: startX + dx * direction,
|
||||
y: startY + dy,
|
||||
id: 1,
|
||||
}],
|
||||
});
|
||||
}
|
||||
const [dx, dy, time] = points.at(-1)!;
|
||||
const claimed = ![
|
||||
"vertical",
|
||||
"diagonal",
|
||||
"horizontal-scroll",
|
||||
"horizontal-edge",
|
||||
"input",
|
||||
"edge",
|
||||
"desktop",
|
||||
"anonymous",
|
||||
].includes(kind);
|
||||
await painted(page);
|
||||
if (claimed) {
|
||||
assertEquals(
|
||||
await page.locator(".sidebar-swiping").count(),
|
||||
1,
|
||||
`${scenario}: preview before release`,
|
||||
);
|
||||
const actual = await page.locator(".sidebar-frame").evaluate((
|
||||
panel,
|
||||
) => ({
|
||||
x: panel.getBoundingClientRect().left,
|
||||
width: panel.getBoundingClientRect().width,
|
||||
visibility: getComputedStyle(panel).visibility,
|
||||
transition: getComputedStyle(panel).transitionDuration,
|
||||
}));
|
||||
assert(
|
||||
Math.abs(actual.x - (closing ? -dx : -actual.width + dx)) < 2,
|
||||
`${scenario}: panel must track finger, got ${actual.x}`,
|
||||
);
|
||||
assertEquals(actual.visibility, "visible");
|
||||
assertEquals(actual.transition, "0s");
|
||||
assertEquals(
|
||||
await page.locator("main").getAttribute("inert"),
|
||||
closing ? "" : null,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".sidebar-frame-content").getAttribute("inert"),
|
||||
closing ? null : "",
|
||||
);
|
||||
} else {
|
||||
assertEquals(await page.locator(".sidebar-swiping").count(), 0);
|
||||
}
|
||||
if (kind === "cancel") {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
type: "touchCancel",
|
||||
touchPoints: [],
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
);
|
||||
if (scenario === "right") {
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator("main").getAttribute("inert"), "");
|
||||
await page.getByRole("button", { name: "Hide sidebar", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true })
|
||||
.waitFor();
|
||||
} else if (scenario === "close") {
|
||||
await page.getByRole("button", { name: "Show sidebar", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator("main").getAttribute("inert"), null);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
} else {
|
||||
if (kind === "multitouch") {
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
timestamp: base + (time + 5) / 1000,
|
||||
touchPoints: [
|
||||
{ x: startX + dx * direction, y: startY + dy, id: 1 },
|
||||
{ x: startX + dx * direction + 30, y: startY + dy, id: 2 },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (kind === "resize") {
|
||||
await page.setViewportSize({ width: 400, height: 844 });
|
||||
}
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
timestamp: base + (time + 10) / 1000,
|
||||
touchPoints: [],
|
||||
});
|
||||
}
|
||||
const commits = [
|
||||
"drag",
|
||||
"flick",
|
||||
"slow",
|
||||
"arc",
|
||||
"ambiguous",
|
||||
"button",
|
||||
"link",
|
||||
].includes(kind);
|
||||
const expectedOpen = commits ? !closing : closing;
|
||||
await painted(page);
|
||||
if (scenario !== "desktop" && scenario !== "anonymous") {
|
||||
assertEquals(
|
||||
await page.locator(".app-shell.sidebar-open").count(),
|
||||
closing ? 1 : 0,
|
||||
expectedOpen ? 1 : 0,
|
||||
`${scenario}: final state`,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator("main").getAttribute("inert"),
|
||||
expectedOpen ? "" : null,
|
||||
);
|
||||
await page.waitForFunction((open) => {
|
||||
const bounds = document.querySelector(".sidebar-frame")!
|
||||
.getBoundingClientRect();
|
||||
return Math.abs(bounds.left - (open ? 0 : -bounds.width)) < 1;
|
||||
}, expectedOpen);
|
||||
}
|
||||
assertEquals(await page.locator(".sidebar-swiping").count(), 0);
|
||||
assertEquals(
|
||||
await page.locator("#swipe-fixture").getAttribute("data-clicked"),
|
||||
null,
|
||||
);
|
||||
assertEquals(new URL(page.url()).hash, "");
|
||||
if (kind === "vertical") {
|
||||
await page.waitForFunction(
|
||||
(selector) =>
|
||||
@@ -145,17 +279,28 @@ export async function sidebarSwipe(
|
||||
await page.waitForFunction(
|
||||
(closing) =>
|
||||
closing
|
||||
? document.querySelector("#swipe-horizontal")!.scrollLeft >
|
||||
200
|
||||
: document.querySelector("#swipe-horizontal")!.scrollLeft <
|
||||
200,
|
||||
? document.querySelector("#swipe-horizontal")!.scrollLeft > 200
|
||||
: document.querySelector("#swipe-horizontal")!.scrollLeft < 200,
|
||||
closing,
|
||||
);
|
||||
}
|
||||
if (kind === "input") {
|
||||
assert(
|
||||
(await page.locator(target).inputValue()) !== "50",
|
||||
"native range control remains operable",
|
||||
);
|
||||
}
|
||||
if (kind === "button-short") {
|
||||
// A new tap after snap-back must still activate the same button.
|
||||
await page.locator(target).tap();
|
||||
assertEquals(
|
||||
await page.locator("#swipe-fixture").getAttribute("data-clicked"),
|
||||
"true",
|
||||
);
|
||||
}
|
||||
if (scenario === "anonymous") {
|
||||
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||
}
|
||||
}
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
|
||||
Reference in New Issue
Block a user