fix(desktop): lock horizontal webview pan (Magic Mouse side-scroll) (#1480)

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-07-02 22:31:40 -04:00
committed by GitHub
parent 228122fddc
commit 5d4edf1535
3 changed files with 128 additions and 22 deletions

View File

@@ -25,6 +25,19 @@ function isScrollableY(element: HTMLElement) {
return element.scrollHeight > element.clientHeight + BOUNDARY_EPSILON_PX;
}
function isScrollableX(element: HTMLElement) {
if (isDocumentElement(element)) {
return false;
}
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW_VALUES.has(style.overflowX)) {
return false;
}
return element.scrollWidth > element.clientWidth + BOUNDARY_EPSILON_PX;
}
function canScrollY(element: HTMLElement, deltaY: number) {
if (deltaY < 0) {
return element.scrollTop > BOUNDARY_EPSILON_PX;
@@ -34,6 +47,15 @@ function canScrollY(element: HTMLElement, deltaY: number) {
return element.scrollTop < maxScrollTop - BOUNDARY_EPSILON_PX;
}
function canScrollX(element: HTMLElement, deltaX: number) {
if (deltaX < 0) {
return element.scrollLeft > BOUNDARY_EPSILON_PX;
}
const maxScrollLeft = element.scrollWidth - element.clientWidth;
return element.scrollLeft < maxScrollLeft - BOUNDARY_EPSILON_PX;
}
function isConversationScroller(element: HTMLElement) {
return Boolean(element.closest(CONVERSATION_SCROLL_SELECTOR));
}
@@ -44,21 +66,29 @@ function isConversationScroller(element: HTMLElement) {
* Buzz is laid out as fixed-height nested panes. On macOS, a wheel/trackpad
* gesture that starts over a non-scrollable pane (or over a scrollable pane at
* its boundary) can still be handed to the WKWebView viewport, which rubber-
* bands the entire app and reveals a blank strip above/below the UI. CSS
* bands the entire app and reveals a blank strip beside the UI. CSS
* `overscroll-behavior` is not enough for all of the empty/header/footer hit
* targets in the webview, so this capture listener consumes only gestures that
* otherwise have nowhere app-local to scroll.
* otherwise have nowhere app-local to scroll. Both axes are locked: vertical
* and horizontal pans are each checked against containers that can actually
* move in that direction.
*
* Real scrolling is left alone: if any scroll container under the pointer can
* move in the wheel direction, the browser handles it normally. At boundaries,
* only containers marked with `data-buzz-conversation-scroll` are allowed to
* receive the gesture so their own local elastic affordance can remain; every
* other boundary is locked and cannot chain to the viewport.
* move in the wheel direction, the browser handles it normally. At vertical
* boundaries, only containers marked with `data-buzz-conversation-scroll` are
* allowed to receive the gesture so their own local elastic affordance can
* remain; every other boundary — including all horizontal ones — is locked and
* cannot chain to the viewport.
*/
export function useWebviewScrollBoundaryLock() {
React.useEffect(() => {
function handleWheel(event: WheelEvent) {
if (event.defaultPrevented || event.deltaY === 0 || event.ctrlKey) {
if (event.defaultPrevented || event.ctrlKey) {
return;
}
const { deltaX, deltaY } = event;
if (deltaX === 0 && deltaY === 0) {
return;
}
@@ -66,17 +96,33 @@ export function useWebviewScrollBoundaryLock() {
let firstScrollable: HTMLElement | null = null;
for (const target of path) {
if (!isHTMLElement(target) || !isScrollableY(target)) {
if (!isHTMLElement(target)) {
continue;
}
const scrollableY = deltaY !== 0 && isScrollableY(target);
const scrollableX = deltaX !== 0 && isScrollableX(target);
if (!scrollableY && !scrollableX) {
continue;
}
firstScrollable ??= target;
if (canScrollY(target, event.deltaY)) {
if (
(scrollableY && canScrollY(target, deltaY)) ||
(scrollableX && canScrollX(target, deltaX))
) {
return;
}
}
if (firstScrollable && isConversationScroller(firstScrollable)) {
// Only the vertical elastic affordance of conversation scrollers is
// preserved; a predominantly horizontal gesture must never pan the
// webview, even over a conversation pane.
if (
firstScrollable &&
isConversationScroller(firstScrollable) &&
Math.abs(deltaY) >= Math.abs(deltaX)
) {
return;
}

View File

@@ -111,6 +111,20 @@
width: 100%;
}
/*
* The app is a fixed-height shell — the document itself must never scroll
* or rubber-band. `overscroll-behavior: none` stops scroll chaining from
* inner containers reaching the viewport on both axes (the Magic Mouse
* horizontal pan escaped through here), and `overflow: hidden` removes any
* document-level scrollability. useWebviewScrollBoundaryLock handles the
* wheel-event side; this is the CSS backstop.
*/
html,
body {
overflow: hidden;
overscroll-behavior: none;
}
mark {
background-color: transparent;
color: inherit;

View File

@@ -5,10 +5,10 @@ import { installMockBridge } from "../helpers/bridge";
async function dispatchWheelPrevented(
page: import("@playwright/test").Page,
selector: string,
deltaY: number,
deltas: { deltaX?: number; deltaY?: number },
) {
return page.evaluate(
({ selector, deltaY }) => {
({ selector, deltaX, deltaY }) => {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Missing element for selector: ${selector}`);
@@ -17,12 +17,13 @@ async function dispatchWheelPrevented(
const event = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
deltaX,
deltaY,
});
element.dispatchEvent(event);
return event.defaultPrevented;
},
{ selector, deltaY },
{ selector, deltaX: deltas.deltaX ?? 0, deltaY: deltas.deltaY ?? 0 },
);
}
@@ -38,23 +39,68 @@ test("locks viewport rubber-band outside conversation scrollers", async ({
await expect(page.getByTestId("message-timeline")).toBeVisible();
await expect(
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', -120),
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', -120),
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(
page,
'[data-testid="app-sidebar-scroll-anchor"]',
-120,
),
dispatchWheelPrevented(page, '[data-testid="app-sidebar-scroll-anchor"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="chat-title"]', -120),
dispatchWheelPrevented(page, '[data-testid="chat-title"]', {
deltaY: -120,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', -120),
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaY: -120,
}),
).resolves.toBe(false);
});
test("locks horizontal viewport pan everywhere", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
for (const deltaX of [-120, 120]) {
await expect(
dispatchWheelPrevented(page, '[data-testid="app-top-chrome"]', {
deltaX,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="sidebar-pinned-header"]', {
deltaX,
}),
).resolves.toBe(true);
await expect(
dispatchWheelPrevented(page, '[data-testid="chat-title"]', { deltaX }),
).resolves.toBe(true);
// Unlike vertical, horizontal pans over the conversation pane are locked
// too — there is no horizontal elastic affordance.
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaX,
}),
).resolves.toBe(true);
}
// A predominantly vertical gesture with slight horizontal drift still
// reaches the conversation scroller.
await expect(
dispatchWheelPrevented(page, '[data-testid="message-timeline"]', {
deltaX: -10,
deltaY: -120,
}),
).resolves.toBe(false);
});