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 SidebarFrame from '#lib/workspace/sidebar/SidebarFrame.svelte';
|
||||||
import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte';
|
import SidebarToggleIcon from '#lib/workspace/sidebar/SidebarToggleIcon.svelte';
|
||||||
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '#lib/workspace/sidebar/context.ts';
|
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 { createOverrideStack } from '#lib/workspace/sidebar/override-stack.ts';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
@@ -111,7 +112,14 @@
|
|||||||
{#if page.url.pathname !== '/' && page.url.pathname !== '/account'}
|
{#if page.url.pathname !== '/' && page.url.pathname !== '/account'}
|
||||||
<WorkspaceAlerts />
|
<WorkspaceAlerts />
|
||||||
{/if}
|
{/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
|
<SidebarFrame
|
||||||
mode={sidebarMode}
|
mode={sidebarMode}
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { assertEquals } from "@std/assert";
|
import { assertEquals } from "@std/assert";
|
||||||
import { resolve } from "@std/path";
|
import { resolve } from "@std/path";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
|
import { sidebarSwipe } from "./sidebar-swipe.ts";
|
||||||
import {
|
import {
|
||||||
startOwnedProcesses,
|
startOwnedProcesses,
|
||||||
stopOwnedProcesses,
|
stopOwnedProcesses,
|
||||||
@@ -37,6 +38,7 @@ Deno.test("only authenticated account and home show the sidebar; public entry re
|
|||||||
try {
|
try {
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
try {
|
try {
|
||||||
|
await sidebarSwipe(t, browser, origin);
|
||||||
for (const width of [1440, 390]) {
|
for (const width of [1440, 390]) {
|
||||||
for (
|
for (
|
||||||
const [path, mode] of [
|
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