From d8818b776b706d58a9f32c1d5ebd4739285eb2f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 26 Oct 2026 09:39:35 +0900 Subject: [PATCH] feat: open and close mobile sidebar with guarded swipes --- .../sidebar/swipe-sidebar.browser.spec.ts | 195 ++++++++++++++++++ .../lib/workspace/sidebar/swipe-sidebar.ts | 120 +++++++++++ web/workspace/src/routes/+layout.svelte | 10 +- .../sidebar-shell.browser.test.ts | 2 + .../test/auth-browser/sidebar-swipe.ts | 165 +++++++++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts create mode 100644 web/workspace/test/auth-browser/sidebar-swipe.ts 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 new file mode 100644 index 000000000..2c6b6a4dd --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.browser.spec.ts @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, test, vi } from "vitest"; +import { swipeSidebar } from "./swipe-sidebar.ts"; + +let destroy: (() => void) | undefined; +afterEach(() => { + destroy?.(); + destroy = undefined; + document.body.replaceChildren(); + document.getSelection()?.removeAllRanges(); + vi.restoreAllMocks(); +}); + +describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => { + function fixture(html = "

Page content

") { + const node = document.createElement("div"); + node.innerHTML = html; + document.body.append(node); + let enabled = true; + let isOpen = initiallyOpen; + const open = vi.fn((next: boolean) => { + isOpen = next; + }); + const listeners = vi.spyOn(node, "addEventListener"); + destroy = swipeSidebar(node, { + enabled: () => enabled, + isOpen: () => isOpen, + setOpen: open, + }).destroy; + const target = node.firstElementChild!; + const send = ( + type: string, + x: number, + y: number, + time = 100, + count = 1, + id = 1, + ) => { + const touch = { + identifier: id, + clientX: initiallyOpen ? window.innerWidth - x : x, + clientY: y, + }; + const event = new Event(type, { bubbles: true, cancelable: true }); + 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 { + node, + target, + open, + send, + listeners, + flipState: () => { + isOpen = !isOpen; + }, + disable: () => { + enabled = false; + }, + }; + } + + test("intentional swipe changes state on release without claiming native touch events", () => { + const { node, open, send, listeners } = fixture(); + send("touchstart", 120, 200); + send("touchmove", 150, 203, 150); + send("touchmove", 230, 205, 200); + expect(open).not.toHaveBeenCalled(); + send("touchend", 230, 205, 250); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith(!initiallyOpen); + expect(node.style.touchAction).toBe(""); + for (const [name, , options] of listeners.mock.calls) { + if (String(name).startsWith("touch")) { + expect(options).toEqual({ passive: true }); + } + } + }); + + 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]]], + ] 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) + ); + const [x, y] = points[points.length - 1]; + send("touchend", x, y, 300); + expect(open).not.toHaveBeenCalled(); + }); + } + + for ( + const html of [ + '', + "", + "", + "", + '
Editor
', + '
Slider
', + "Dialog", + ] + ) { + test(`does not claim control gestures: ${html}`, () => { + const { open, send } = fixture(html); + send("touchstart", 120, 200); + send("touchend", 240, 201, 300); + expect(open).not.toHaveBeenCalled(); + }); + } + + 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 }); + send("touchstart", 120, 200); + send("touchend", 240, 201, 300); + expect(open).not.toHaveBeenCalled(); + }); + + for ( + const reason of [ + "scroll", + "touchcancel", + "multitouch", + "disabled", + "state-change", + "held", + "identifier", + "destroy", + "selection", + ] + ) { + test(`abandons the gesture after ${reason}`, () => { + const { target, open, send, disable, flipState } = fixture(); + send("touchstart", 120, 200); + send("touchmove", 160, 202, 150); + if (reason === "scroll" || reason === "touchcancel") { + target.dispatchEvent( + new Event(reason, { bubbles: reason === "touchcancel" }), + ); + } + if (reason === "multitouch") { + send("touchstart", 160, 202, 160, 2); + } + if (reason === "disabled") disable(); + if (reason === "state-change") flipState(); + 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, + ); + expect(open).not.toHaveBeenCalled(); + }); + } + + 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); + send("touchstart", 120, 200); + send("touchend", 240, 201, 300); + expect(open).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts new file mode 100644 index 000000000..e31ab9060 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/swipe-sidebar.ts @@ -0,0 +1,120 @@ +type Options = { + enabled: () => boolean; + isOpen: () => boolean; + 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. +export function swipeSidebar(node: HTMLElement, options: Options) { + let gesture: { + id: number; + x: number; + y: number; + started: number; + horizontal: boolean; + wasOpen: boolean; + } | null = null; + const cancel = () => { + gesture = null; + }; + const selectedText = () => + node.ownerDocument.getSelection()?.isCollapsed === false; + + 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"]', + ) + ) return true; + // Do not steal a horizontal pan even at a scroll container's left/right end. + for (; element; element = element.parentElement) { + if ( + element.scrollWidth > element.clientWidth + 1 && + /^(auto|scroll|overlay)$/.test(getComputedStyle(element).overflowX) + ) return true; + if (element === node) break; + } + return false; + } + + function start(event: TouchEvent) { + cancel(); + if ( + !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; + gesture = { + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + started: event.timeStamp, + horizontal: false, + wasOpen: options.isOpen(), + }; + } + + 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); + if ( + !options.enabled() || options.isOpen() !== gesture.wasOpen || + event.defaultPrevented || selectedText() || + touch.identifier !== gesture.id || + event.timeStamp - gesture.started > 700 || + dy > 32 || dx < -12 + ) { + cancel(); + return false; + } + if (!gesture.horizontal && Math.hypot(dx, dy) >= 12) { + if (dx < 2.5 * dy) { + cancel(); + return false; + } + gesture.horizontal = true; + } + return gesture.horizontal && dx >= 80 && dx >= 2.5 * dy; + } + + function move(event: TouchEvent) { + if (event.touches.length !== 1) { + cancel(); + return; + } + track(event, event.touches[0]); + } + + 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); + } + + const passive = { passive: true }; + node.addEventListener("touchstart", start, passive); + node.addEventListener("touchmove", move, passive); + node.addEventListener("touchend", end, passive); + node.addEventListener("touchcancel", cancel, passive); + node.addEventListener("scroll", cancel, { passive: true, capture: true }); + return { + destroy() { + cancel(); + node.removeEventListener("touchstart", start); + node.removeEventListener("touchmove", move); + node.removeEventListener("touchend", end); + node.removeEventListener("touchcancel", cancel); + node.removeEventListener("scroll", cancel, true); + }, + }; +} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 2146edae4..933429545 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -21,6 +21,7 @@ import SidebarFrame from '#lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '#lib/workspace/sidebar/context.ts'; + import { swipeSidebar } from '#lib/workspace/sidebar/swipe-sidebar.ts'; import { createOverrideStack } from '#lib/workspace/sidebar/override-stack.ts'; import '../app.css'; import type { LayoutProps } from './$types'; @@ -111,7 +112,14 @@ {#if page.url.pathname !== '/' && page.url.pathname !== '/account'} {/if} -
+
mobileLayout.current && authenticated, + isOpen: () => sidebarOpen, + setOpen: (open) => { sidebarTransientOpen = open; }, + }} + class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'} +> +// Native Chromium touch input against the production shell; HTTP uses the auth fixture. +import { assertEquals } from "@std/assert"; +import type { Browser } from "playwright"; + +export async function sidebarSwipe( + t: Deno.TestContext, + browser: Browser, + origin: string, +) { + for ( + const scenario of [ + "right", + "vertical", + "diagonal", + "short", + "horizontal-scroll", + "input", + "desktop", + "anonymous", + "close", + "close-vertical", + "close-diagonal", + "close-short", + "close-horizontal-scroll", + "close-input", + "close-opposite", + ] + ) { + await t.step(`native touch swipe: ${scenario}`, async () => { + const context = await browser.newContext({ + viewport: { width: scenario === "desktop" ? 1200 : 390, height: 844 }, + hasTouch: true, + isMobile: scenario !== "desktop", + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.request.get( + `${origin}/?fixture=${ + scenario === "anonymous" ? "anonymous" : "account" + }`, + ); + await page.goto( + `${origin}${scenario === "anonymous" ? "/" : "/account"}`, + ); + await page.getByRole("heading", { + name: scenario === "anonymous" ? "Yoi" : "Account", + exact: true, + }).waitFor(); + if (scenario === "desktop") { + await page.getByRole("button", { name: "Unpin sidebar" }).click(); + } + const closing = scenario.startsWith("close"); + const kind = closing ? scenario.slice(6) : scenario; + if (closing) { + await page.getByRole("button", { name: "Show sidebar", exact: true }) + .click(); + } + 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
'; + main.prepend(area); + }); + const target = kind === "horizontal-scroll" + ? "#swipe-horizontal" + : kind === "input" + ? "#swipe-input" + : "#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); + 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; + await client.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: startX, y: startY, id: 1 }], + }); + for (let i = 1; i <= 8; i++) { + await client.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ + x: startX + dx * i / 8, + y: startY + dy * i / 8, + 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(); + } else { + assertEquals( + await page.locator(".app-shell.sidebar-open").count(), + closing ? 1 : 0, + ); + 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(errors, []); + } finally { + await context.close(); + } + }); + } +}