feat: open and close mobile sidebar with guarded swipes
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user