diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index 1acca85b8..38ed7eb7f 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -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; diff --git a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts index 2c6b6a4dd..ee6ca011c 100644 --- a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts @@ -15,19 +15,27 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => { function fixture(html = "

Page content

") { 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) => { '', "", "", - "", '
Editor
', '
Slider
', "Dialog", + "
Custom gesture
", ] ) { - 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('Link text'); - const click = new Event("click", { bubbles: true, cancelable: true }); - target.dispatchEvent(click); - expect(click.defaultPrevented).toBe(false); + for (const html of ['Link', ""]) { + 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("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(""); send("touchstart", 120, 200); - send("touchend", 240, 201, 300); - expect(open).toHaveBeenCalledOnce(); + send("touchmove", 160, 202, 400); + send("touchend", 160, 202, 500); + await Promise.resolve(); + expect(open).not.toHaveBeenCalled(); + expect(click(160, 202).defaultPrevented).toBe(true); }); }); diff --git a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts index e31ab9060..2a4307220 100644 --- a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts +++ b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts @@ -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(".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; + } + current.horizontal = true; } - return gesture.horizontal && dx >= 80 && dx >= 2.5 * dy; + 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]); - cancel(); - if (accepted) options.setOpen(nextOpen); + // 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(); + 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); }, }; } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 933429545..11d9e8bb9 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -114,8 +114,9 @@ {/if}
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'} diff --git a/web/workspace/test/auth-browser/sidebar-swipe.ts b/web/workspace/test/auth-browser/sidebar-swipe.ts index 7c1a047fe..be0377a1c 100644 --- a/web/workspace/test/auth-browser/sidebar-swipe.ts +++ b/web/workspace/test/auth-browser/sidebar-swipe.ts @@ -1,30 +1,49 @@ /// // 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((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ) + ); +} export async function sidebarSwipe( t: Deno.TestContext, browser: Browser, origin: string, ) { + const kinds = [ + "drag", + "flick", + "slow", + "short", + "arc", + "ambiguous", + "return", + "reverse", + "vertical", + "diagonal", + "horizontal-scroll", + "horizontal-edge", + "input", + "button", + "button-short", + "link", + "edge", + "cancel", + "multitouch", + "resize", + ]; for ( const scenario of [ - "right", - "vertical", - "diagonal", - "short", - "horizontal-scroll", - "input", + ...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,107 +73,233 @@ 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 = - '
Swipe surface
Horizontal content
'; + '
Swipe surface
Horizontal content
Ordinary link
'; + 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((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, }], }); } - await client.send("Input.dispatchTouchEvent", { - type: "touchEnd", - touchPoints: [], - }); - await page.evaluate(() => - new Promise((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(); + 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: "touchCancel", + touchPoints: [], + }); + } 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`, ); - if (kind === "vertical") { - await page.waitForFunction( - (selector) => - (document.querySelector(selector)?.scrollTop ?? 0) > 0, - surface, - ); - } - if (kind === "horizontal-scroll") { - await page.waitForFunction( - (closing) => - closing - ? document.querySelector("#swipe-horizontal")!.scrollLeft > - 200 - : document.querySelector("#swipe-horizontal")!.scrollLeft < - 200, - closing, - ); - } - if (scenario === "anonymous") { - assertEquals(await page.locator(".sidebar-frame").count(), 0); - } + 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) => + (document.querySelector(selector)?.scrollTop ?? 0) > 0, + surface, + ); + } + if (kind === "horizontal-scroll") { + await page.waitForFunction( + (closing) => + closing + ? 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 {