fix: make mobile sidebar swipes track touch gestures
This commit is contained in:
@@ -708,6 +708,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition-delay: 0s, var(--sidebar-motion-duration);
|
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 .sidebar-frame-content,
|
||||||
.sidebar-frame.folded .sidebar-frame-content {
|
.sidebar-frame.folded .sidebar-frame-content {
|
||||||
transform: none;
|
transform: none;
|
||||||
|
|||||||
@@ -15,19 +15,27 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
|||||||
function fixture(html = "<p>Page content</p>") {
|
function fixture(html = "<p>Page content</p>") {
|
||||||
const node = document.createElement("div");
|
const node = document.createElement("div");
|
||||||
node.innerHTML = html;
|
node.innerHTML = html;
|
||||||
|
const target = node.firstElementChild!;
|
||||||
|
const panel = document.createElement("aside");
|
||||||
|
panel.className = "sidebar-frame";
|
||||||
|
node.append(panel);
|
||||||
document.body.append(node);
|
document.body.append(node);
|
||||||
let enabled = true;
|
let enabled = true;
|
||||||
let isOpen = initiallyOpen;
|
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) => {
|
const open = vi.fn((next: boolean) => {
|
||||||
isOpen = next;
|
isOpen = next;
|
||||||
|
action.update(options());
|
||||||
});
|
});
|
||||||
|
const options = () => ({ enabled, open: isOpen, route, setOpen: open });
|
||||||
const listeners = vi.spyOn(node, "addEventListener");
|
const listeners = vi.spyOn(node, "addEventListener");
|
||||||
destroy = swipeSidebar(node, {
|
const action = swipeSidebar(node, options());
|
||||||
enabled: () => enabled,
|
destroy = action.destroy;
|
||||||
isOpen: () => isOpen,
|
const clientX = (x: number) => initiallyOpen ? window.innerWidth - x : x;
|
||||||
setOpen: open,
|
|
||||||
}).destroy;
|
|
||||||
const target = node.firstElementChild!;
|
|
||||||
const send = (
|
const send = (
|
||||||
type: string,
|
type: string,
|
||||||
x: number,
|
x: number,
|
||||||
@@ -35,77 +43,156 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
|||||||
time = 100,
|
time = 100,
|
||||||
count = 1,
|
count = 1,
|
||||||
id = 1,
|
id = 1,
|
||||||
|
cancelable = true,
|
||||||
) => {
|
) => {
|
||||||
const touch = {
|
const touch = { identifier: id, clientX: clientX(x), clientY: y };
|
||||||
identifier: id,
|
const event = new Event(type, { bubbles: true, cancelable });
|
||||||
clientX: initiallyOpen ? window.innerWidth - x : x,
|
|
||||||
clientY: y,
|
|
||||||
};
|
|
||||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
||||||
Object.defineProperties(event, {
|
Object.defineProperties(event, {
|
||||||
touches: { value: type === "touchend" ? [] : Array(count).fill(touch) },
|
touches: { value: type === "touchend" ? [] : Array(count).fill(touch) },
|
||||||
changedTouches: { value: [touch] },
|
changedTouches: { value: [touch] },
|
||||||
timeStamp: { value: time },
|
timeStamp: { value: time },
|
||||||
});
|
});
|
||||||
target.dispatchEvent(event);
|
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 {
|
return {
|
||||||
node,
|
node,
|
||||||
target,
|
target,
|
||||||
|
panel,
|
||||||
open,
|
open,
|
||||||
send,
|
send,
|
||||||
|
click,
|
||||||
listeners,
|
listeners,
|
||||||
flipState: () => {
|
flipState: () => {
|
||||||
isOpen = !isOpen;
|
isOpen = !isOpen;
|
||||||
|
action.update(options());
|
||||||
},
|
},
|
||||||
disable: () => {
|
disable: () => {
|
||||||
enabled = false;
|
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();
|
const { node, open, send, listeners } = fixture();
|
||||||
send("touchstart", 120, 200);
|
expect(send("touchstart", 120, 200).defaultPrevented).toBe(false);
|
||||||
send("touchmove", 150, 203, 150);
|
expect(node.classList.contains("sidebar-swiping")).toBe(false);
|
||||||
send("touchmove", 230, 205, 200);
|
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();
|
expect(open).not.toHaveBeenCalled();
|
||||||
send("touchend", 230, 205, 250);
|
expect(node.style.getPropertyValue("--sidebar-swipe-offset")).toBe(
|
||||||
expect(open).toHaveBeenCalledTimes(1);
|
"-150px",
|
||||||
expect(open).toHaveBeenCalledWith(!initiallyOpen);
|
);
|
||||||
|
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("");
|
expect(node.style.touchAction).toBe("");
|
||||||
for (const [name, , options] of listeners.mock.calls) {
|
expect(listeners.mock.calls.find(([name]) => name === "touchmove")?.[2])
|
||||||
if (String(name).startsWith("touch")) {
|
.toEqual({ passive: false });
|
||||||
expect(options).toEqual({ passive: true });
|
});
|
||||||
}
|
|
||||||
}
|
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 (
|
for (
|
||||||
const [name, points] of [
|
const [name, points] of [
|
||||||
["tap", [[120, 200], [122, 201]]],
|
["tap", [[120, 200, 100], [122, 201, 130]]],
|
||||||
["short horizontal motion", [[120, 200], [170, 202]]],
|
["short slow drag", [[120, 200, 100], [160, 202, 500]]],
|
||||||
["vertical scroll", [[120, 200], [125, 250], [240, 252]]],
|
["tiny fast flick", [[120, 200, 100], [138, 202, 120]]],
|
||||||
["diagonal motion", [[120, 200], [140, 214], [230, 218]]],
|
["vertical intent", [[120, 200, 100], [125, 240, 150], [280, 242, 200]]],
|
||||||
["opposite direction", [[240, 200], [210, 202], [120, 203]]],
|
["ambiguous diagonal throughout", [[120, 200, 100], [135, 215, 130], [
|
||||||
["vertical excursion before returning", [
|
200,
|
||||||
[120, 200],
|
270,
|
||||||
[150, 202],
|
200,
|
||||||
[230, 240],
|
]]],
|
||||||
[240, 201],
|
["opposite direction", [[240, 200, 100], [210, 202, 140], [
|
||||||
]],
|
120,
|
||||||
["browser left edge", [[10, 200], [150, 201]]],
|
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
|
] as const
|
||||||
) {
|
) {
|
||||||
test(`${name} does not open the sidebar`, () => {
|
test(`${name} leaves the sidebar in its original state`, async () => {
|
||||||
const { open, send } = fixture();
|
const { node, open, send } = fixture();
|
||||||
points.forEach(([x, y], i) =>
|
points.forEach(([x, y, time], i) =>
|
||||||
send(i === 0 ? "touchstart" : "touchmove", x, y, 100 + i * 30)
|
send(i === 0 ? "touchstart" : "touchmove", x, y, time)
|
||||||
);
|
);
|
||||||
const [x, y] = points[points.length - 1];
|
const [x, y, time] = points.at(-1)!;
|
||||||
send("touchend", x, y, 300);
|
send("touchend", x, y, time + 10);
|
||||||
|
await Promise.resolve();
|
||||||
expect(open).not.toHaveBeenCalled();
|
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">',
|
'<input type="range">',
|
||||||
"<textarea></textarea>",
|
"<textarea></textarea>",
|
||||||
"<select></select>",
|
"<select></select>",
|
||||||
"<button>Action</button>",
|
|
||||||
'<div contenteditable="true">Editor</div>',
|
'<div contenteditable="true">Editor</div>',
|
||||||
'<div role="slider">Slider</div>',
|
'<div role="slider">Slider</div>',
|
||||||
"<dialog open>Dialog</dialog>",
|
"<dialog open>Dialog</dialog>",
|
||||||
|
"<div data-sidebar-swipe-ignore>Custom gesture</div>",
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
test(`does not claim control gestures: ${html}`, () => {
|
test(`keeps native controls unclaimed: ${html}`, () => {
|
||||||
const { open, send } = fixture(html);
|
const { open, send, listeners } = fixture(html);
|
||||||
send("touchstart", 120, 200);
|
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(open).not.toHaveBeenCalled();
|
||||||
|
expect(listeners.mock.calls.some(([name]) => name === "touchmove")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("horizontal scroll container remains excluded even at its boundary", () => {
|
test("nested horizontal scrolling remains excluded even at its boundary", () => {
|
||||||
const { node, open, send } = fixture();
|
const { target, open, send } = fixture();
|
||||||
node.style.overflowX = "auto";
|
(target as HTMLElement).style.overflowX = "auto";
|
||||||
Object.defineProperty(node, "scrollWidth", { value: 800 });
|
Object.defineProperty(target, "scrollWidth", { value: 800 });
|
||||||
Object.defineProperty(node, "clientWidth", { value: 300 });
|
Object.defineProperty(target, "clientWidth", { value: 300 });
|
||||||
send("touchstart", 120, 200);
|
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(open).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,51 +237,76 @@ describe.each([false, true])("sidebar initially open=%s", (initiallyOpen) => {
|
|||||||
"multitouch",
|
"multitouch",
|
||||||
"disabled",
|
"disabled",
|
||||||
"state-change",
|
"state-change",
|
||||||
"held",
|
"navigation",
|
||||||
|
"resize",
|
||||||
|
"hidden",
|
||||||
"identifier",
|
"identifier",
|
||||||
"destroy",
|
"destroy",
|
||||||
"selection",
|
"selection",
|
||||||
|
"native-claimed",
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
test(`abandons the gesture after ${reason}`, () => {
|
test(`cancels preview after ${reason}`, () => {
|
||||||
const { target, open, send, disable, flipState } = fixture();
|
const { node, target, open, send, disable, flipState, navigate } =
|
||||||
|
fixture();
|
||||||
send("touchstart", 120, 200);
|
send("touchstart", 120, 200);
|
||||||
send("touchmove", 160, 202, 150);
|
send("touchmove", 160, 202, 150);
|
||||||
|
expect(node.classList.contains("sidebar-swiping")).toBe(true);
|
||||||
if (reason === "scroll" || reason === "touchcancel") {
|
if (reason === "scroll" || reason === "touchcancel") {
|
||||||
target.dispatchEvent(
|
target.dispatchEvent(
|
||||||
new Event(reason, { bubbles: reason === "touchcancel" }),
|
new Event(reason, { bubbles: reason === "touchcancel" }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (reason === "multitouch") {
|
if (reason === "multitouch") send("touchstart", 160, 202, 160, 2);
|
||||||
send("touchstart", 160, 202, 160, 2);
|
|
||||||
}
|
|
||||||
if (reason === "disabled") disable();
|
if (reason === "disabled") disable();
|
||||||
if (reason === "state-change") flipState();
|
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 === "destroy") destroy?.();
|
||||||
if (reason === "selection") {
|
if (reason === "selection") {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(target);
|
range.selectNodeContents(target);
|
||||||
document.getSelection()?.addRange(range);
|
document.getSelection()?.addRange(range);
|
||||||
}
|
}
|
||||||
send(
|
if (reason === "native-claimed") {
|
||||||
"touchend",
|
send("touchmove", 200, 203, 200, 1, 1, false);
|
||||||
240,
|
}
|
||||||
203,
|
send("touchend", 290, 203, 300, 1, reason === "identifier" ? 2 : 1);
|
||||||
reason === "held" ? 1000 : 300,
|
|
||||||
1,
|
|
||||||
reason === "identifier" ? 2 : 1,
|
|
||||||
);
|
|
||||||
expect(open).not.toHaveBeenCalled();
|
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", () => {
|
for (const html of ['<a href="#link">Link</a>', "<button>Action</button>"]) {
|
||||||
const { target, open, send } = fixture('<a href="#link">Link text</a>');
|
test(`swipe from ${html} suppresses only its click, not later taps or keyboard activation`, async () => {
|
||||||
const click = new Event("click", { bubbles: true, cancelable: true });
|
const { open, send, click, target } = fixture(html);
|
||||||
target.dispatchEvent(click);
|
const activate = vi.fn();
|
||||||
expect(click.defaultPrevented).toBe(false);
|
target.addEventListener("click", activate);
|
||||||
send("touchstart", 120, 200);
|
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(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 = {
|
type Options = {
|
||||||
enabled: () => boolean;
|
enabled: boolean;
|
||||||
isOpen: () => boolean;
|
open: boolean;
|
||||||
|
route: string;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observe, never claim, the touch stream: native scrolling/zoom and child controls
|
type Sample = { x: number; time: number };
|
||||||
// retain their defaults. A deliberate right swipe opens; a left swipe closes.
|
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) {
|
export function swipeSidebar(node: HTMLElement, options: Options) {
|
||||||
let gesture: {
|
let gesture: {
|
||||||
id: number;
|
id: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
started: number;
|
|
||||||
horizontal: boolean;
|
horizontal: boolean;
|
||||||
wasOpen: boolean;
|
wasOpen: boolean;
|
||||||
|
width: number;
|
||||||
|
startOffset: number;
|
||||||
|
offset: number;
|
||||||
|
peak: number;
|
||||||
|
samples: Sample[];
|
||||||
} | null = null;
|
} | null = null;
|
||||||
const cancel = () => {
|
let suppressedClick: { x: number; y: number; until: number } | null = null;
|
||||||
gesture = null;
|
let cleanupVersion = 0;
|
||||||
};
|
const view = node.ownerDocument.defaultView;
|
||||||
const selectedText = () =>
|
const selectedText = () =>
|
||||||
node.ownerDocument.getSelection()?.isCollapsed === false;
|
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 {
|
function excluded(target: EventTarget | null): boolean {
|
||||||
let element = target instanceof Element ? target : null;
|
let element = target instanceof Element ? target : null;
|
||||||
if (
|
if (
|
||||||
!element || element.closest(
|
!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;
|
) 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) {
|
for (; element; element = element.parentElement) {
|
||||||
if (
|
if (
|
||||||
element.scrollWidth > element.clientWidth + 1 &&
|
element.scrollWidth > element.clientWidth + 1 &&
|
||||||
@@ -41,47 +64,91 @@ export function swipeSidebar(node: HTMLElement, options: Options) {
|
|||||||
|
|
||||||
function start(event: TouchEvent) {
|
function start(event: TouchEvent) {
|
||||||
cancel();
|
cancel();
|
||||||
|
suppressedClick = null; // A new tap must not inherit the previous swipe.
|
||||||
if (
|
if (
|
||||||
!options.enabled() || event.defaultPrevented ||
|
!options.enabled || event.defaultPrevented ||
|
||||||
event.touches.length !== 1 ||
|
event.touches.length !== 1 || selectedText() || excluded(event.target)
|
||||||
selectedText() || excluded(event.target)
|
|
||||||
) return;
|
) return;
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
const width = node.ownerDocument.defaultView?.innerWidth ?? 0;
|
const viewportWidth = view?.innerWidth ?? 0;
|
||||||
// Leave OS/browser edge-navigation gestures alone.
|
if (touch.clientX < 24 || touch.clientX > viewportWidth - 24) return;
|
||||||
if (touch.clientX < 24 || touch.clientX > width - 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 = {
|
gesture = {
|
||||||
id: touch.identifier,
|
id: touch.identifier,
|
||||||
x: touch.clientX,
|
x: touch.clientX,
|
||||||
y: touch.clientY,
|
y: touch.clientY,
|
||||||
started: event.timeStamp,
|
|
||||||
horizontal: false,
|
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 {
|
function track(event: TouchEvent, touch: Touch): boolean {
|
||||||
if (!gesture) return false;
|
const current = gesture;
|
||||||
const dx = (touch.clientX - gesture.x) * (gesture.wasOpen ? -1 : 1);
|
if (!current) return false;
|
||||||
const dy = Math.abs(touch.clientY - gesture.y);
|
|
||||||
if (
|
if (
|
||||||
!options.enabled() || options.isOpen() !== gesture.wasOpen ||
|
!options.enabled || options.open !== current.wasOpen ||
|
||||||
event.defaultPrevented || selectedText() ||
|
event.defaultPrevented || selectedText() ||
|
||||||
touch.identifier !== gesture.id ||
|
touch.identifier !== current.id
|
||||||
event.timeStamp - gesture.started > 700 ||
|
|
||||||
dy > 32 || dx < -12
|
|
||||||
) {
|
) {
|
||||||
cancel();
|
cancel();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!gesture.horizontal && Math.hypot(dx, dy) >= 12) {
|
const rawDx = touch.clientX - current.x;
|
||||||
if (dx < 2.5 * dy) {
|
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();
|
cancel();
|
||||||
return false;
|
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) {
|
function move(event: TouchEvent) {
|
||||||
@@ -93,28 +160,80 @@ export function swipeSidebar(node: HTMLElement, options: Options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function end(event: TouchEvent) {
|
function end(event: TouchEvent) {
|
||||||
const nextOpen = !gesture?.wasOpen;
|
// Do not turn an unclaimed touch into a swipe only after the finger lifts.
|
||||||
const accepted = event.touches.length === 0 &&
|
if (
|
||||||
event.changedTouches.length === 1 &&
|
!gesture?.horizontal || event.touches.length !== 0 ||
|
||||||
track(event, event.changedTouches[0]);
|
event.changedTouches.length !== 1 ||
|
||||||
|
!track(event, event.changedTouches[0])
|
||||||
|
) {
|
||||||
cancel();
|
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 };
|
const passive = { passive: true };
|
||||||
node.addEventListener("touchstart", start, passive);
|
node.addEventListener("touchstart", start, passive);
|
||||||
node.addEventListener("touchmove", move, passive);
|
node.addEventListener("touchend", end, { passive: false });
|
||||||
node.addEventListener("touchend", end, passive);
|
|
||||||
node.addEventListener("touchcancel", cancel, passive);
|
node.addEventListener("touchcancel", cancel, passive);
|
||||||
node.addEventListener("scroll", cancel, { passive: true, capture: true });
|
node.addEventListener("scroll", cancel, { passive: true, capture: true });
|
||||||
|
node.addEventListener("click", click, true);
|
||||||
|
view?.addEventListener("resize", cancel);
|
||||||
|
node.ownerDocument.addEventListener("visibilitychange", cancel);
|
||||||
return {
|
return {
|
||||||
|
update(next: Options) {
|
||||||
|
if (
|
||||||
|
!next.enabled || next.open !== options.open ||
|
||||||
|
next.route !== options.route
|
||||||
|
) cancel();
|
||||||
|
options = next;
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
cancel();
|
cancel();
|
||||||
node.removeEventListener("touchstart", start);
|
node.removeEventListener("touchstart", start);
|
||||||
node.removeEventListener("touchmove", move);
|
|
||||||
node.removeEventListener("touchend", end);
|
node.removeEventListener("touchend", end);
|
||||||
node.removeEventListener("touchcancel", cancel);
|
node.removeEventListener("touchcancel", cancel);
|
||||||
node.removeEventListener("scroll", cancel, true);
|
node.removeEventListener("scroll", cancel, true);
|
||||||
|
node.removeEventListener("click", click, true);
|
||||||
|
view?.removeEventListener("resize", cancel);
|
||||||
|
node.ownerDocument.removeEventListener("visibilitychange", cancel);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,8 +114,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
use:swipeSidebar={{
|
use:swipeSidebar={{
|
||||||
enabled: () => mobileLayout.current && authenticated,
|
enabled: mobileLayout.current && authenticated,
|
||||||
isOpen: () => sidebarOpen,
|
open: sidebarOpen,
|
||||||
|
route: page.url.href,
|
||||||
setOpen: (open) => { sidebarTransientOpen = open; },
|
setOpen: (open) => { sidebarTransientOpen = open; },
|
||||||
}}
|
}}
|
||||||
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
|
class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
/// <reference lib="dom" />
|
/// <reference lib="dom" />
|
||||||
// Native Chromium touch input against the production shell; HTTP uses the auth fixture.
|
// Native Chromium touch input against the production shell; HTTP uses the auth fixture.
|
||||||
import { assertEquals } from "@std/assert";
|
import { assert, assertEquals } from "@std/assert";
|
||||||
import type { Browser } from "playwright";
|
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(
|
export async function sidebarSwipe(
|
||||||
t: Deno.TestContext,
|
t: Deno.TestContext,
|
||||||
browser: Browser,
|
browser: Browser,
|
||||||
origin: string,
|
origin: string,
|
||||||
) {
|
) {
|
||||||
for (
|
const kinds = [
|
||||||
const scenario of [
|
"drag",
|
||||||
"right",
|
"flick",
|
||||||
|
"slow",
|
||||||
|
"short",
|
||||||
|
"arc",
|
||||||
|
"ambiguous",
|
||||||
|
"return",
|
||||||
|
"reverse",
|
||||||
"vertical",
|
"vertical",
|
||||||
"diagonal",
|
"diagonal",
|
||||||
"short",
|
|
||||||
"horizontal-scroll",
|
"horizontal-scroll",
|
||||||
|
"horizontal-edge",
|
||||||
"input",
|
"input",
|
||||||
|
"button",
|
||||||
|
"button-short",
|
||||||
|
"link",
|
||||||
|
"edge",
|
||||||
|
"cancel",
|
||||||
|
"multitouch",
|
||||||
|
"resize",
|
||||||
|
];
|
||||||
|
for (
|
||||||
|
const scenario of [
|
||||||
|
...kinds,
|
||||||
|
...kinds.map((kind) => `close-${kind}`),
|
||||||
"desktop",
|
"desktop",
|
||||||
"anonymous",
|
"anonymous",
|
||||||
"close",
|
|
||||||
"close-vertical",
|
|
||||||
"close-diagonal",
|
|
||||||
"close-short",
|
|
||||||
"close-horizontal-scroll",
|
|
||||||
"close-input",
|
|
||||||
"close-opposite",
|
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
await t.step(`native touch swipe: ${scenario}`, async () => {
|
await t.step(`native touch swipe: ${scenario}`, async () => {
|
||||||
@@ -32,7 +51,7 @@ export async function sidebarSwipe(
|
|||||||
viewport: { width: scenario === "desktop" ? 1200 : 390, height: 844 },
|
viewport: { width: scenario === "desktop" ? 1200 : 390, height: 844 },
|
||||||
hasTouch: true,
|
hasTouch: true,
|
||||||
isMobile: scenario !== "desktop",
|
isMobile: scenario !== "desktop",
|
||||||
reducedMotion: "reduce",
|
reducedMotion: scenario.endsWith("drag") ? "no-preference" : "reduce",
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -54,86 +73,201 @@ export async function sidebarSwipe(
|
|||||||
if (scenario === "desktop") {
|
if (scenario === "desktop") {
|
||||||
await page.getByRole("button", { name: "Unpin sidebar" }).click();
|
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;
|
const kind = closing ? scenario.slice(6) : scenario;
|
||||||
if (closing) {
|
if (closing) {
|
||||||
await page.getByRole("button", { name: "Show sidebar", exact: true })
|
await page.getByRole("button", { name: "Show sidebar", exact: true })
|
||||||
.click();
|
.click();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
Math.abs(
|
||||||
|
document.querySelector(".sidebar-frame")!.getBoundingClientRect()
|
||||||
|
.left,
|
||||||
|
) < 1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const surface = closing ? ".sidebar-frame-content" : "main";
|
const surface = closing ? ".sidebar-frame-content" : "main";
|
||||||
// Supply deterministic scroll/control surfaces inside the actual shell.
|
|
||||||
await page.locator(surface).evaluate((main) => {
|
await page.locator(surface).evaluate((main) => {
|
||||||
const area = document.createElement("div");
|
const area = document.createElement("div");
|
||||||
area.innerHTML =
|
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);
|
main.prepend(area);
|
||||||
});
|
});
|
||||||
const target = kind === "horizontal-scroll"
|
const target = kind.startsWith("horizontal-")
|
||||||
? "#swipe-horizontal"
|
? "#swipe-horizontal"
|
||||||
: kind === "input"
|
: kind === "input"
|
||||||
? "#swipe-input"
|
? "#swipe-input"
|
||||||
|
: kind.startsWith("button")
|
||||||
|
? "#swipe-button"
|
||||||
|
: kind === "link"
|
||||||
|
? "#swipe-link"
|
||||||
: "#swipe-plain";
|
: "#swipe-plain";
|
||||||
const box = (await page.locator(target).boundingBox())!;
|
|
||||||
if (kind === "horizontal-scroll") {
|
if (kind === "horizontal-scroll") {
|
||||||
await page.locator(target).evaluate((node) => {
|
await page.locator(target).evaluate((node) => {
|
||||||
node.scrollLeft = 200;
|
node.scrollLeft = 200;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await page.evaluate(() =>
|
await painted(page);
|
||||||
new Promise<void>((resolve) =>
|
const box = (await page.locator(target).boundingBox())!;
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
const startX = kind === "edge"
|
||||||
)
|
? (closing ? 380 : 10)
|
||||||
);
|
: box.x + (closing ? 245 : 35);
|
||||||
const client = await context.newCDPSession(page);
|
|
||||||
const startX = box.x + (closing ? 210 : 60);
|
|
||||||
const startY = box.y + (kind === "vertical" ? 150 : box.height / 2);
|
const startY = box.y + (kind === "vertical" ? 150 : box.height / 2);
|
||||||
const dx = (closing && kind !== "opposite" ? -1 : 1) *
|
const direction = closing ? -1 : 1;
|
||||||
(kind === "vertical" ? 8 : kind === "short" ? 40 : 150);
|
// dx is measured toward the requested state. Explicit event timestamps
|
||||||
const dy = kind === "vertical" ? -130 : kind === "diagonal" ? 100 : 3;
|
// 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", {
|
await client.send("Input.dispatchTouchEvent", {
|
||||||
type: "touchStart",
|
type: "touchStart",
|
||||||
|
timestamp: base,
|
||||||
touchPoints: [{ x: startX, y: startY, id: 1 }],
|
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", {
|
await client.send("Input.dispatchTouchEvent", {
|
||||||
type: "touchMove",
|
type: "touchMove",
|
||||||
|
timestamp: base + time / 1000,
|
||||||
touchPoints: [{
|
touchPoints: [{
|
||||||
x: startX + dx * i / 8,
|
x: startX + dx * direction,
|
||||||
y: startY + dy * i / 8,
|
y: startY + dy,
|
||||||
id: 1,
|
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", {
|
await client.send("Input.dispatchTouchEvent", {
|
||||||
type: "touchEnd",
|
type: "touchCancel",
|
||||||
touchPoints: [],
|
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 {
|
} 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(
|
assertEquals(
|
||||||
await page.locator(".app-shell.sidebar-open").count(),
|
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") {
|
if (kind === "vertical") {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(selector) =>
|
(selector) =>
|
||||||
@@ -145,17 +279,28 @@ export async function sidebarSwipe(
|
|||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(closing) =>
|
(closing) =>
|
||||||
closing
|
closing
|
||||||
? document.querySelector("#swipe-horizontal")!.scrollLeft >
|
? document.querySelector("#swipe-horizontal")!.scrollLeft > 200
|
||||||
200
|
: document.querySelector("#swipe-horizontal")!.scrollLeft < 200,
|
||||||
: document.querySelector("#swipe-horizontal")!.scrollLeft <
|
|
||||||
200,
|
|
||||||
closing,
|
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") {
|
if (scenario === "anonymous") {
|
||||||
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
assertEquals(await page.locator(".sidebar-frame").count(), 0);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
assertEquals(errors, []);
|
assertEquals(errors, []);
|
||||||
} finally {
|
} finally {
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user