fix: make mobile sidebar swipes track touch gestures

This commit is contained in:
2026-10-27 06:49:28 +09:00
parent 623c31615d
commit 5b12c3e085
5 changed files with 585 additions and 195 deletions
@@ -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;
@@ -15,19 +15,27 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
function fixture(html = "<p>Page content</p>") {
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) => {
'<input type="range">',
"<textarea></textarea>",
"<select></select>",
"<button>Action</button>",
'<div contenteditable="true">Editor</div>',
'<div role="slider">Slider</div>',
"<dialog open>Dialog</dialog>",
"<div data-sidebar-swipe-ignore>Custom gesture</div>",
]
) {
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('<a href="#link">Link text</a>');
const click = new Event("click", { bubbles: true, cancelable: true });
target.dispatchEvent(click);
expect(click.defaultPrevented).toBe(false);
for (const html of ['<a href="#link">Link</a>', "<button>Action</button>"]) {
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("touchend", 240, 201, 300);
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("<button>Action</button>");
send("touchstart", 120, 200);
send("touchmove", 160, 202, 400);
send("touchend", 160, 202, 500);
await Promise.resolve();
expect(open).not.toHaveBeenCalled();
expect(click(160, 202).defaultPrevented).toBe(true);
});
});
@@ -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<HTMLElement>(".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;
}
return gesture.horizontal && dx >= 80 && dx >= 2.5 * dy;
current.horizontal = true;
}
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]);
// 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();
if (accepted) options.setOpen(nextOpen);
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);
},
};
}
+3 -2
View File
@@ -114,8 +114,9 @@
{/if}
<div
use:swipeSidebar={{
enabled: () => 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'}
+205 -60
View File
@@ -1,30 +1,49 @@
/// <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";
import { assert, assertEquals } from "@std/assert";
import type { Browser, Page } from "playwright";
async function painted(page: Page) {
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
);
}
export async function sidebarSwipe(
t: Deno.TestContext,
browser: Browser,
origin: string,
) {
for (
const scenario of [
"right",
const kinds = [
"drag",
"flick",
"slow",
"short",
"arc",
"ambiguous",
"return",
"reverse",
"vertical",
"diagonal",
"short",
"horizontal-scroll",
"horizontal-edge",
"input",
"button",
"button-short",
"link",
"edge",
"cancel",
"multitouch",
"resize",
];
for (
const scenario of [
...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,86 +73,201 @@ 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 =
'<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>';
'<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"><button id="swipe-button" style="display:block;width:280px;height:70px">Ordinary button</button><a id="swipe-link" href="#unexpected" style="display:block;width:280px;height:70px">Ordinary link</a><div style="height:1800px"></div>';
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<void>((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,
}],
});
}
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: "touchEnd",
type: "touchCancel",
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 {
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`,
);
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) =>
@@ -145,17 +279,28 @@ export async function sidebarSwipe(
await page.waitForFunction(
(closing) =>
closing
? document.querySelector("#swipe-horizontal")!.scrollLeft >
200
: document.querySelector("#swipe-horizontal")!.scrollLeft <
200,
? 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 {
await context.close();