Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { render } from "../../test/render";
import { HistorySwipeNavigation } from "./HistorySwipeNavigation";

const navigateHistory = vi.hoisted(() => vi.fn());
vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory }));

function swipe(target: Element, deltaX: number) {
for (const delta of [deltaX, 0]) {
target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true }));
}
}

describe("HistorySwipeNavigation", () => {
it.each([
["scrolling left inside a shadow-root scroller keeps the thread", "shadow", 100, -130, false],
["scrolling right inside a shadow-root scroller keeps the thread", "shadow", 100, 130, false],
[
"scrolling after a shadow scroller remount keeps the thread",
"shadow-remount",
100,
130,
false,
],
["scrolling left inside a scroller keeps the thread", "light", 100, -130, false],
["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true],
["swiping back over a panel without overflow navigates", "none", 0, -130, true],
["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false],
] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => {
navigateHistory.mockReset();
await using screen = await render(<HistorySwipeNavigation />);
const panel = screen.container.appendChild(document.createElement("div"));
const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel;
const viewport = root.appendChild(document.createElement("div"));
const noOverflow = scroller === "none" || scroller === "closed-dialog";
viewport.style.cssText = `width: 200px; overflow-x: ${noOverflow ? "visible" : "auto"}`;
const content = viewport.appendChild(document.createElement("div"));
content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`;
viewport.scrollLeft = scrollLeft;

if (scroller === "closed-dialog") {
panel.setAttribute("role", "dialog");
content.dispatchEvent(new WheelEvent("wheel", { deltaX: -1, bubbles: true, composed: true }));
panel.remove();
screen.container.appendChild(viewport);
}

if (scroller === "shadow-remount") {
content.dispatchEvent(new WheelEvent("wheel", { deltaX: 1, bubbles: true, composed: true }));
const replacement = viewport.cloneNode(true) as HTMLDivElement;
viewport.replaceWith(replacement);
replacement.scrollLeft = scrollLeft;
swipe(replacement, deltaX);
} else {
swipe(content, deltaX);
}

expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0);
});
});
25 changes: 12 additions & 13 deletions apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const MOMENTUM_SAMPLE_SIZE = 5;

type SwipeBounds = { left: number; top: number; width: number; height: number };
type SwipeGesture =
| { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number }
| { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number }
| {
kind: "tracking";
direction: HistoryDirection;
Expand All @@ -37,13 +37,10 @@ function readMainContentBounds(): SwipeBounds {
return { left, top, width, height };
}

function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean {
for (
let element = target instanceof Element ? target : null;
element;
element = element.parentElement
) {
if (element.scrollWidth <= element.clientWidth) continue;
// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it.
function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean {
for (const element of path) {
if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue;
const { overflowX } = getComputedStyle(element);
if (overflowX !== "auto" && overflowX !== "scroll") continue;
const canScroll =
Expand All @@ -58,10 +55,12 @@ function canScrollHorizontally(target: EventTarget | null, deltaX: number): bool
function lockSwipeAxis(
pending: Extract<SwipeGesture, { kind: "pending" }>,
): Exclude<SwipeGesture, { kind: "pending" }> {
const { target, sumX, sumY } = pending;
const { path, sumX, sumY } = pending;
if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" };
if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" };
if (canScrollHorizontally(target, sumX)) return { kind: "ignored" };
if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) {
return { kind: "ignored" };
}
if (canScrollHorizontally(path, sumX)) return { kind: "ignored" };
const direction = sumX < 0 ? "back" : "forward";
if (!canNavigateHistory(direction)) return { kind: "ignored" };
return {
Expand Down Expand Up @@ -119,14 +118,14 @@ export function HistorySwipeNavigation() {
gesture =
event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL
? { kind: "ignored" }
: { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 };
: { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 };
}
if (gesture.kind === "ignored") return;
if (gesture.kind === "pending") {
gesture.sumX += event.deltaX;
gesture.sumY += event.deltaY;
if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return;
gesture = lockSwipeAxis(gesture);
gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] });
if (gesture.kind === "ignored") return;
} else {
const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX;
Expand Down
Loading