feat: open and close mobile sidebar with guarded swipes

This commit is contained in:
2026-10-26 09:39:35 +09:00
parent 40c36ccb1d
commit d8818b776b
5 changed files with 491 additions and 1 deletions
@@ -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 = "<p>Page content</p>") {
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 [
'<input type="range">',
"<textarea></textarea>",
"<select></select>",
"<button>Action</button>",
'<div contenteditable="true">Editor</div>',
'<div role="slider">Slider</div>',
"<dialog open>Dialog</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('<a href="#link">Link text</a>');
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();
});
});
@@ -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);
},
};
}
+9 -1
View File
@@ -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'}
<WorkspaceAlerts />
{/if}
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
<div
use:swipeSidebar={{
enabled: () => mobileLayout.current && authenticated,
isOpen: () => sidebarOpen,
setOpen: (open) => { sidebarTransientOpen = open; },
}}
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
>
<SidebarFrame
mode={sidebarMode}
open={sidebarOpen}
@@ -3,6 +3,7 @@
import { assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { chromium } from "playwright";
import { sidebarSwipe } from "./sidebar-swipe.ts";
import {
startOwnedProcesses,
stopOwnedProcesses,
@@ -37,6 +38,7 @@ Deno.test("only authenticated account and home show the sidebar; public entry re
try {
const browser = await chromium.launch();
try {
await sidebarSwipe(t, browser, origin);
for (const width of [1440, 390]) {
for (
const [path, mode] of [
@@ -0,0 +1,165 @@
/// <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";
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 =
'<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>';
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<void>((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<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 {
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();
}
});
}
}