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) => { '', "", "", - "", '