diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx new file mode 100644 index 0000000000000..b2e6fcaaaf402 --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -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(); + 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); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c020878b25363..767b4285c696f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -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; @@ -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 = @@ -58,10 +55,12 @@ function canScrollHorizontally(target: EventTarget | null, deltaX: number): bool function lockSwipeAxis( pending: Extract, ): Exclude { - 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 { @@ -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;