diff --git a/.changeset/scroll-lock-keeps-the-page.md b/.changeset/scroll-lock-keeps-the-page.md new file mode 100644 index 0000000000000..b9a407e820293 --- /dev/null +++ b/.changeset/scroll-lock-keeps-the-page.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] `useScrollLock`, and so Dialog, BottomSheet, Drawer, Lightbox and MobileNav, keeps the page where it is while an overlay is open. The lock used to pin `` with `position: fixed` at minus the scroll offset and scroll back on release: the page's offset read 0 for the whole lock, and on iOS Safari the jump to the top brought the toolbar back, where it stayed after the overlay closed. Now the page gets `overflow: hidden` (on the root, or on `` when the body's own overflow is the one the viewport uses), and where the primary pointer is coarse (phones and tablets), a one-finger pan that would scroll the page is cancelled, which iOS Safari needs because it scrolls an `overflow: hidden` page under a finger. Pans that a scroller inside the overlay can take, drags on a native range input, pinch-zoom, and one-finger drags on a pinch-zoomed page (they pan the zoomed view) still work. The lock no longer pins ``, and `window.scrollY` keeps its value while the lock holds. The lock cancels those pans itself, leaving a touch a gesture recognizer owns to it, and judges each pan after a small slop so a swipe's first wobble does not fix the wrong axis. It follows the browser's scroll chain, so an overlay rendered inside a scrolling container still holds the page: a modal dialog's pans go to the page, not to that container. It also sets `overscroll-behavior-y: none` on the root and ``, which stops the page's overscroll bounce while an overlay is open. MobileNav now takes the same lock: while it is open, the page's bounce stops and one-finger pans are held as behind any other modal, and closing it while another modal holds the page keeps that modal's scrollbar gutter. + +@vjeux diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 6c7bc290781bd..4c83a8680e501 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -332,7 +332,10 @@ a measured, sourced difference exists. four exceptions. While a scroll lock under a modal presentation holds the page, it MAY cancel the moves of a one-finger touch that no recognizer owns when that pan would scroll the page; a second finger is never cancelled - (FR10), and the page keeps pinch-zoom. Once its press is delivered, a + (FR10), and the page keeps pinch-zoom. A modal overlay that takes the page + scroll lock also stops the page's pull-to-refresh and overscroll bounce + while it holds the lock, and releases them with it. Once its press is + delivered, a control that keeps its press through a drag (FR11) owns its touch and MAY cancel that finger's moves. A scroll-edge pan, such as a sheet's pull from a scroll area at its edge (FR14), MAY cancel its touch's moves from the diff --git a/docs/specs/components/BottomSheet/BottomSheetSwitcher.spec.md b/docs/specs/components/BottomSheet/BottomSheetSwitcher.spec.md index a9eabeb2229e0..d8f14bd6b7e1d 100644 --- a/docs/specs/components/BottomSheet/BottomSheetSwitcher.spec.md +++ b/docs/specs/components/BottomSheet/BottomSheetSwitcher.spec.md @@ -146,7 +146,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR2 | Each child sheet owns one native dialog. Opening a sheet presents its dialog once; a handoff presents the entering sheet's dialog above the retained one and closes the retained dialog only after its panel has faded. Returning to a sheet whose dialog is still open beneath a newer one re-presents it so the interactive sheet is the topmost layer. | DEC-1 | | FR3 | During a handoff, the entering sheet is above the retained sheet. A taller retained sheet aligns down to a shorter entering sheet, and the retained sheet fades only after required transform motion completes. Under reduced motion the handoff follows `design:reduced-motion`: no transform motion runs, and the sheets cross-fade (DR2) or change at once (DR3) in the same order. | Current source and transition tests; `design:reduced-motion` DR2, DR3 | | FR4 | Closing retains the outgoing sheet through its exit, then closes the dialog. A modal flow restores focus to the element captured when that modal flow opened. | Current source and focus tests | -| FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, and native focus containment, and locks page scroll: the page does not scroll while a modal flow is presented; the lock is held continuously across a handoff, never released and re-acquired between steps; it still holds beneath a `Dialog` opened from a step; it is released after the flow's exit (DEC-1). `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive. In a non-modal flow the sheet dialogs order themselves with local values inside the switcher's isolated flow wrapper (DEC-1); the page-level value that wrapper carries remains a `spec:AST-027` conformance gap. | DEC-1; `spec:AST-027` FR2, FR4, FR8 | +| FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, and native focus containment, and locks page scroll: the page does not scroll while a modal flow is presented, and pull-to-refresh and the page's bounce stop behind it, because a scroll lock under a modal holds the page (`spec:AST-094` FR19), as behind a UIKit modal presentation; the lock is held continuously across a handoff, never released and re-acquired between steps; it still holds beneath a `Dialog` opened from a step; it is released after the flow's exit (DEC-1). `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive. In a non-modal flow the sheet dialogs order themselves with local values inside the switcher's isolated flow wrapper (DEC-1); the page-level value that wrapper carries remains a `spec:AST-027` conformance gap. | DEC-1; `spec:AST-027` FR2, FR4, FR8 | | FR6 | The active child purpose governs implicit dismissal: `info` allows Escape, scrim click, and swipe; `form` allows Escape; `required` blocks all three and exposes `alertdialog`. | Current BottomSheet docs and switcher tests | | FR7 | While visible, the switcher participates in `family:overlay-dismissal`; one Escape or platform close request reaches only the topmost present layer, and descendants receive a deeper logical layer scope. | `family:overlay-dismissal` and the audit regression test | | FR8 | One backdrop paints the flow's scrim at a time: the retained sheet's dialog keeps it through a handoff and the entering dialog takes it over without a fade when the retained dialog closes. A stale gesture from an outgoing sheet cannot change the flow's scrim, and unmounting a retained sheet cannot keep its dialog open or the flow's modal layer held. | DEC-1; current tests | @@ -259,7 +259,7 @@ Consumer migration instructions belong in consumer docs and release notes. | Contract | Verification | Representative states | Mutation or failure expectation | Audit section | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close; a handoff under reduced motion on a coarse and a fine pointer | A child becomes interactive at the wrong time, a retained child disappears early, a second backdrop paints, or a sheet dialog's lifecycle breaks. Under reduced motion, a handoff that moves a sheet, or on a coarse pointer does not cross-fade. | `audit:BottomSheetSwitcher/behavior` | -| FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts` | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` | +| FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts`; `useScrollLock.test.ts` for the page's overscroll | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` | | FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref across open/handoff/close/unmount, callback-ref cleanup, `id` once, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or duplicated, an object ref is null while a sheet is mounted or skips a cleanup, or an input overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` | | Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` | | FR10 | `component:BottomSheet`'s stacking suite (the switcher-as-a-level cases); real-browser dim and recede sampling of a flow stacked over, and under, a standalone sheet | switcher base covered by a standalone sheet, switcher on top of a standalone sheet, a sheet in a step's content, flow closing over a lower sheet | Two backdrops paint, the covered level does not recede, a covered flow takes Escape, or focus lands on `body` when the flow closes over a sheet. | `audit:BottomSheetSwitcher/behavior` | diff --git a/docs/specs/components/Drawer/Drawer.spec.md b/docs/specs/components/Drawer/Drawer.spec.md index 7effabb54c2fb..ac12067eb8652 100644 --- a/docs/specs/components/Drawer/Drawer.spec.md +++ b/docs/specs/components/Drawer/Drawer.spec.md @@ -163,7 +163,7 @@ Consumer prop syntax and examples remain in `Drawer.doc.mjs`. | FR2 | The logical edge selected for an open Drawer MUST remain latched through its exit even when caller state changes the live edge prop during close. | Current exit-side test and implementation | | FR3 | The desktop inline-size budget MUST accept pixel numbers or CSS lengths and remain bounded by the viewport. At or below 640px, page-reveal mode MUST preserve 56px and use `width` as a cap; full-mobile mode MUST override `width` with `100dvw`. | Current docs, styles, and width tests | | FR4 | Visibility through Drawer-owned behavior MUST follow `isOpen`, from the render after hydration on (FR17). Uncanceled Escape routed by the shared stack and uncanceled modal-backdrop activation request `false` when `purpose` allows (FR15), and a `DrawerHeader` close action requests `false` through the callback it is given; Drawer prevents its handled native `cancel` event from closing the dialog directly. During controlled exit, Drawer keeps its host and content region mounted while rendering the caller's current `children`. | Current prop contract and lifecycle tests | -| FR5 | At open time, `hasScrim={true}` MUST select native `showModal()` with `aria-modal`, a focus trap, body scroll locking, and a visible backdrop; `hasScrim={false}` MUST select a manual Popover API host that leaves the page behind interactive. Changing `hasScrim` while open is not a supported native-mode transition. | Current source, docs, and initial-mode tests | +| FR5 | At open time, `hasScrim={true}` MUST select native `showModal()` with `aria-modal`, a focus trap, page scroll locking, and a visible backdrop; under the lock, pull-to-refresh and the page's bounce stop, because a scroll lock under a modal holds the page (`spec:AST-094` FR19), as behind a UIKit modal presentation; `hasScrim={false}` MUST select a manual Popover API host that leaves the page behind interactive. Changing `hasScrim` while open is not a supported native-mode transition. | Current source, docs, and initial-mode tests | | FR6 | An uncanceled press on the modal backdrop that starts and ends there (`target === currentTarget` for the pointer-down, the pointer-up, and the click, while live `hasScrim` is true; `family:overlay-dismissal` FR10) MUST request close when `purpose` is `info`, and MUST NOT for `form` or `required`; a press that crosses the drawer's edge either way MUST NOT request close. Descendant clicks and dialog-root clicks while live `hasScrim` is false MUST NOT request close, and non-modal presentation MUST NOT install an invisible outside-pointer dismissal plane. | Current click tests; `useBackdropPress.test.tsx` | | FR7 | Drawer MUST NOT render a close control of its own. A visible close action is caller composition, normally `DrawerHeader` with `onOpenChange` (`component:DrawerHeader` FR3), which requests `false` through that callback without taking open-state ownership. | DEC-1; close-control suite | | FR8 | Opening MUST present the native host, capture the element focused before opening, and attempt to focus the first rendered `[data-autofocus]` descendant, if any. After a completed controlled close releases the native host, Drawer MUST attempt to restore focus to the captured element. The non-modal host's synthetic `close` event MUST dispatch after that restore attempt — matching the task-queued native `dialog.close()` event — so a consumer close listener keeps the last word on final focus. | Current presence hook and focus tests | @@ -336,7 +336,7 @@ block is optional during migration, so the map is deferred. Read from source, with no focused test yet: - FR1: not reserving layout space or reflowing the page. -- FR5: body scroll locking for a modal drawer. +- FR5: page scroll locking for a modal drawer. - FR9: the computed-duration backstop. - FR13: logical depth for descendant layers, and platform-close routing. - FR14: the `dialog.show()` fallback below the Popover API support floor. diff --git a/docs/specs/components/MobileNav/MobileNav.spec.md b/docs/specs/components/MobileNav/MobileNav.spec.md index 3041eb1f187b6..5a1d45f9e9813 100644 --- a/docs/specs/components/MobileNav/MobileNav.spec.md +++ b/docs/specs/components/MobileNav/MobileNav.spec.md @@ -136,21 +136,21 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| FR1 | MobileNav renders one native `` Navigation overlay containing a Drawer with a stable Header, Close button, and scrollable Content region. The overlay carries `mobile-nav` with the resolved `side`; Drawer, Header, and Content carry no MobileNav public target. | Current source, docs, and tests | -| FR2 | `isOpen` and `onOpenChange` win when given; otherwise the drawer reads and changes the enclosing AppShell's drawer state. The dialog uses AppShell's drawer id when there is one, else its own; a caller `id` replaces either (Current-state impact). | Current source and tests | -| FR3 | Opening calls `showModal()`, and records the focused element as the opener; where focus lands inside the open drawer is uncontracted here. While open, the page's root clips its overflow and keeps its scrollbar gutter; closing releases both. | Current source and tests | -| FR4 | The native cancel request (Escape) is routed through the shared dismissal stack and requests close only when this drawer is topmost. A click on the overlay outside the Drawer requests close; a click inside does not. The Close button, named by `@astryx.mobileNav.closeNavigation`, requests close. A caller `onClick` on the dialog runs before the backdrop check; one that calls `preventDefault()` cancels that backdrop close. | Current source and tests | -| FR5 | After a close request the drawer slides out while the dialog stays open, and the dialog closes within its slide-out transition and no later than 250 ms after the request (on the next task, never in the commit that requests it, under reduced motion or a zero-length transition). Reopening before then cancels the close. Unmounting closes the dialog. | Current source and tests | -| FR6 | When the dialog closes, focus returns to the recorded opener if it is still connected and was not the document body; otherwise focus is left alone. A reopen that interrupted a close keeps the original opener. | Current source and tests | -| FR7 | The Drawer enters from its resolved inline edge, from off-screen to on-screen, and the backdrop fades in from transparent, without a reduced-motion preference. Under reduced motion, motion follows `design:reduced-motion` DR1–DR3 (Current-state impact records today's shortfall). The overlay clips the off-screen drawer without becoming a scroll container and allows pinch zoom. | Current source and tests | -| FR8 | `side="start"` or `"end"` sets the edge. `side="auto"` MUST keep the drawer's start and end relationship to the element that opened it (`design:bidirectionality` DR7). Implementation gap: it resolves on each open from the focused element's physical half of the viewport (left half picks `start`, otherwise `end`); when nothing is focused at open it keeps its last value, and before any open it is `end`. | Current source | -| FR9 | The Drawer is `width` px wide (default 320), never wider than 85% of the viewport, so a strip of the page stays beside it. | Current source and tests | -| FR10 | The dialog is named by `label`, else a string `header`, else `@astryx.mobileNav.navigation`; a caller `aria-labelledby` outranks all three. A string `header` renders as a level-2 heading; other header content renders as given. | Current source and tests | -| FR11 | MobileNavToggle renders only while AppShell reports its mobile layout with mobile navigation enabled. It is an icon-only ghost Button named by default by `@astryx.mobileNav.toggle.open`, with a menu icon unless `children` replace it; it toggles the drawer, exposes `aria-expanded`, and its `aria-controls` carries AppShell's drawer id, which a MobileNav inside that AppShell applies to its dialog unless a caller `id` replaces it. | Current source, docs, and tests | -| FR12 | MobileNav forwards its `ref`, `data-testid`, other DOM props, `className`, `style`, and `xstyle` to the dialog, except that its own `aria-label` (FR10) replaces a caller's (Current-state impact), a caller `onCancel` never runs (Current-state impact), and a caller `id` replaces its id (FR2). | Current source and tests | -| FR13 | MobileNav's backdrop is the theme's `--color-overlay` dim with no blur, on every pointer; under forced colors the drawer keeps a visible edge. | Owner decision, 2026-10-25: the overlay stays theme-controlled and unblurred on every pointer. | +| ID | Invariant | Basis | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| FR1 | MobileNav renders one native `` Navigation overlay containing a Drawer with a stable Header, Close button, and scrollable Content region. The overlay carries `mobile-nav` with the resolved `side`; Drawer, Header, and Content carry no MobileNav public target. | Current source, docs, and tests | +| FR2 | `isOpen` and `onOpenChange` win when given; otherwise the drawer reads and changes the enclosing AppShell's drawer state. The dialog uses AppShell's drawer id when there is one, else its own; a caller `id` replaces either (Current-state impact). | Current source and tests | +| FR3 | Opening calls `showModal()`, and records the focused element as the opener; where focus lands inside the open drawer is uncontracted here. While open, the page's scroller clips its overflow and keeps its scrollbar gutter: the root, or `` when `` sets its own overflow and the root's stays `visible`. Pull-to-refresh and the page's bounce stop, because a scroll lock under a modal holds the page (`spec:AST-094` FR19). Closing releases all three. | Current source and tests | +| FR4 | The native cancel request (Escape) is routed through the shared dismissal stack and requests close only when this drawer is topmost. A click on the overlay outside the Drawer requests close; a click inside does not. The Close button, named by `@astryx.mobileNav.closeNavigation`, requests close. A caller `onClick` on the dialog runs before the backdrop check; one that calls `preventDefault()` cancels that backdrop close. | Current source and tests | +| FR5 | After a close request the drawer slides out while the dialog stays open, and the dialog closes within its slide-out transition and no later than 250 ms after the request (on the next task, never in the commit that requests it, under reduced motion or a zero-length transition). Reopening before then cancels the close. Unmounting closes the dialog. | Current source and tests | +| FR6 | When the dialog closes, focus returns to the recorded opener if it is still connected and was not the document body; otherwise focus is left alone. A reopen that interrupted a close keeps the original opener. | Current source and tests | +| FR7 | The Drawer enters from its resolved inline edge, from off-screen to on-screen, and the backdrop fades in from transparent, without a reduced-motion preference. Under reduced motion, motion follows `design:reduced-motion` DR1–DR3 (Current-state impact records today's shortfall). The overlay clips the off-screen drawer without becoming a scroll container and allows pinch zoom. | Current source and tests | +| FR8 | `side="start"` or `"end"` sets the edge. `side="auto"` MUST keep the drawer's start and end relationship to the element that opened it (`design:bidirectionality` DR7). Implementation gap: it resolves on each open from the focused element's physical half of the viewport (left half picks `start`, otherwise `end`); when nothing is focused at open it keeps its last value, and before any open it is `end`. | Current source | +| FR9 | The Drawer is `width` px wide (default 320), never wider than 85% of the viewport, so a strip of the page stays beside it. | Current source and tests | +| FR10 | The dialog is named by `label`, else a string `header`, else `@astryx.mobileNav.navigation`; a caller `aria-labelledby` outranks all three. A string `header` renders as a level-2 heading; other header content renders as given. | Current source and tests | +| FR11 | MobileNavToggle renders only while AppShell reports its mobile layout with mobile navigation enabled. It is an icon-only ghost Button named by default by `@astryx.mobileNav.toggle.open`, with a menu icon unless `children` replace it; it toggles the drawer, exposes `aria-expanded`, and its `aria-controls` carries AppShell's drawer id, which a MobileNav inside that AppShell applies to its dialog unless a caller `id` replaces it. | Current source, docs, and tests | +| FR12 | MobileNav forwards its `ref`, `data-testid`, other DOM props, `className`, `style`, and `xstyle` to the dialog, except that its own `aria-label` (FR10) replaces a caller's (Current-state impact), a caller `onCancel` never runs (Current-state impact), and a caller `id` replaces its id (FR2). | Current source and tests | +| FR13 | MobileNav's backdrop is the theme's `--color-overlay` dim with no blur, on every pointer; under forced colors the drawer keeps a visible edge. | Owner decision, 2026-10-25: the overlay stays theme-controlled and unblurred on every pointer. | ### Allowed variation @@ -185,7 +185,8 @@ Consumer migration instructions belong in consumer docs and release notes. ### Performance and resources - **PR1 — Cleanup.** A pending close timer clears on reopen and unmount, and the - root overflow and scrollbar gutter are restored on close and unmount. + page scroller's clip, the overscroll stop and the scrollbar gutter are + released on close and unmount. ## Accessibility contract @@ -263,20 +264,20 @@ ownership. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| FR1, FR2, FR10, FR12 | `MobileNav.test.tsx` | Open and closed; string and no header; caller `id` on a standalone drawer | Losing the dialog, name, Close, or forwarded props fails. | `audit:MobileNav/anatomy` | -| FR2, FR3, FR4 | `MobileNav.test.tsx`; `MobileNavCloseEdgeCases.test.tsx`; `MobileNavScrollbarGutter.test.tsx` | Escape, backdrop, content click, Close | A wrong close source or a lingering scroll lock fails. | `audit:MobileNav/behavior` | -| FR4 | `layerDismissalFamilies.test.tsx` MobileNav cases | Escape over a required Dialog; cancel while topmost and while not | Closing when not topmost, or not closing when topmost, fails. | `audit:MobileNav/behavior` | -| FR5, ORD3 | `MobileNavCloseVisibility.test.tsx`; `MobileNavCloseEdgeCases.test.tsx`; `MobileNavCloseTiming.test.ts` | Close, reduced motion, reopen, unmount, the 250 ms bound | Closing before the slide-out or a stuck dialog fails. | `audit:MobileNav/behavior` | -| FR6, AR2 | `MobileNavFocus.test.tsx` | Standalone, AppShell, Escape, removed opener | Focus not returned, or returned too early, fails. | `audit:MobileNav/accessibility` | -| FR7 | `MobileNavEntryAnimation.test.tsx` | Start and end edges | Missing starting style or a scroll-container overlay fails. | `audit:MobileNav/behavior` | -| FR11, AR3 | `MobileNavToggle.test.tsx`; `MobileNavReopen.test.tsx` | Closed, open, reopen | Broken expanded state or id link fails. | `audit:MobileNav/accessibility` | -| AR4 | `MobileNavPinchZoom.a11y.chromium.spec.ts` | Open drawer on a touch viewport | Pinch zoom blocked fails. | `audit:MobileNav/accessibility` | -| FR9 | `MobileNav.test.tsx` drawer cap test; `MobileNavWidth.a11y.chromium.spec.ts` | Default and wide `width` on a narrow viewport | A drawer wider than 85% of the viewport fails. | `audit:MobileNav/behavior` | -| Theming anatomy map | `scripts/check-knowledge.mjs`; `themingTargets.test.ts` | Canonical parent anatomy and current target | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:MobileNav/theming` | -| RTL relation | RTL audit curated D4 target (`apps/storybook/rtl-audit/targets.json`) on `MobileNav / Default` (drawer opened) | The open drawer; LTR and RTL | A drawer that does not move to the mirrored side of the page in RTL fails D4. Evidence gap: the story centres its opener, so the documented `side="auto"` far-side RTL drawer (FR8) is not observed. | `audit:MobileNav/i18n-rtl` | -| FR13 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer | A `backdrop-filter` on the backdrop at either width, or no visible edge in forced colors, fails. | `audit:MobileNav/theming` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1, FR2, FR10, FR12 | `MobileNav.test.tsx` | Open and closed; string and no header; caller `id` on a standalone drawer | Losing the dialog, name, Close, or forwarded props fails. | `audit:MobileNav/anatomy` | +| FR2, FR3, FR4 | `MobileNav.test.tsx`; `MobileNavCloseEdgeCases.test.tsx`; `MobileNavScrollbarGutter.test.tsx`; `MobileNavRootScrollClaim.test.tsx` | Escape, backdrop, content click, Close | A wrong close source or a lingering scroll lock fails. | `audit:MobileNav/behavior` | +| FR4 | `layerDismissalFamilies.test.tsx` MobileNav cases | Escape over a required Dialog; cancel while topmost and while not | Closing when not topmost, or not closing when topmost, fails. | `audit:MobileNav/behavior` | +| FR5, ORD3 | `MobileNavCloseVisibility.test.tsx`; `MobileNavCloseEdgeCases.test.tsx`; `MobileNavCloseTiming.test.ts` | Close, reduced motion, reopen, unmount, the 250 ms bound | Closing before the slide-out or a stuck dialog fails. | `audit:MobileNav/behavior` | +| FR6, AR2 | `MobileNavFocus.test.tsx` | Standalone, AppShell, Escape, removed opener | Focus not returned, or returned too early, fails. | `audit:MobileNav/accessibility` | +| FR7 | `MobileNavEntryAnimation.test.tsx` | Start and end edges | Missing starting style or a scroll-container overlay fails. | `audit:MobileNav/behavior` | +| FR11, AR3 | `MobileNavToggle.test.tsx`; `MobileNavReopen.test.tsx` | Closed, open, reopen | Broken expanded state or id link fails. | `audit:MobileNav/accessibility` | +| AR4 | `MobileNavPinchZoom.a11y.chromium.spec.ts` | Open drawer on a touch viewport | Pinch zoom blocked fails. | `audit:MobileNav/accessibility` | +| FR9 | `MobileNav.test.tsx` drawer cap test; `MobileNavWidth.a11y.chromium.spec.ts` | Default and wide `width` on a narrow viewport | A drawer wider than 85% of the viewport fails. | `audit:MobileNav/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs`; `themingTargets.test.ts` | Canonical parent anatomy and current target | Missing, extra, prefixed, stale, or multiply assigned mappings fail repository validation. | `audit:MobileNav/theming` | +| RTL relation | RTL audit curated D4 target (`apps/storybook/rtl-audit/targets.json`) on `MobileNav / Default` (drawer opened) | The open drawer; LTR and RTL | A drawer that does not move to the mirrored side of the page in RTL fails D4. Evidence gap: the story centres its opener, so the documented `side="auto"` far-side RTL drawer (FR8) is not observed. | `audit:MobileNav/i18n-rtl` | +| FR13 | `modalBackdrop.test.tsx`; `ModalBackdrop.a11y.browser.spec.ts` in Chromium and WebKit | A finger at phone width; a mouse at desktop width; forced colors under either pointer | A `backdrop-filter` on the backdrop at either width, or no visible edge in forced colors, fails. | `audit:MobileNav/theming` | Read from source, with no focused test yet: diff --git a/packages/core/src/AlertDialog/AlertDialog.test.tsx b/packages/core/src/AlertDialog/AlertDialog.test.tsx index e1fd8ccfbc697..b37767874c51c 100644 --- a/packages/core/src/AlertDialog/AlertDialog.test.tsx +++ b/packages/core/src/AlertDialog/AlertDialog.test.tsx @@ -49,6 +49,13 @@ const hostCoarseQuery = { dispatchEvent: () => false, }; +// The scroll lock's page hold reads the primary pointer as it starts +// (gestures/pageHold.ts). The stub keeps that hold out of these tests, so the +// pointer stubs below reach only AlertDialog's own layout and press. +vi.mock('../gestures/pageHold', () => ({ + holdPageScroll: () => () => {}, +})); + function stubAlertDialogMedia({ smallScreen, coarsePointer, diff --git a/packages/core/src/BottomSheet/BottomSheet.test.tsx b/packages/core/src/BottomSheet/BottomSheet.test.tsx index dca6d28529b1e..3e04ecc9c5e4d 100644 --- a/packages/core/src/BottomSheet/BottomSheet.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.test.tsx @@ -579,12 +579,12 @@ describe('BottomSheet', () => { expect(dialog).toHaveAttribute('aria-hidden', 'true'); expect(dialog).not.toHaveAttribute('aria-modal'); expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '0'}); - expect(document.body.style.position).toBe('fixed'); + expect(document.documentElement.style.overflow).toBe('hidden'); finishSheetExit(); expect(dialog).not.toHaveAttribute('open'); - expect(document.body.style.position).not.toBe('fixed'); + expect(document.documentElement.style.overflow).not.toBe('hidden'); }); it('does not dismiss when the sheet surface itself is clicked', () => { @@ -1027,7 +1027,7 @@ describe('BottomSheet', () => { expect(dialog).toHaveAttribute('open'); expect(dialog).toHaveAttribute('inert'); - expect(document.body.style.position).not.toBe('fixed'); + expect(document.documentElement.style.overflow).not.toBe('hidden'); finishSheetExit(); diff --git a/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx b/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx index 4154094c1df05..484539c322062 100644 --- a/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx @@ -27,6 +27,7 @@ import { getBottomSheetStackForTests, resetBottomSheetStackForTests, } from './bottomSheetStack'; +import {isScrollLockActive} from '../hooks/rootScrollStyles'; import {__resetInteractionModalityForTest} from '../utils/interactionModality'; let nativeLog: { @@ -1455,7 +1456,7 @@ describe('BottomSheet replace: non-modal sheets', () => { 'hidePopover:One', ]); // The page stayed interactive: no scroll lock, no focus steal. - expect(document.body.style.position).not.toBe('fixed'); + expect(isScrollLockActive()).toBe(false); }); it('a non-modal reversal re-shows the returning sheet above the other', () => { diff --git a/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx b/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx index 4c512f55355ba..7a93a0df38cdf 100644 --- a/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx @@ -27,6 +27,10 @@ import {hydrateRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; import {BottomSheet} from './BottomSheet'; import {BottomSheetSwitcher} from './BottomSheetSwitcher'; +import { + isScrollLockActive, + subscribeScrollLock, +} from '../hooks/rootScrollStyles'; // jsdom implements no presentation. The stubs record which open // dialogs are modal, standing in for the `:modal` pseudo-class jsdom cannot @@ -346,11 +350,18 @@ function openDialogs(): HTMLDialogElement[] { /** * Whether the shared page-scroll lock (useScrollLock, as Dialog uses) is - * engaged: it pins . The browser spec measures the lock's effect on - * scrolling; this reads its pin. + * engaged. The browser spec measures the lock's effect on scrolling; this + * reads its state. */ function isScrollLockEngaged(): boolean { - return document.body.style.position === 'fixed'; + return isScrollLockActive(); +} + +/** Records every change of the shared lock's state until `stop` runs. */ +function recordScrollLock(): {changes: boolean[]; stop: () => void} { + const changes: boolean[] = []; + const stop = subscribeScrollLock(() => changes.push(isScrollLockActive())); + return {changes, stop}; } /** A two-step flow whose second step opens a Core Dialog above itself. */ @@ -1335,18 +1346,15 @@ describe('BottomSheetSwitcher', () => { }); it('holds one page scroll lock for the whole flow: engaged continuously through a handoff, released once after the exit', () => { - // The shared lock restores the saved offset with window.scrollTo on its - // last release, so the call count is the release count: a flow that - // released and re-acquired between steps would scroll the page back - // mid-handoff. - vi.spyOn(window, 'scrollY', 'get').mockReturnValue(300); + // Every change of the shared lock is recorded: a flow that released and + // re-acquired between steps would add a pair of changes mid-handoff. + const lock = recordScrollLock(); render(); expect(isScrollLockEngaged()).toBe(false); fireEvent.click(screen.getByRole('button', {name: 'Start flow'})); expect(isScrollLockEngaged()).toBe(true); - expect(document.body.style.top).toBe('-300px'); - expect(window.scrollTo).not.toHaveBeenCalled(); + expect(lock.changes).toEqual([true]); fireEvent.click(screen.getByRole('button', {name: 'Continue'})); // Two modal sheet dialogs are open: still the one lock. @@ -1372,40 +1380,39 @@ describe('BottomSheetSwitcher', () => { expect(window.scrollTo).not.toHaveBeenCalled(); finishSheetTransition(getSheetLayer('details-sheet'), 'transform'); expect(isScrollLockEngaged()).toBe(false); - expect(document.body.style.top).toBe(''); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(window.scrollTo).toHaveBeenCalledWith(0, 300); + expect(lock.changes).toEqual([true, false]); + // The lock never moves the page. + expect(window.scrollTo).not.toHaveBeenCalled(); + lock.stop(); }); it('keeps the lock through a StrictMode replay of a flow mounted open, and releases it when the flow unmounts while presented', () => { - vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120); + const lock = recordScrollLock(); const mounted = render( , ); // StrictMode runs the lock's effect, its cleanup, and the effect again - // in one commit: the pin must stand afterwards, and the one real - // release has not happened. + // in one commit: the lock must stand afterwards. expect(isScrollLockEngaged()).toBe(true); - expect(document.body.style.top).toBe('-120px'); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - vi.mocked(window.scrollTo).mockClear(); + expect(lock.changes).toEqual([true, false, true]); fireEvent.click(screen.getByRole('button', {name: 'Continue'})); expect(openDialogs()).toHaveLength(2); expect(isScrollLockEngaged()).toBe(true); - expect(window.scrollTo).not.toHaveBeenCalled(); + expect(lock.changes).toEqual([true, false, true]); // Unmounted while presented, two dialogs open: one release. mounted.unmount(); expect(isScrollLockEngaged()).toBe(false); - expect(document.body.style.top).toBe(''); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(window.scrollTo).toHaveBeenCalledWith(0, 120); + expect(lock.changes).toEqual([true, false, true, false]); + expect(window.scrollTo).not.toHaveBeenCalled(); + lock.stop(); }); it('keeps the lock engaged under a Core Dialog opened from a step, and the Dialog paints above the step', () => { + const lock = recordScrollLock(); render(); fireEvent.click(screen.getByRole('button', {name: 'Start flow'})); fireEvent.click(screen.getByRole('button', {name: 'Continue'})); @@ -1438,7 +1445,9 @@ describe('BottomSheetSwitcher', () => { fireEvent.click(screen.getByRole('button', {name: 'Done'})); finishSheetTransition(getSheetLayer('confirm-sheet'), 'transform'); expect(isScrollLockEngaged()).toBe(false); - expect(window.scrollTo).toHaveBeenCalledTimes(1); + // One engagement for the whole flow, Dialog included. + expect(lock.changes).toEqual([true, false]); + lock.stop(); }); it("resolves the switcher ref to the host sheet's dialog and never to null while a sheet is mounted, from the first commit on", () => { diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts index 6e09375336d25..d009bf77e6f15 100644 --- a/packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetNativeModal.a11y.browser.spec.ts @@ -9,7 +9,7 @@ * dialog: the flow's one page scroll lock (the shared hook, as Dialog) * holding while two sheet dialogs are open through a handoff and under a * Core Dialog opened from a step, never released between steps nor when - * that Dialog closes, and the page's offset restored after the exit; Tab landing inside the entering sheet's dialog + * that Dialog closes, and the page never moving; Tab landing inside the entering sheet's dialog * as soon as both dialogs are open and on Tab stops for as long as the * retained dialog is open beneath it (its fade included), never the page * once the handoff is done, by the browser's own containment, with the @@ -85,35 +85,32 @@ async function mountScrollable(page: Page, story = 'hug-content') { } /** - * The shared lock's pin on , and the page's offset as the lock sees - * it: while pinned, `scrollY` reads 0 and the saved offset is `top`; after - * release, `scrollY` is the restored offset. `scrollEvents` counts window - * scroll events since `resetScrollCounter`: a lock released and re-acquired - * between steps restores the offset and pins again, which fires one. + * The shared lock's hold on the page (`overflow: hidden` on the root) and the + * page's offset, which the lock never changes. `scrollEvents` counts window + * scroll events since `resetScrollCounter`: any movement of the page while + * the flow is open fires one. */ async function lockState(page: Page): Promise<{ - position: string; - top: string; + overflow: string; scrollY: number; scrollEvents: number; }> { return page.evaluate(() => ({ - position: document.body.style.position, - top: document.body.style.top, + overflow: document.documentElement.style.overflow, scrollY: window.scrollY, scrollEvents: (window as {__scrollEvents?: number}).__scrollEvents ?? 0, })); } /** - * Zero the counter once the events already owed have arrived: the spacer - * scroll in `mountScrollable` and the lock's own pin (300 → 0) each fire one - * window `scroll` event, and WebKit delivers them late on a cold run. + * Zero the counter once the owed event arrives. The spacer scroll in + * `mountScrollable` fires one window `scroll` event, and WebKit delivers it + * late on a cold run. */ async function resetScrollCounter(page: Page) { await page .waitForFunction( - () => ((window as {__scrollEvents?: number}).__scrollEvents ?? 0) >= 2, + () => ((window as {__scrollEvents?: number}).__scrollEvents ?? 0) >= 1, undefined, {timeout: 3000}, ) @@ -166,67 +163,63 @@ async function openFlow(page: Page) { await page.waitForTimeout(500); } -test('the flow holds one page scroll lock through a handoff and under a Core Dialog opened from a step, never released between steps, and restores the offset after the exit', async ({ +test('the flow holds one page scroll lock through a handoff and under a Core Dialog opened from a step, never released between steps, and never moves the page', async ({ page, browserName, }) => { await mountScrollable(page, 'dialog-from-step'); await openFlow(page); let state = await lockState(page); - expect(state.position).toBe('fixed'); - expect(state.top).toBe('-300px'); + expect(state.overflow).toBe('hidden'); + expect(state.scrollY).toBe(300); await resetScrollCounter(page); await page.getByRole('button', {name: 'Continue', exact: true}).click(); await expect(page.locator('dialog[open]')).toHaveCount(2); state = await lockState(page); - expect(state.position).toBe('fixed'); - expect(state.top).toBe('-300px'); - // Wheel and finger over the backdrop with both dialogs open: the pinned + expect(state.overflow).toBe('hidden'); + // Wheel and finger over the backdrop with both dialogs open: the locked // page does not move. await wheelDown(page); await touchScrollDown(page, browserName); state = await lockState(page); - expect(state.position).toBe('fixed'); - expect(state.top).toBe('-300px'); - expect(state.scrollY).toBe(0); + expect(state.overflow).toBe('hidden'); + expect(state.scrollY).toBe(300); await expect(page.locator('dialog[open]')).toHaveCount(1, {timeout: 3000}); - // One lock for the flow: the handoff released nothing, so the page never - // scrolled back and forth between steps. + // One lock for the flow: the handoff released nothing, and the page never + // moved between steps. state = await lockState(page); - expect(state.position).toBe('fixed'); + expect(state.overflow).toBe('hidden'); expect(state.scrollEvents).toBe(0); // A Core Dialog opened from the step, as a builder would, takes the same // shared lock a second time; closing it must give back only its own hold, - // leaving the flow's pin in place with the saved offset untouched. + // leaving the flow's lock in place and the page where it was. await page.getByRole('button', {name: 'Reset', exact: true}).click(); const resetDialog = page.getByRole('dialog', {name: 'Reset frequency?'}); await expect(resetDialog).toBeVisible(); await wheelDown(page); state = await lockState(page); - expect(state.position).toBe('fixed'); - expect(state.top).toBe('-300px'); - expect(state.scrollY).toBe(0); + expect(state.overflow).toBe('hidden'); + expect(state.scrollY).toBe(300); await page.getByRole('button', {name: 'Keep', exact: true}).click(); await expect(resetDialog).toBeHidden(); await page.waitForTimeout(400); state = await lockState(page); - expect(state.position).toBe('fixed'); - expect(state.top).toBe('-300px'); - expect(state.scrollY).toBe(0); + expect(state.overflow).toBe('hidden'); + expect(state.scrollY).toBe(300); expect(state.scrollEvents).toBe(0); await wheelDown(page); - expect(await page.evaluate(() => window.scrollY)).toBe(0); + expect(await page.evaluate(() => window.scrollY)).toBe(300); // The Dialog returned focus into the step, so Escape closes the flow. await expect(page.locator('dialog[open]')).toHaveCount(1); await page.keyboard.press('Escape'); await expect(page.locator('dialog[open]')).toHaveCount(0, {timeout: 3000}); state = await lockState(page); - expect(state.position).toBe(''); - expect(state.top).toBe(''); + expect(state.overflow).toBe(''); expect(state.scrollY).toBe(300); + expect(state.scrollEvents).toBe(0); // The page scrolls again. await wheelDown(page); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(300); @@ -283,6 +276,146 @@ async function handoffStep( }, retainedName); } +test('the lock keeps the page where it is on a page whose body sets its own overflow', async ({ + page, +}) => { + await mountScrollable(page); + // A common reset: with `html, body { height: 100% }`, the body's own + // overflow propagates to the viewport, which a root `overflow` would stop. + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = + 'html, body { height: 100%; } body { overflow-x: hidden; }'; + document.head.append(style); + window.scrollTo(0, 300); + }); + const position = async () => + page.evaluate(() => ({ + scrollY: window.scrollY, + spacerTop: Math.round( + document.getElementById('spacer')?.getBoundingClientRect().top ?? NaN, + ), + })); + const before = await position(); + expect(before.scrollY).toBe(300); + + await openFlow(page); + expect(await position()).toEqual(before); + + await page.keyboard.press('Escape'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + // The lock's release gives the body its own inline overflow back. + await page.waitForFunction(() => document.body.style.overflow === ''); + expect(await position()).toEqual(before); +}); + +/** Where the backdrop drag starts: over the dimmed page, above the sheet. */ +const DRAG_START = {x: 195, y: 120}; + +/** + * A finger drag down the backdrop as trusted touch events, one move per frame; + * Chromium only (CDP). `Input.synthesizeScrollGesture` delivers the touch's + * start but none of its moves to the page, so it cannot show a cancelled move. + */ +async function touchDragDown(page: Page) { + const session = await page.context().newCDPSession(page); + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [DRAG_START], + }); + for (let step = 1; step <= 12; step += 1) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{x: DRAG_START.x, y: DRAG_START.y + step * 20}], + }); + await page.evaluate(async () => new Promise(requestAnimationFrame)); + } + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await session.detach(); + // Two frames, so the page has handled the lift before the test reads it. + await page.evaluate( + async () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} + +test('the lock holds a backdrop drag when the flow renders inside a scrolling container', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'touch input needs CDP'); + await mountScrollable(page); + // The sheets render their dialogs in place, so moving the story root into a + // scroller puts the open flow inside it. A modal dialog's pans still go to + // the viewport, never to that scroller, so the lock cancels them here too. + // iOS Safari scrolls the page under a move that goes through. + // Chromium never does, so the cancelled moves are the evidence here. + await page.evaluate(() => { + const root = document.getElementById('storybook-root'); + const container = document.createElement('div'); + container.id = 'container'; + container.style.cssText = 'height: 400px; overflow-y: auto;'; + root?.before(container); + if (root != null) { + container.append(root); + } + const filler = document.createElement('div'); + filler.style.height = '2000px'; + container.append(filler); + container.scrollTop = 100; + // After every listener: whether each move past the hold's 5 px slop was + // cancelled. + const moves = {judged: 0, cancelled: 0, startX: 0, startY: 0}; + (window as {__moves?: typeof moves}).__moves = moves; + window.addEventListener('touchstart', event => { + moves.startX = event.touches[0]?.clientX ?? 0; + moves.startY = event.touches[0]?.clientY ?? 0; + }); + window.addEventListener('touchmove', event => { + const touch = event.touches[0]; + if (touch == null) { + return; + } + const distance = Math.max( + Math.abs(touch.clientX - moves.startX), + Math.abs(touch.clientY - moves.startY), + ); + if (distance >= 5) { + moves.judged += 1; + moves.cancelled += event.defaultPrevented ? 1 : 0; + } + }); + }); + // Moving the story root changes the page's height, so read the offset now. + const scrollY = await page.evaluate(() => window.scrollY); + await openFlow(page); + // The drag starts on the backdrop. There, the one open dialog is under the + // finger, not the sheet's content, and the dialog is painted. + const start = await page.evaluate(({x, y}) => { + const hit = document.elementFromPoint(x, y); + const box = hit?.getBoundingClientRect(); + return { + openDialogs: document.querySelectorAll('dialog[open]').length, + hitIsOpenDialog: hit instanceof HTMLDialogElement && hit.open, + painted: box != null && box.width > 0 && box.height > 0, + }; + }, DRAG_START); + expect(start).toEqual({openDialogs: 1, hitIsOpenDialog: true, painted: true}); + await touchDragDown(page); + + const moves = await page.evaluate( + () => (window as {__moves?: {judged: number; cancelled: number}}).__moves, + ); + expect(moves?.judged ?? 0).toBeGreaterThan(0); + expect(moves?.cancelled).toBe(moves?.judged); + expect(await page.evaluate(() => window.scrollY)).toBe(scrollY); +}); + /** How long a held phase's panel transitions are stretched to run, ms. */ const HELD_RUN_MS = 1_000_000; diff --git a/packages/core/src/MobileNav/MobileNav.tsx b/packages/core/src/MobileNav/MobileNav.tsx index 6b3d53896930d..bf357677d9c59 100644 --- a/packages/core/src/MobileNav/MobileNav.tsx +++ b/packages/core/src/MobileNav/MobileNav.tsx @@ -18,9 +18,10 @@ * without manual z-index management. The browser provides: * - Top layer promotion (no z-index needed) * - `::backdrop` pseudo-element - * - Body scroll lock * - Focus trapping * + * The page behind takes `useScrollLock`, the same lock as every other modal. + * * Escape is owned by the shared dismissal stack (`useLayerDismissal`), so a * drawer opened over another layer takes the press and nothing behind it * closes. The native `cancel` event still handles the dismissals the browser @@ -36,7 +37,7 @@ * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/MobileNav/index.ts (exports if types change) * - /packages/core/src/Layer/useLayerDismissal.ts (dismissal stack) - * - /packages/core/src/hooks/scrollbarGutter.ts (shared scroll-lock gutter) + * - /packages/core/src/hooks/useScrollLock.ts (the page's scroll lock) * - /packages/cli/assets/templates/blocks/components/MobileNav/ (showcase blocks) */ @@ -62,10 +63,6 @@ import {Button} from '../Button'; import {Icon} from '../Icon'; import {Heading} from '../Heading/Heading'; import {useAppShellMobile} from '../AppShell/AppShellMobileContext'; -import { - holdScrollbarGutter, - type ScrollbarGutterHold, -} from '../hooks/scrollbarGutter'; import {mergeProps, composeEventHandlers} from '../utils'; import {layerTextReset} from '../Layer/layerTextReset.stylex'; import {layerStructureReset} from '../Layer/layerStructureReset.stylex'; @@ -79,6 +76,7 @@ import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; import {useMergedRefs} from '../hooks/useMergedRefs'; +import {useScrollLock} from '../hooks/useScrollLock'; import { registerEnglishFallback, useComponentTranslator, @@ -525,15 +523,10 @@ export function MobileNav({ setWasOpen(isOpen); setIsCloseHeld(!isOpen); } - const gutterRef = useRef(null); - - // Gives back the gutter held open in place of the hidden scrollbar. - const releaseGutter = useCallback(() => { - if (gutterRef.current) { - gutterRef.current.release(); - gutterRef.current = null; - } - }, []); + // The same page lock as every other modal. Its effect runs before the + // open/close effect below, so it measures the scrollbar gutter before + // showModal() can hide the scrollbar. + useScrollLock(isOpen); // Resolved side — computed from trigger position when side='auto' const [resolvedSide, setResolvedSide] = useState<'start' | 'end'>( side === 'auto' ? 'end' : side, @@ -576,10 +569,6 @@ export function MobileNav({ } if (isOpen) { - // Taken first: every mutation below is one that can hide the scrollbar, - // and the gutter has to be measured while it is still there. - gutterRef.current ??= holdScrollbarGutter(document.documentElement); - if (!dialog.open) { // Capture the opener before showModal() pulls focus into the top // layer. Only on the transition into open: a reopen while the delayed @@ -589,15 +578,7 @@ export function MobileNav({ document.activeElement as HTMLElement | null; dialog.showModal(); } - // Prevent background scrolling and iOS pull-to-refresh. - // overflow: clip avoids creating a scroll container (unlike hidden), - // so there's no scroll bounce and no need to save/restore scroll position. - document.documentElement.style.overflow = 'clip'; - gutterRef.current.settle(); } else if (dialog.open) { - document.documentElement.style.overflow = ''; - releaseGutter(); - closeTimeoutRef.current = setTimeout(() => { closeAndRestoreFocus(dialog, triggerElementRef); setIsCloseHeld(false); @@ -613,10 +594,8 @@ export function MobileNav({ clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } - document.documentElement.style.overflow = ''; - releaseGutter(); }; - }, [isOpen, releaseGutter]); + }, [isOpen]); // Close the native dialog on unmount if it's still open. Inside AppShell the // drawer is mounted in an that switches to mode="hidden" when the diff --git a/packages/core/src/MobileNav/MobileNavCloseEdgeCases.test.tsx b/packages/core/src/MobileNav/MobileNavCloseEdgeCases.test.tsx index 8cb76ad8c72da..32b6eead0fb66 100644 --- a/packages/core/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +++ b/packages/core/src/MobileNav/MobileNavCloseEdgeCases.test.tsx @@ -256,7 +256,7 @@ describe('MobileNav close path edge cases', () => { try { const {rerender} = render(); const dialog = screen.getByTestId('mobile-nav'); - expect(document.documentElement.style.overflow).toBe('clip'); + expect(document.documentElement.style.overflow).toBe('hidden'); // Two acts on purpose: the close timer is armed by the effect that runs // when the first act flushes, so advancing inside that same act would @@ -279,9 +279,29 @@ describe('MobileNav close path edge cases', () => { } }); + it("hides 's overflow instead of the root's on a page whose sets its own overflow, and gives it back", () => { + // A common reset: `body { overflow-x: hidden }` with the root's overflow + // left visible. The viewport then takes 's overflow. Hiding the + // root's overflow stops that propagation, and the page jumps to its top. + document.body.style.overflowX = 'hidden'; + try { + const {unmount} = render(); + + expect(document.body.style.overflow).toBe('hidden'); + expect(document.documentElement.style.overflow).toBe(''); + + unmount(); + + expect(document.body.style.overflow).toBe(''); + expect(document.body.style.overflowX).toBe('hidden'); + } finally { + document.body.style.overflowX = ''; + } + }); + it('restores documentElement overflow when unmounted while open', () => { const {unmount} = render(); - expect(document.documentElement.style.overflow).toBe('clip'); + expect(document.documentElement.style.overflow).toBe('hidden'); unmount(); diff --git a/packages/core/src/MobileNav/MobileNavRootScrollClaim.test.tsx b/packages/core/src/MobileNav/MobileNavRootScrollClaim.test.tsx new file mode 100644 index 0000000000000..bbc8b6e849d5b --- /dev/null +++ b/packages/core/src/MobileNav/MobileNavRootScrollClaim.test.tsx @@ -0,0 +1,171 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MobileNavRootScrollClaim.test.tsx + * @input Uses vitest, @testing-library/react, MobileNav, useScrollLock and + * the primary-pointer test helper + * @output Unit tests for MobileNav's scroll lock: its claims, its one-finger + * hold, and another overlay's lock left in place + * @position Testing; guards MobileNav.tsx's use of useScrollLock + * + * SYNC: When MobileNav.tsx changes, update tests to match new behavior + */ + +import {afterEach, beforeAll, describe, expect, it} from 'vitest'; +import {cleanup, render, renderHook} from '@testing-library/react'; +import {MobileNav} from './MobileNav'; +import {useScrollLock} from '../hooks/useScrollLock'; +import {coarsePrimaryPointer} from '../gestures/__tests__/pointerQuery'; + +beforeAll(() => { + // jsdom doesn't implement showModal/close on + HTMLDialogElement.prototype.showModal = + HTMLDialogElement.prototype.showModal || + function (this: HTMLDialogElement) { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close = + HTMLDialogElement.prototype.close || + function (this: HTMLDialogElement) { + this.removeAttribute('open'); + }; +}); + +afterEach(() => { + cleanup(); + document.documentElement.style.cssText = ''; + document.body.style.cssText = ''; +}); + +const rootOverflow = () => document.documentElement.style.overflow; + +/** One finger's touch event at (100, y) on `target`. */ +function touch( + target: EventTarget, + type: 'touchstart' | 'touchmove' | 'touchend', + y: number, +): Event { + if (type === 'touchstart') { + // A browser sends a contact's pointerdown before its touchstart. + const down = new Event('pointerdown', {bubbles: true, composed: true}); + Object.assign(down, { + pointerType: 'touch', + pointerId: 1, + isPrimary: true, + clientX: 100, + clientY: y, + }); + target.dispatchEvent(down); + } + const event = new Event(type, {bubbles: true, cancelable: true}); + const point = {clientX: 100, clientY: y, identifier: 0, target}; + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [point], + }); + Object.defineProperty(event, 'changedTouches', {value: [point]}); + target.dispatchEvent(event); + return event; +} + +/** A one-finger pan from y0 to y1, returning whether its move was cancelled. */ +function pan(target: EventTarget, y0: number, y1: number): boolean { + touch(target, 'touchstart', y0); + const move = touch(target, 'touchmove', y1); + touch(target, 'touchend', y1); + return move.defaultPrevented; +} + +describe('MobileNav root scroll claim', () => { + it("hides the page's overflow while open and gives it back on close", () => { + const nav = render( + {}}> + Nav + , + ); + expect(rootOverflow()).toBe('hidden'); + + nav.rerender( + {}}> + Nav + , + ); + expect(rootOverflow()).toBe(''); + }); + + it("stops the page's pull-to-refresh and bounce while open and gives them back on close", () => { + const nav = render( + {}}> + Nav + , + ); + expect(document.documentElement.style.overscrollBehaviorY).toBe('none'); + expect(document.body.style.overscrollBehaviorY).toBe('none'); + + nav.rerender( + {}}> + Nav + , + ); + expect(document.documentElement.style.overscrollBehaviorY).toBe(''); + expect(document.body.style.overscrollBehaviorY).toBe(''); + }); + + it('holds a one-finger pan on the page beside the open drawer, where the primary pointer is coarse', () => { + const restorePointer = coarsePrimaryPointer(); + const strip = document.createElement('div'); + document.body.append(strip); + try { + const nav = render( + {}}> + Nav + , + ); + expect(pan(strip, 300, 200)).toBe(true); + + nav.rerender( + {}}> + Nav + , + ); + expect(pan(strip, 300, 200)).toBe(false); + } finally { + strip.remove(); + restorePointer(); + } + }); + + it('leaves the scroll lock of another overlay in place when it closes', () => { + const nav = render( + {}}> + Nav + , + ); + const lock = renderHook(() => useScrollLock(true)); + expect(rootOverflow()).toBe('hidden'); + + nav.rerender( + {}}> + Nav + , + ); + expect(rootOverflow()).toBe('hidden'); + + lock.unmount(); + expect(rootOverflow()).toBe(''); + }); + + it('leaves the scroll lock of another overlay in place when it unmounts', () => { + const nav = render( + {}}> + Nav + , + ); + const lock = renderHook(() => useScrollLock(true)); + + nav.unmount(); + expect(rootOverflow()).toBe('hidden'); + + lock.unmount(); + expect(rootOverflow()).toBe(''); + }); +}); diff --git a/packages/core/src/MobileNav/MobileNavScrollbarGutter.test.tsx b/packages/core/src/MobileNav/MobileNavScrollbarGutter.test.tsx index 4572fee186a48..5b2383b5abf5c 100644 --- a/packages/core/src/MobileNav/MobileNavScrollbarGutter.test.tsx +++ b/packages/core/src/MobileNav/MobileNavScrollbarGutter.test.tsx @@ -6,17 +6,17 @@ * @output Unit tests for the scrollbar gutter MobileNav reserves while open * @position Testing; guards MobileNav.tsx against the open-drawer page shift * - * MobileNav clips the document to lock background scroll, which hides a + * MobileNav's scroll lock hides the document's overflow, which hides a * classic scrollbar and widens the layout viewport by its width — the page - * behind the drawer jumps sideways. The clip has to be paired with holding - * that gutter open. + * behind the drawer jumps sideways. The lock holds that gutter open. * * SYNC: When MobileNav.tsx changes, update tests to match new behavior */ import {describe, it, expect, beforeAll, afterEach} from 'vitest'; -import {render, cleanup} from '@testing-library/react'; +import {render, renderHook, cleanup} from '@testing-library/react'; import {MobileNav} from './MobileNav'; +import {useScrollLock} from '../hooks/useScrollLock'; beforeAll(() => { // jsdom doesn't implement showModal/close on @@ -32,7 +32,12 @@ beforeAll(() => { }; }); -/** jsdom does no layout, so both halves of the measurement are stubbed. */ +const originalCss = Object.getOwnPropertyDescriptor(window, 'CSS'); + +/** + * jsdom does no layout, so both halves of the measurement are stubbed, and + * the engine has `scrollbar-gutter`, as every current one does. + */ function stubViewport(innerWidth: number, clientWidth: number) { Object.defineProperty(window, 'innerWidth', { value: innerWidth, @@ -43,6 +48,14 @@ function stubViewport(innerWidth: number, clientWidth: number) { value: clientWidth, configurable: true, }); + Object.defineProperty(window, 'CSS', { + value: { + supports: (property: string, value: string) => + property === 'scrollbar-gutter' && value === 'stable', + }, + configurable: true, + writable: true, + }); } const rootGutter = () => document.documentElement.style.scrollbarGutter; @@ -53,6 +66,12 @@ describe('MobileNav scrollbar gutter', () => { document.documentElement.style.cssText = ''; // @ts-expect-error -- drop the viewport stub so jsdom's own value comes back delete document.documentElement.clientWidth; + if (originalCss) { + Object.defineProperty(window, 'CSS', originalCss); + } else { + // @ts-expect-error -- jsdom has no CSS object of its own + delete window.CSS; + } }); it('holds the gutter open while the drawer is open', () => { @@ -64,7 +83,7 @@ describe('MobileNav scrollbar gutter', () => { , ); - expect(document.documentElement.style.overflow).toBe('clip'); + expect(document.documentElement.style.overflow).toBe('hidden'); expect(rootGutter()).toBe('stable'); view.unmount(); @@ -93,6 +112,28 @@ describe('MobileNav scrollbar gutter', () => { expect(rootGutter()).toBe(''); }); + it("keeps the gutter for another overlay's scroll lock when the drawer closes", () => { + stubViewport(1024, 1009); + document.documentElement.style.scrollbarGutter = 'auto'; + + const view = render( + {}}> + Nav content + , + ); + const lock = renderHook(() => useScrollLock(true)); + + view.rerender( + {}}> + Nav content + , + ); + expect(rootGutter()).toBe('stable'); + + lock.unmount(); + expect(rootGutter()).toBe('auto'); + }); + it('holds nothing when the scrollbar is an overlay one', () => { stubViewport(1024, 1024); @@ -102,7 +143,7 @@ describe('MobileNav scrollbar gutter', () => { , ); - expect(document.documentElement.style.overflow).toBe('clip'); + expect(document.documentElement.style.overflow).toBe('hidden'); expect(rootGutter()).toBe(''); expect(document.documentElement.style.paddingRight).toBe(''); }); diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md index 638256b47d864..a4c32823ecf51 100644 --- a/packages/core/src/gestures/README.md +++ b/packages/core/src/gestures/README.md @@ -436,9 +436,9 @@ no scroll. ### Holding the page still -A scroll lock may hold the page with `holdPageScroll(document, decide)` from -`pageHold.ts`, which returns the release (none does yet: `useScrollLock` -does not call it). It lives in its own module, so a page without a scroll +A scroll lock (`useScrollLock`) holds the page with +`holdPageScroll(document, decide)` from `pageHold.ts`, which returns the +release. It lives in its own module, so a page without a scroll lock never ships it, and it imports nothing of the gesture host: it asks the document's host whether a recognizer owns a touch only if a host exists, and a document without one has no recognizer to own it. A hold reads the diff --git a/packages/core/src/hooks/rootScrollStyles.test.ts b/packages/core/src/hooks/rootScrollStyles.test.ts new file mode 100644 index 0000000000000..4faa29a3273ff --- /dev/null +++ b/packages/core/src/hooks/rootScrollStyles.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rootScrollStyles.test.ts + * @input Uses vitest and the rootScrollStyles module + * @output Unit tests for the root scroll-style claims (stacking, out-of-order + * release, the page's own value, per-property independence) and for the + * scroll-lock signal + * @position Testing; validates rootScrollStyles.ts implementation + * + * SYNC: When rootScrollStyles.ts changes, update tests to match new behavior + */ + +import {afterEach, describe, expect, it} from 'vitest'; +import { + claimRootScrollStyle, + isScrollLockActive, + reportScrollLock, + subscribeScrollLock, +} from './rootScrollStyles'; + +const root = () => document.documentElement.style; + +describe('claimRootScrollStyle', () => { + afterEach(() => { + document.documentElement.style.cssText = ''; + document.body.style.cssText = ''; + }); + + it('leaves a value that an outside writer set while the claim held', () => { + // A drawer that writes the root directly opens first... + root().overflow = 'clip'; + const release = claimRootScrollStyle({overflow: 'hidden'}); + // ...and clears it as it closes, before this claim is released. + root().overflow = ''; + + release(); + expect(root().overflow).toBe(''); + }); + + it("claims overflow on the body when the body's own overflow is the one the viewport uses", () => { + // A common reset: `body { overflow-x: hidden }` propagates to the viewport + // while the root's overflow is visible. + document.body.style.overflowX = 'hidden'; + + const release = claimRootScrollStyle({overflow: 'hidden'}); + let bodyWhileHeld: string; + let rootWhileHeld: string; + try { + bodyWhileHeld = document.body.style.overflow; + rootWhileHeld = root().overflow; + } finally { + release(); + } + expect(bodyWhileHeld).toBe('hidden'); + expect(rootWhileHeld).toBe(''); + + expect(document.body.style.overflow).toBe(''); + expect(document.body.style.overflowX).toBe('hidden'); + }); + + it('sets the claimed styles and gives the page its own values back', () => { + root().overflow = 'auto'; + + const release = claimRootScrollStyle({ + overflow: 'hidden', + overscrollBehaviorY: 'none', + }); + expect(root().overflow).toBe('hidden'); + expect(root().overscrollBehaviorY).toBe('none'); + + release(); + expect(root().overflow).toBe('auto'); + expect(root().overscrollBehaviorY).toBe(''); + }); + + it('mirrors overscroll-behavior-y onto the body, which Chrome on Android reads for its pull-to-refresh, and gives the body its own value back', () => { + document.body.style.overscrollBehaviorY = 'contain'; + + const release = claimRootScrollStyle({overscrollBehaviorY: 'none'}); + expect(root().overscrollBehaviorY).toBe('none'); + expect(document.body.style.overscrollBehaviorY).toBe('none'); + + release(); + expect(root().overscrollBehaviorY).toBe(''); + expect(document.body.style.overscrollBehaviorY).toBe('contain'); + }); + + it('leaves a body overscroll value that an outside writer set while the claim held', () => { + const release = claimRootScrollStyle({overscrollBehaviorY: 'none'}); + document.body.style.overscrollBehaviorY = 'auto'; + + release(); + expect(document.body.style.overscrollBehaviorY).toBe('auto'); + expect(root().overscrollBehaviorY).toBe(''); + }); + + it('lets the latest claim win and restores the one beneath it', () => { + const lock = claimRootScrollStyle({overflow: 'hidden'}); + const drawer = claimRootScrollStyle({overflow: 'clip'}); + expect(root().overflow).toBe('clip'); + + drawer(); + expect(root().overflow).toBe('hidden'); + + lock(); + expect(root().overflow).toBe(''); + }); + + it('keeps the latest claim when an earlier one is released first', () => { + root().overflow = 'auto'; + const lock = claimRootScrollStyle({overflow: 'hidden'}); + const drawer = claimRootScrollStyle({overflow: 'clip'}); + + lock(); + expect(root().overflow).toBe('clip'); + + drawer(); + expect(root().overflow).toBe('auto'); + }); + + it('keeps each property to the claims that name it', () => { + const lock = claimRootScrollStyle({overflow: 'hidden'}); + const refresh = claimRootScrollStyle({overscrollBehaviorY: 'none'}); + + lock(); + expect(root().overflow).toBe(''); + expect(root().overscrollBehaviorY).toBe('none'); + + refresh(); + expect(root().overscrollBehaviorY).toBe(''); + }); + + it('stacks scrollbar-gutter claims on the root and gives the root its own value back', () => { + document.documentElement.style.scrollbarGutter = 'auto'; + const first = claimRootScrollStyle({scrollbarGutter: 'stable'}); + const second = claimRootScrollStyle({scrollbarGutter: 'stable'}); + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + expect(document.body.style.scrollbarGutter).toBe(''); + + first(); + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + second(); + expect(document.documentElement.style.scrollbarGutter).toBe('auto'); + }); + + it('does nothing on a second release', () => { + const lock = claimRootScrollStyle({overflow: 'hidden'}); + const drawer = claimRootScrollStyle({overflow: 'clip'}); + + lock(); + lock(); + expect(root().overflow).toBe('clip'); + + drawer(); + expect(root().overflow).toBe(''); + }); + + it('reads the page value when the first claim lands, not when the module loads', () => { + root().overflow = 'scroll'; + + const release = claimRootScrollStyle({overflow: 'clip'}); + expect(root().overflow).toBe('clip'); + + release(); + expect(root().overflow).toBe('scroll'); + }); +}); + +describe('scroll-lock signal', () => { + it('reports only the changes between free and locked', () => { + const states: boolean[] = []; + const unsubscribe = subscribeScrollLock(isActive => states.push(isActive)); + + reportScrollLock(1); + reportScrollLock(1); + expect(isScrollLockActive()).toBe(true); + reportScrollLock(-1); + expect(isScrollLockActive()).toBe(true); + reportScrollLock(-1); + + expect(isScrollLockActive()).toBe(false); + expect(states).toEqual([true, false]); + unsubscribe(); + }); + + it('stops calling a listener once it unsubscribes', () => { + const states: boolean[] = []; + const unsubscribe = subscribeScrollLock(isActive => states.push(isActive)); + unsubscribe(); + + reportScrollLock(1); + reportScrollLock(-1); + + expect(states).toEqual([]); + }); +}); diff --git a/packages/core/src/hooks/rootScrollStyles.ts b/packages/core/src/hooks/rootScrollStyles.ts new file mode 100644 index 0000000000000..282768d45dd0f --- /dev/null +++ b/packages/core/src/hooks/rootScrollStyles.ts @@ -0,0 +1,247 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file rootScrollStyles.ts + * @input The inline styles of the root and `` + * @output claimRootScrollStyle, which owns the page's `overflow`, + * `overscroll-behavior-y` and `scrollbar-gutter`; isScrollLockActive and + * subscribeScrollLock, which report whether a scroll lock holds the page + * @position Internal hook utility. Each caller that changes how the page + * scrolls claims these styles here, so no caller overwrites a value that + * another caller still needs. + * + * SYNC: When modified, update: + * - /packages/core/src/hooks/useScrollLock.ts + */ + +/** The root scroll styles a caller can hold. */ +export interface RootScrollStyle { + /** `hidden` or `clip` stops wheel, keyboard and scrollbar scrolling of the page. */ + overflow?: 'hidden' | 'clip'; + /** `none` turns the page's own bounce off at both vertical edges. */ + overscrollBehaviorY?: 'none' | 'contain'; + /** `stable` keeps a classic scrollbar's space while the page cannot scroll. */ + scrollbarGutter?: 'stable'; +} + +type RootScrollProperty = keyof RootScrollStyle; + +const PROPERTIES: ReadonlyArray = [ + 'overflow', + 'overscrollBehaviorY', + 'scrollbarGutter', +]; + +interface Claim { + readonly id: number; + readonly value: string; +} + +const claims: Record = { + overflow: [], + overscrollBehaviorY: [], + scrollbarGutter: [], +}; + +type SavedStyle = + | 'overflow' + | 'overflowX' + | 'overflowY' + | 'overscrollBehaviorY' + | 'scrollbarGutter'; + +/** + * The inline styles saved for each property, so a page's own value comes back + * exactly, including a one-axis `overflow-x` or `overflow-y`. + */ +const SAVED: Record> = { + overflow: ['overflow', 'overflowX', 'overflowY'], + overscrollBehaviorY: ['overscrollBehaviorY'], + scrollbarGutter: ['scrollbarGutter'], +}; + +/** + * An element that holds a property's claims. It keeps the page's own inline + * values, saved at the first claim, and the value that the claims last wrote. + */ +interface HeldTarget { + readonly element: HTMLElement; + readonly pageValues: Partial>; + written: string; +} + +const held = new Map(); + +let nextClaimId = 0; + +/** + * The element whose `overflow` the viewport uses. That is the root, unless the + * root's own is `visible` and `` sets one, which then propagates to the + * viewport. A write to the root in that case stops the propagation. Then + * `` becomes its own scroller at offset 0, and the page jumps to its top. + */ +function overflowTarget(doc: Document): HTMLElement { + const root = doc.documentElement; + const {body} = doc; + const view = doc.defaultView; + if (body == null || view == null) { + return root; + } + const isVisible = (element: Element) => { + const style = view.getComputedStyle(element); + return ( + (style.overflowX || 'visible') === 'visible' && + (style.overflowY || 'visible') === 'visible' + ); + }; + return isVisible(root) && !isVisible(body) ? body : root; +} + +/** + * Where a property's claims go. `overflow` goes to the element that the + * viewport takes it from. `overscroll-behavior-y` goes to the root and to + * ``, because Chrome on Android reads ``'s value for its + * pull-to-refresh. `scrollbar-gutter` goes to the root. + */ +function claimTargets( + property: RootScrollProperty, + doc: Document, +): HTMLElement[] { + if (property === 'overflow') { + return [overflowTarget(doc)]; + } + if (property === 'scrollbarGutter') { + return [doc.documentElement]; + } + return doc.body == null + ? [doc.documentElement] + : [doc.documentElement, doc.body]; +} + +function applyTopClaim(property: RootScrollProperty): void { + const targets = held.get(property); + if (targets == null) { + return; + } + const stack = claims[property]; + const top = stack[stack.length - 1]; + for (const target of targets) { + const {style} = target.element; + if (top != null) { + style[property] = top.value; + target.written = top.value; + continue; + } + // The last claim is gone, so the page gets its own values back. A value + // that an outside writer set meanwhile stays. + if (style[property] === target.written) { + style[property] = ''; + for (const name of SAVED[property]) { + const value = target.pageValues[name]; + if (value) { + style[name] = value; + } + } + } + } + if (top == null) { + held.delete(property); + } +} + +/** + * Sets the page's `overflow`, `overscroll-behavior-y` or `scrollbar-gutter` + * until the returned release runs. Claims stack per property: the latest claim + * wins, and a release in any order restores the claim beneath it. The page's + * own inline value comes back after the last release, unless an outside writer + * changed that style meanwhile. A second release does nothing. + * + * @example + * ``` + * const release = claimRootScrollStyle({overflow: 'hidden'}); + * // later + * release(); + * ``` + */ +export function claimRootScrollStyle(claim: RootScrollStyle): () => void { + if (typeof document === 'undefined') { + return () => {}; + } + nextClaimId += 1; + const id = nextClaimId; + const claimed: RootScrollProperty[] = []; + for (const property of PROPERTIES) { + const value = claim[property]; + if (value == null) { + continue; + } + if (claims[property].length === 0) { + held.set( + property, + claimTargets(property, document).map(element => { + const pageValues: Partial> = {}; + for (const name of SAVED[property]) { + pageValues[name] = element.style[name]; + } + return {element, pageValues, written: ''}; + }), + ); + } + claims[property].push({id, value}); + claimed.push(property); + applyTopClaim(property); + } + + let released = false; + return () => { + if (released) { + return; + } + released = true; + for (const property of claimed) { + const stack = claims[property]; + const index = stack.findIndex(entry => entry.id === id); + if (index !== -1) { + stack.splice(index, 1); + } + applyTopClaim(property); + } + }; +} + +let lockCount = 0; +const lockListeners = new Set<(isActive: boolean) => void>(); + +/** Whether a scroll lock (useScrollLock) currently holds the page. */ +export function isScrollLockActive(): boolean { + return lockCount > 0; +} + +/** + * Calls `listener` with the new state each time the page becomes locked or + * free. A second lock while one holds is no change. Returns the unsubscribe + * function. + */ +export function subscribeScrollLock( + listener: (isActive: boolean) => void, +): () => void { + lockListeners.add(listener); + return () => { + lockListeners.delete(listener); + }; +} + +/** For scroll locks: one lock taken (`1`) or released (`-1`). */ +export function reportScrollLock(delta: 1 | -1): void { + const wasActive = lockCount > 0; + lockCount = Math.max(0, lockCount + delta); + const isActive = lockCount > 0; + if (isActive === wasActive) { + return; + } + for (const listener of [...lockListeners]) { + listener(isActive); + } +} diff --git a/packages/core/src/hooks/scrollLockTouchGuard.ts b/packages/core/src/hooks/scrollLockTouchGuard.ts new file mode 100644 index 0000000000000..aaf478f95e385 --- /dev/null +++ b/packages/core/src/hooks/scrollLockTouchGuard.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file scrollLockTouchGuard.ts + * @input The node under a finger and the pan's direction, while a scroll lock + * holds the page + * @output isPanAbsorbedBeforePage and isPageZoomedIn, for the page hold + * @position Internal hook utility; gestures/pageHold calls it for + * useScrollLock. + */ + +// iOS Safari scrolls an `overflow: hidden` page under a finger. +// The page hold cancels each pan that would reach the page. This walk decides +// which pans those are. A scroller that can move in the pan's direction takes +// the pan. A scroller at its edge that contains its overscroll also takes it. +// The walk follows flat-tree parents. It ends at a top-layer or fixed-position +// element, whose containing block is the viewport, so its untaken pans go to +// the page. + +import {parentAcrossShadow} from '../utils/flatTree'; +import {isRtlElement} from './isRtlElement'; + +const SCROLLABLE = new Set(['auto', 'scroll']); + +/** Overflow at or below this many px is a box that fits, not a scroller. */ +const EDGE_TOLERANCE = 1; + +function canMoveY(element: Element, dy: number, range: number): boolean { + // A finger moving down drags the content down: scrollTop decreases. + return dy > 0 + ? element.scrollTop > EDGE_TOLERANCE - 0.5 + : element.scrollTop < range - EDGE_TOLERANCE + 0.5; +} + +function canMoveX( + element: Element, + dx: number, + range: number, + isRtl: boolean, +): boolean { + const min = isRtl ? -range : 0; + const max = isRtl ? 0 : range; + // A finger moving right drags the content right: scrollLeft decreases. + return dx > 0 + ? element.scrollLeft > min + EDGE_TOLERANCE - 0.5 + : element.scrollLeft < max - EDGE_TOLERANCE + 0.5; +} + +/** + * Whether `element` itself takes a pan. It does when it can still scroll that + * way, or when its `overscroll-behavior` contains the overscroll at its edge. + */ +function takesPan( + element: Element, + style: CSSStyleDeclaration, + isVertical: boolean, + dx: number, + dy: number, +): boolean { + if (!SCROLLABLE.has(isVertical ? style.overflowY : style.overflowX)) { + return false; + } + const range = isVertical + ? element.scrollHeight - element.clientHeight + : element.scrollWidth - element.clientWidth; + // Content that fits has nothing to scroll. The pan goes on to the next + // scroller in the chain, and at last to the page. + if (range <= EDGE_TOLERANCE) { + return false; + } + const canMove = isVertical + ? canMoveY(element, dy, range) + : canMoveX(element, dx, range, isRtlElement(element as HTMLElement)); + if (canMove) { + return true; + } + const behavior = isVertical + ? style.overscrollBehaviorY + : style.overscrollBehaviorX; + return behavior === 'contain' || behavior === 'none'; +} + +/** + * Whether `element` is in the state that a pseudo-class names (`:modal`, + * `:popover-open`). An engine without the pseudo-class has no element in that + * state. Its `matches` throws on the selector, so the engine is not asked. + * Other errors surface. + */ +function isInState(element: Element, pseudoClass: string): boolean { + const css = element.ownerDocument.defaultView?.CSS; + return ( + typeof css?.supports === 'function' && + css.supports(`selector(${pseudoClass})`) && + element.matches(pseudoClass) + ); +} + +/** + * Whether a pan that `element` does not take goes to the viewport, not to its + * DOM parent. The browser chains scrolling along containing blocks. A + * top-layer element (an open modal dialog or popover) or a fixed-position one + * has the viewport as its containing block. So a Dialog inside a scrolling + * container gives an untaken pan to the page. + */ +function chainsToViewport( + element: Element, + style: CSSStyleDeclaration, +): boolean { + return ( + style.position === 'fixed' || + isInState(element, ':modal') || + isInState(element, ':popover-open') + ); +} + +/** + * Whether the page is pinch-zoomed in (`visualViewport.scale > 1`). A + * one-finger drag then pans the zoomed view, which a zoomed-in reader needs + * around an open overlay. The value is read per touch, so a pinch during the + * lock counts from the next drag. + */ +export function isPageZoomedIn(doc: Document): boolean { + return (doc.defaultView?.visualViewport?.scale ?? 1) > 1; +} + +/** + * Whether `element` is a native control that takes a finger's drag itself. A + * range input moves its thumb, so the drag never reaches the page. + */ +function takesDragItself(element: Element): boolean { + // `:disabled` also covers a slider inside a disabled fieldset. + return element.matches('input[type="range"]:not(:disabled)'); +} + +/** + * Whether a scroller in the scroll chain of `target` takes a pan of (`dx`, + * `dy`) before the page does. + */ +export function isPanAbsorbedBeforePage( + target: EventTarget | null, + dx: number, + dy: number, +): boolean { + if (!(target instanceof Node)) { + return false; + } + const doc = target.ownerDocument ?? document; + const view = doc.defaultView; + if (view == null) { + return false; + } + const isVertical = Math.abs(dy) >= Math.abs(dx); + for ( + let node: Node | null = target; + node != null; + node = parentAcrossShadow(node) + ) { + if (!(node instanceof Element)) { + continue; + } + if (takesDragItself(node)) { + return true; + } + if ( + node === doc.documentElement || + node === doc.body || + node === doc.scrollingElement + ) { + break; + } + const style = view.getComputedStyle(node); + if (takesPan(node, style, isVertical, dx, dy)) { + return true; + } + if (chainsToViewport(node, style)) { + break; + } + } + return false; +} diff --git a/packages/core/src/hooks/scrollbarGutter.test.ts b/packages/core/src/hooks/scrollbarGutter.test.ts index 87dcf4ced3776..78962a256ba08 100644 --- a/packages/core/src/hooks/scrollbarGutter.test.ts +++ b/packages/core/src/hooks/scrollbarGutter.test.ts @@ -2,30 +2,35 @@ /** * @file scrollbarGutter.test.ts - * @input Uses vitest and a stubbed viewport + element box (jsdom does no layout) + * @input Uses vitest and a stubbed viewport, element box and CSS.supports + * (jsdom does no layout) * @output Unit tests for holdScrollbarGutter * @position Testing; validates scrollbarGutter.ts implementation * * SYNC: When scrollbarGutter.ts changes, update tests to match new behavior */ -import {afterEach, describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {holdScrollbarGutter} from './scrollbarGutter'; +const originalCss = Object.getOwnPropertyDescriptor(window, 'CSS'); + /** * jsdom does no layout, so both halves of the measurement are stubbed: the * viewport (`innerWidth` vs `documentElement.clientWidth`) and the element's * own box, which `widths` reads from and a test flips to simulate the lock - * widening it. + * widening it. `gutter` says whether the engine has `scrollbar-gutter`. */ function stub({ innerWidth, clientWidth, - widths, + widths = () => clientWidth, + gutter = true, }: { innerWidth: number; clientWidth: number; widths?: () => number; + gutter?: boolean; }) { Object.defineProperty(window, 'innerWidth', { value: innerWidth, @@ -36,9 +41,17 @@ function stub({ value: clientWidth, configurable: true, }); - if (widths) { - document.body.getBoundingClientRect = () => ({width: widths()}) as DOMRect; - } + Object.defineProperty(window, 'CSS', { + value: { + supports: (property: string, value: string) => + gutter && property === 'scrollbar-gutter' && value === 'stable', + }, + configurable: true, + writable: true, + }); + const box = vi.fn(() => ({width: widths()}) as DOMRect); + document.body.getBoundingClientRect = box; + return {box}; } const rootGutter = () => document.documentElement.style.scrollbarGutter; @@ -51,51 +64,86 @@ describe('holdScrollbarGutter', () => { delete document.documentElement.clientWidth; // @ts-expect-error -- restore the prototype's implementation delete document.body.getBoundingClientRect; + if (originalCss) { + Object.defineProperty(window, 'CSS', originalCss); + } else { + Reflect.deleteProperty(window, 'CSS'); + } + vi.restoreAllMocks(); }); - it('holds the gutter open when a space-taking scrollbar is about to be hidden', () => { + it('holds the gutter open with the lock’s styles when a space-taking scrollbar is about to be hidden', () => { // A 1024px window over a 1009px layout viewport = a 15px classic scrollbar. stub({innerWidth: 1024, clientWidth: 1009}); const hold = holdScrollbarGutter(document.body); - expect(rootGutter()).toBe('stable'); + // Taking the hold only reads; the gutter is written with the lock. + expect(rootGutter()).toBe(''); hold.settle(); - hold.release(); + expect(rootGutter()).toBe('stable'); + hold.release(); expect(rootGutter()).toBe(''); }); - it('leaves an overlay scrollbar alone — there is no gutter to hold', () => { - stub({innerWidth: 1024, clientWidth: 1024}); + it('reads no layout once the lock’s styles apply, where the engine holds the gutter', () => { + const {box} = stub({innerWidth: 1024, clientWidth: 1009}); + const computed = vi.spyOn(window, 'getComputedStyle'); + + const hold = holdScrollbarGutter(document.body); + document.documentElement.style.overflow = 'hidden'; + hold.settle(); + + // A read after the lock's writes would force a second layout during the + // overlay's opening commit. + expect(box).not.toHaveBeenCalled(); + expect(computed).not.toHaveBeenCalled(); + hold.release(); + }); + + it('leaves an overlay scrollbar alone, reading nothing past the viewport', () => { + const {box} = stub({innerWidth: 1024, clientWidth: 1024}); + const computed = vi.spyOn(window, 'getComputedStyle'); const hold = holdScrollbarGutter(document.body); hold.settle(); expect(rootGutter()).toBe(''); expect(document.body.style.paddingRight).toBe(''); + expect(box).not.toHaveBeenCalled(); + expect(computed).not.toHaveBeenCalled(); hold.release(); }); - it('does not pad when holding the gutter kept the element still', () => { - stub({innerWidth: 1024, clientWidth: 1009, widths: () => 1009}); + it('does not pad on an engine without scrollbar-gutter when the element kept its width', () => { + stub({ + innerWidth: 1024, + clientWidth: 1009, + widths: () => 1009, + gutter: false, + }); const hold = holdScrollbarGutter(document.body); hold.settle(); - expect(rootGutter()).toBe('stable'); + expect(rootGutter()).toBe(''); expect(document.body.style.paddingRight).toBe(''); hold.release(); }); - it('falls back to padding when the element grew anyway', () => { - // What an engine without scrollbar-gutter support does: the gutter request - // is ignored, the scrollbar goes away, and the element widens by 15px. + it('falls back to padding on an engine without scrollbar-gutter when the element grew', () => { + // The scrollbar goes away and the element widens by 15px. let width = 1009; - stub({innerWidth: 1024, clientWidth: 1009, widths: () => width}); + stub({ + innerWidth: 1024, + clientWidth: 1009, + widths: () => width, + gutter: false, + }); const hold = holdScrollbarGutter(document.body); width = 1024; @@ -110,7 +158,12 @@ describe('holdScrollbarGutter', () => { it("adds to the page's own padding instead of replacing it", () => { let width = 1009; - stub({innerWidth: 1024, clientWidth: 1009, widths: () => width}); + stub({ + innerWidth: 1024, + clientWidth: 1009, + widths: () => width, + gutter: false, + }); document.body.style.paddingRight = '24px'; const hold = holdScrollbarGutter(document.body); @@ -124,23 +177,44 @@ describe('holdScrollbarGutter', () => { expect(document.body.style.paddingRight).toBe('24px'); }); - it('restores the page\u2019s own scrollbar-gutter rather than clearing it', () => { + it('restores the page’s own scrollbar-gutter rather than clearing it', () => { stub({innerWidth: 1024, clientWidth: 1009}); document.documentElement.style.scrollbarGutter = 'stable both-edges'; const hold = holdScrollbarGutter(document.body); + hold.settle(); expect(rootGutter()).toBe('stable'); - hold.settle(); hold.release(); expect(rootGutter()).toBe('stable both-edges'); }); + it("keeps the gutter while another hold still needs it, and gives the page's own value back after the last one", () => { + stub({innerWidth: 1024, clientWidth: 1009}); + document.documentElement.style.scrollbarGutter = 'auto'; + + const drawer = holdScrollbarGutter(document.documentElement); + drawer.settle(); + const lock = holdScrollbarGutter(document.body); + lock.settle(); + + drawer.release(); + expect(rootGutter()).toBe('stable'); + + lock.release(); + expect(rootGutter()).toBe('auto'); + }); + it('settles once, however many times it is called', () => { let width = 1009; - stub({innerWidth: 1024, clientWidth: 1009, widths: () => width}); + stub({ + innerWidth: 1024, + clientWidth: 1009, + widths: () => width, + gutter: false, + }); const hold = holdScrollbarGutter(document.body); width = 1024; diff --git a/packages/core/src/hooks/scrollbarGutter.ts b/packages/core/src/hooks/scrollbarGutter.ts index 30f6fa27d4981..f32c7ee581bba 100644 --- a/packages/core/src/hooks/scrollbarGutter.ts +++ b/packages/core/src/hooks/scrollbarGutter.ts @@ -2,7 +2,8 @@ /** * @file scrollbarGutter.ts - * @input The element a scroll lock pins, plus live layout measurements + * @input The element whose width a scroll lock holds, plus live layout + * measurements * @output holdScrollbarGutter and the ScrollbarGutterHold it returns * @position Internal hook utility; used by useScrollLock and MobileNav so that * locking background scroll does not resize the layout viewport. @@ -16,6 +17,8 @@ // engines without `scrollbar-gutter`, and it applies only when a measurement // shows that the element moved. +import {claimRootScrollStyle} from './rootScrollStyles'; + const NOOP: ScrollbarGutterHold = { settle() {}, release() {}, @@ -23,14 +26,23 @@ const NOOP: ScrollbarGutterHold = { export interface ScrollbarGutterHold { /** - * Call once the lock's own styles are applied: measures whether the element - * grew anyway and, only then, pads the difference away. + * Call once the lock's own styles apply. It holds the gutter open, or, + * without `scrollbar-gutter`, pads away only the width the element gained. */ settle(): void; /** Call on unlock: restores everything this hold changed. */ release(): void; } +/** Whether the engine reserves a gutter for `scrollbar-gutter: stable`. */ +function supportsScrollbarGutter(): boolean { + return ( + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports('scrollbar-gutter', 'stable') + ); +} + /** * Keeps `element`'s content box the width it is right now, across a scroll * lock that is about to hide the document's scrollbar. @@ -40,8 +52,8 @@ export interface ScrollbarGutterHold { * ScrollbarGutterHold.release} on unlock: * * ``` - * const hold = holdScrollbarGutter(document.body); - * body.style.position = 'fixed'; + * const hold = holdScrollbarGutter(document.documentElement); + * document.documentElement.style.overflow = 'hidden'; * hold.settle(); * ``` * @@ -56,28 +68,37 @@ export function holdScrollbarGutter(element: HTMLElement): ScrollbarGutterHold { const root = document.documentElement; const viewportWidth = root.clientWidth; - // 0 means "no layout to measure" (jsdom, and any non-visual environment), - // not "the scrollbar is as wide as the window". - if (viewportWidth === 0) { + // 0 means that there is no layout to measure, as in jsdom. An overlay + // scrollbar takes no space, so there is nothing to hold. A gutter held open + // on such a page would add space that was never there. + if (viewportWidth === 0 || window.innerWidth <= viewportWidth) { return NOOP; } - const previousGutter = root.style.scrollbarGutter; + if (supportsScrollbarGutter()) { + // The gutter holds the width by construction, so the lock forces no second + // layout. Each hold is a stacked claim, so one release keeps the gutter + // that another hold still needs. + let releaseGutter: (() => void) | null = null; + return { + settle() { + releaseGutter ??= claimRootScrollStyle({scrollbarGutter: 'stable'}); + }, + release() { + releaseGutter?.(); + releaseGutter = null; + }, + }; + } + + // An engine without `scrollbar-gutter`: measure the element now, again + // once the lock's styles apply, and pad away what it grew. This is the one + // path that reads layout after a write. const previousPadding = element.style.paddingRight; const widthBefore = element.getBoundingClientRect().width; - - let heldGutter = false; let padded = false; let settled = false; - // Only when a space-taking scrollbar is actually there. Holding the gutter - // open on a page that never had one would carve out 15px that was never - // reserved — the same shift, in the other direction. - if (window.innerWidth > viewportWidth) { - root.style.scrollbarGutter = 'stable'; - heldGutter = true; - } - return { settle() { if (settled) { @@ -86,9 +107,7 @@ export function holdScrollbarGutter(element: HTMLElement): ScrollbarGutterHold { settled = true; // What the lock actually did to this element, rather than what we - // assumed it would do. 0 on any engine where the gutter held — and on a - // page that already sets `scrollbar-gutter: stable` itself, where the - // scrollbar was never taking the space back. + // assumed it would do. const grew = element.getBoundingClientRect().width - widthBefore; if (grew <= 0) { return; @@ -105,10 +124,6 @@ export function holdScrollbarGutter(element: HTMLElement): ScrollbarGutterHold { element.style.paddingRight = previousPadding; padded = false; } - if (heldGutter) { - root.style.scrollbarGutter = previousGutter; - heldGutter = false; - } }, }; } diff --git a/packages/core/src/hooks/useScrollLock.doc.mjs b/packages/core/src/hooks/useScrollLock.doc.mjs index 36b9526f8b0cd..6cce9ed31e9af 100644 --- a/packages/core/src/hooks/useScrollLock.doc.mjs +++ b/packages/core/src/hooks/useScrollLock.doc.mjs @@ -4,23 +4,50 @@ export const docs = { name: 'useScrollLock', displayName: 'useScrollLock', - keywords: ['scroll', 'lock', 'modal', 'dialog', 'body', 'prevent', 'background', 'ios', 'safari', 'fixed', 'scrollbar', 'gutter', 'layout shift', 'reflow'], + keywords: [ + 'scroll', + 'lock', + 'modal', + 'dialog', + 'page', + 'prevent', + 'background', + 'ios', + 'safari', + 'touch', + 'scrollbar', + 'gutter', + 'layout shift', + 'reflow', + ], params: [ { name: 'isLocked', type: 'boolean', - description: 'whether body scroll should be locked.', + description: 'whether page scroll should be locked.', required: true, }, ], returns: [], usage: { description: - 'Locks body scroll when active by pinning the body with position: fixed. This prevents background scrolling behind modals and dialogs, which is necessary for iOS Safari where overscroll-behavior: contain does not work. Restores the original scroll position when unlocked. Pinning hides the document scrollbar, so where that scrollbar takes layout space (desktop) the hook holds its gutter open with scrollbar-gutter: stable for the duration of the lock. The page, including any position: fixed chrome, does not shift sideways.', + 'Locks page scroll while active and keeps the page where it is. The page gets overflow: hidden (on the root, or on body when its own overflow is the one the viewport uses), which stops wheel, keyboard and scrollbar scrolling, and overscroll-behavior-y: none on the root and body, which stops the overscroll bounce while the lock holds. Where the primary pointer is coarse (phones and tablets), a one-finger pan that would scroll the page is cancelled, because iOS Safari scrolls an overflow: hidden page under a finger; pans that a scroller inside the overlay can take, drags on a native range input, pinch-zoom, and one-finger drags on a pinch-zoomed page (which pan the zoomed view) still work. The scroll offset never changes, so nothing jumps when the lock starts or ends, and iOS Safari keeps its toolbar as it was. Where the hidden scrollbar takes layout space (desktop), the hook holds its gutter open with scrollbar-gutter: stable, so the page, including any position: fixed chrome, does not shift sideways.', bestPractices: [ - { guidance: true, description: 'Use when opening full-screen modals or dialogs to prevent background content from scrolling.' }, - { guidance: true, description: 'Pass the same boolean that controls dialog visibility (e.g., isOpen) as the isLocked parameter.' }, - { guidance: false, description: 'Use for non-modal overlays like popovers or tooltips; users should be able to scroll away from those.' }, + { + guidance: true, + description: + 'Use when opening full-screen modals or dialogs to prevent background content from scrolling.', + }, + { + guidance: true, + description: + 'Pass the same boolean that controls dialog visibility (e.g., isOpen) as the isLocked parameter.', + }, + { + guidance: false, + description: + 'Use for non-modal overlays like popovers or tooltips; users should be able to scroll away from those.', + }, ], }, relatedComponents: ['Dialog'], @@ -32,17 +59,29 @@ export const docs = { /** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */ export const docsDense = { description: - 'Locks body scroll when active by pinning body w/ position: fixed. Prevents background scrolling behind modals + dialogs, necessary for iOS Safari where overscroll-behavior: contain does not work. Restores original scroll position when unlocked. Holds the hidden scrollbar\'s gutter open (scrollbar-gutter: stable) so the page (incl. position: fixed chrome) does not shift sideways.', + "Locks page scroll while active, page stays where it is: overflow: hidden on the root, or on body when its own overflow propagates (wheel, keyboard, scrollbar) + overscroll-behavior-y: none on root and body (no overscroll bounce while locked) + cancels 1-finger pans that would scroll the page (iOS Safari scrolls overflow: hidden pages under a finger); overlay scrollers, range inputs, pinch-zoom + drags on a zoomed page still work. Offset never changes: no jump, iOS toolbar unchanged. Holds the hidden scrollbar's gutter open (scrollbar-gutter: stable) so the page (incl. position: fixed chrome) does not shift sideways.", paramDescriptions: { - isLocked: 'whether body scroll should be locked.', + isLocked: 'whether page scroll should be locked.', }, usage: { description: - 'Locks body scroll when active by pinning body w/ position: fixed. Prevents background scrolling behind modals + dialogs, necessary for iOS Safari where overscroll-behavior: contain does not work. Restores original scroll position when unlocked. Holds the hidden scrollbar\'s gutter open (scrollbar-gutter: stable) so the page (incl. position: fixed chrome) does not shift sideways.', + "Locks page scroll while active, page stays where it is: overflow: hidden on the root, or on body when its own overflow propagates (wheel, keyboard, scrollbar) + overscroll-behavior-y: none on root and body (no overscroll bounce while locked) + cancels 1-finger pans that would scroll the page (iOS Safari scrolls overflow: hidden pages under a finger); overlay scrollers, range inputs, pinch-zoom + drags on a zoomed page still work. Offset never changes: no jump, iOS toolbar unchanged. Holds the hidden scrollbar's gutter open (scrollbar-gutter: stable) so the page (incl. position: fixed chrome) does not shift sideways.", bestPractices: [ - { guidance: true, description: 'Use when opening full-screen modals / dialogs to prevent background content from scrolling.' }, - { guidance: true, description: 'Pass same boolean that controls dialog visibility (e.g. isOpen) as isLocked parameter.' }, - { guidance: false, description: 'Use for non-modal overlays like popovers / tooltips; users should be able to scroll away from those.' }, + { + guidance: true, + description: + 'Use when opening full-screen modals / dialogs to prevent background content from scrolling.', + }, + { + guidance: true, + description: + 'Pass same boolean that controls dialog visibility (e.g. isOpen) as isLocked parameter.', + }, + { + guidance: false, + description: + 'Use for non-modal overlays like popovers / tooltips; users should be able to scroll away from those.', + }, ], }, }; diff --git a/packages/core/src/hooks/useScrollLock.test.ts b/packages/core/src/hooks/useScrollLock.test.ts index 0616fd954897a..12edc92c67aa3 100644 --- a/packages/core/src/hooks/useScrollLock.test.ts +++ b/packages/core/src/hooks/useScrollLock.test.ts @@ -3,193 +3,522 @@ /** * @file useScrollLock.test.ts * @input Uses vitest, @testing-library/react, useScrollLock hook - * @output Unit tests for single and concurrent useScrollLock instances + * @output Unit tests for single and concurrent useScrollLock instances: the + * root's overflow claim, the untouched scroll offset, the touch guard and the + * scrollbar gutter * @position Testing; validates useScrollLock.ts implementation * * SYNC: When useScrollLock.ts changes, update tests to match new behavior */ -import {afterEach, describe, expect, it, vi} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, renderHook} from '@testing-library/react'; import {useScrollLock} from './useScrollLock'; +import {isScrollLockActive, subscribeScrollLock} from './rootScrollStyles'; +import {isPanAbsorbedBeforePage} from './scrollLockTouchGuard'; +import { + coarsePrimaryPointer, + pointerQuery, +} from '../gestures/__tests__/pointerQuery'; + +/** + * A one-finger (or `fingers`-finger) touch event at (x, y) on `target`. Every + * touch reuses identifier 0, as Chrome for Android does for each new touch. + */ +function touch( + target: EventTarget, + type: 'touchstart' | 'touchmove' | 'touchend', + x: number, + y: number, + fingers = 1, +): Event { + if (type === 'touchstart') { + // A browser sends a contact's pointerdown before its touchstart; the page + // hold reads the primary pointer at a hold's first touch contact. + const down = new Event('pointerdown', {bubbles: true, composed: true}); + Object.assign(down, { + pointerType: 'touch', + pointerId: 1, + isPrimary: true, + clientX: x, + clientY: y, + }); + target.dispatchEvent(down); + } + // Touch events are composed: they leave shadow roots, retargeted to the host. + const event = new Event(type, { + bubbles: true, + cancelable: true, + composed: true, + }); + const point = {clientX: x, clientY: y, identifier: 0, target}; + // As a browser does: `touches` lists the fingers still down (none once the + // finger lifts), `changedTouches` the one this event is about. + Object.defineProperty(event, 'touches', { + value: + type === 'touchend' ? [] : Array.from({length: fingers}, () => point), + }); + Object.defineProperty(event, 'changedTouches', {value: [point]}); + target.dispatchEvent(event); + return event; +} + +/** + * A pan from (x, y0) to (x, y1) that lifts at the end, returning whether its + * move was cancelled. + */ +function pan(target: EventTarget, y0: number, y1: number, fingers = 1) { + touch(target, 'touchstart', 100, y0, fingers); + const move = touch(target, 'touchmove', 100, y1, fingers); + touch(target, 'touchend', 100, y1, fingers); + return move.defaultPrevented; +} + +/** A vertical scroller with `overflow` px to scroll, scrolled to `top`. */ +function scroller( + overflow: number, + top: number, + overscrollBehaviorY = 'auto', +): HTMLElement { + const element = document.createElement('div'); + element.style.overflowY = 'auto'; + element.style.overscrollBehaviorY = overscrollBehaviorY; + Object.defineProperty(element, 'clientHeight', {value: 200}); + Object.defineProperty(element, 'scrollHeight', {value: 200 + overflow}); + element.scrollTop = top; + document.body.append(element); + return element; +} + +const originalCss = Object.getOwnPropertyDescriptor(window, 'CSS'); + +/** + * A desktop page with a classic scrollbar: a 1024px window over a 1009px + * layout viewport (a 15px scrollbar), on an engine with `scrollbar-gutter`. + */ +function classicScrollbar(clientWidth = 1009): void { + Object.defineProperty(window, 'innerWidth', { + value: 1024, + configurable: true, + writable: true, + }); + Object.defineProperty(document.documentElement, 'clientWidth', { + value: clientWidth, + configurable: true, + }); + Object.defineProperty(window, 'CSS', { + value: { + supports: (property: string, value: string) => + property === 'scrollbar-gutter' && value === 'stable', + }, + configurable: true, + writable: true, + }); +} + +/** + * An engine whose `CSS.supports('selector(…)')` knows these selectors, on + * top of whatever `CSS.supports` already answered. + */ +function supportSelectors(selectors: ReadonlyArray): void { + const previous = window.CSS; + Object.defineProperty(window, 'CSS', { + value: { + supports: (...args: [string] | [string, string]) => + args.length === 1 + ? selectors.some(selector => args[0] === `selector(${selector})`) + : (previous?.supports(args[0], args[1]) ?? false), + }, + configurable: true, + writable: true, + }); +} describe('useScrollLock', () => { afterEach(() => { cleanup(); document.body.style.cssText = ''; + document.body.replaceChildren(); document.documentElement.style.cssText = ''; // @ts-expect-error -- drop the viewport stub so jsdom's own value comes back delete document.documentElement.clientWidth; + if (originalCss) { + Object.defineProperty(window, 'CSS', originalCss); + } else { + Reflect.deleteProperty(window, 'CSS'); + } vi.restoreAllMocks(); }); - it('restores body styles and scroll position after a single lock is released', () => { - document.body.style.overflow = 'auto'; - document.body.style.position = 'relative'; - - vi.spyOn(window, 'scrollX', 'get').mockReturnValue(120); - vi.spyOn(window, 'scrollY', 'get').mockReturnValue(480); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + it('hides the page overflow while locked and gives the page its own value back', () => { + document.documentElement.style.overflow = 'auto'; const lock = renderHook(() => useScrollLock(true)); - - expect(document.body.style.overflow).toBe('hidden'); - expect(document.body.style.position).toBe('fixed'); + expect(document.documentElement.style.overflow).toBe('hidden'); lock.unmount(); + expect(document.documentElement.style.overflow).toBe('auto'); + }); + + it("turns the page's overscroll off on the root and the body while locked, so a pull cannot refresh the page behind the overlay", () => { + const {unmount} = renderHook(() => useScrollLock(true)); + expect(document.documentElement.style.overscrollBehaviorY).toBe('none'); + expect(document.body.style.overscrollBehaviorY).toBe('none'); - expect(document.body.style.overflow).toBe('auto'); - expect(document.body.style.position).toBe('relative'); - expect(window.scrollTo).toHaveBeenCalledWith(120, 480); + unmount(); + expect(document.documentElement.style.overscrollBehaviorY).toBe(''); + expect(document.body.style.overscrollBehaviorY).toBe(''); }); - it('leaves the body alone and never scrolls when it is not locked', () => { - document.body.style.overflow = 'auto'; - document.body.style.position = 'relative'; + it('never moves the page: no body pinning, no scroll restore', () => { + vi.spyOn(window, 'scrollY', 'get').mockReturnValue(480); vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); - const lock = renderHook(() => useScrollLock(false)); - - expect(document.body.style.overflow).toBe('auto'); - expect(document.body.style.position).toBe('relative'); - expect(document.body.style.top).toBe(''); - expect(document.body.style.left).toBe(''); - expect(document.body.style.right).toBe(''); + const lock = renderHook(() => useScrollLock(true)); + // The body carries only the overscroll mirror, never a pin. + expect(document.body.style.cssText).toBe('overscroll-behavior-y: none;'); lock.unmount(); - + expect(document.body.style.cssText).toBe(''); expect(window.scrollTo).not.toHaveBeenCalled(); }); - it('pins the body edge to edge, offset upward by the current scroll position', () => { - vi.spyOn(window, 'scrollY', 'get').mockReturnValue(250); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); - - renderHook(() => useScrollLock(true)); + it('leaves the page alone when it is not locked', () => { + const lock = renderHook(() => useScrollLock(false)); - expect(document.body.style.top).toBe('-250px'); - expect(document.body.style.left).toBe('0px'); - expect(document.body.style.right).toBe('0px'); + expect(document.documentElement.style.overflow).toBe(''); + expect(isScrollLockActive()).toBe(false); + lock.unmount(); }); - it('puts back pre-existing top/left/right inline styles instead of blanking them', () => { - document.body.style.top = '10px'; - document.body.style.left = '5px'; - document.body.style.right = '3px'; - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); - + it('keeps one lock in place across rerenders with the same value', () => { + const changes = vi.fn(); + const unsubscribe = subscribeScrollLock(changes); const lock = renderHook(({locked}) => useScrollLock(locked), { initialProps: {locked: true}, }); - expect(document.body.style.position).toBe('fixed'); - lock.rerender({locked: false}); + lock.rerender({locked: true}); + lock.rerender({locked: true}); - expect(document.body.style.top).toBe('10px'); - expect(document.body.style.left).toBe('5px'); - expect(document.body.style.right).toBe('3px'); + expect(changes).toHaveBeenCalledTimes(1); + expect(isScrollLockActive()).toBe(true); + lock.unmount(); + unsubscribe(); }); - it('scrolls back to the offset captured at lock time, not the offset at release', () => { - const scrollX = vi.spyOn(window, 'scrollX', 'get').mockReturnValue(30); - const scrollY = vi.spyOn(window, 'scrollY', 'get').mockReturnValue(250); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + it('stays locked while a second overlay is still open, even if the first closes out of order', () => { + const first = renderHook(() => useScrollLock(true)); + const second = renderHook(() => useScrollLock(true)); + + first.unmount(); + + expect(document.documentElement.style.overflow).toBe('hidden'); + expect(isScrollLockActive()).toBe(true); + + second.unmount(); + + expect(document.documentElement.style.overflow).toBe(''); + expect(isScrollLockActive()).toBe(false); + }); + + it('reports the page becoming locked and free', () => { + const states: boolean[] = []; + const unsubscribe = subscribeScrollLock(() => + states.push(isScrollLockActive()), + ); const lock = renderHook(({locked}) => useScrollLock(locked), { initialProps: {locked: true}, }); - - // Pinning the body drops the document to the top in a real browser; the - // hook must restore from the value it saved, not from the current one. - scrollX.mockReturnValue(0); - scrollY.mockReturnValue(0); lock.rerender({locked: false}); - expect(window.scrollTo).toHaveBeenCalledTimes(1); - expect(window.scrollTo).toHaveBeenCalledWith(30, 250); + expect(states).toEqual([true, false]); + unsubscribe(); }); - it('keeps one lock in place across rerenders with the same value', () => { - vi.spyOn(window, 'scrollY', 'get').mockReturnValue(250); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + describe('touch guard', () => { + // A phone or tablet: the page hold listens only where the primary pointer + // is coarse (`spec:AST-094` FR19), and jsdom models a desktop. + let restorePointer: () => void = () => {}; + beforeEach(() => { + restorePointer = coarsePrimaryPointer(); + }); + afterEach(() => { + restorePointer(); + }); - const lock = renderHook(({locked}) => useScrollLock(locked), { - initialProps: {locked: true}, + it('cancels a one-finger pan that no scroller under the finger can take', () => { + const scrim = document.createElement('div'); + document.body.append(scrim); + renderHook(() => useScrollLock(true)); + + expect(pan(scrim, 300, 200)).toBe(true); }); - lock.rerender({locked: true}); - lock.rerender({locked: true}); + it('lets a one-finger drag through on a pinch-zoomed page, so a zoomed-in reader can pan around the overlay', () => { + const scrim = document.createElement('div'); + document.body.append(scrim); + const zoom = {scale: 2}; + Object.defineProperty(window, 'visualViewport', { + value: zoom, + configurable: true, + }); + try { + renderHook(() => useScrollLock(true)); + + expect(pan(scrim, 300, 200)).toBe(false); + + // Read per touch: back at 1x, the same lock holds the page again. + zoom.scale = 1; + expect(pan(scrim, 300, 200)).toBe(true); + } finally { + Reflect.deleteProperty(window, 'visualViewport'); + } + }); - // Re-running the effect would tear the lock down and back up, restoring - // scroll each time. - expect(window.scrollTo).not.toHaveBeenCalled(); - expect(document.body.style.position).toBe('fixed'); - expect(document.body.style.top).toBe('-250px'); - }); + it('lets a drag on a native range input through, so the slider follows the finger', () => { + const dialog = document.createElement('dialog'); + const slider = document.createElement('input'); + slider.type = 'range'; + dialog.append(slider); + document.body.append(dialog); + renderHook(() => useScrollLock(true)); + + // Along the slider, and along the page's axis alike: the input takes + // the drag, so it never scrolls the page (spec:AST-094 FR19). + touch(slider, 'touchstart', 100, 200); + expect(touch(slider, 'touchmove', 240, 200).defaultPrevented).toBe(false); + touch(slider, 'touchend', 240, 200); + expect(pan(slider, 300, 200)).toBe(false); + + // A disabled slider takes nothing: the drag is the page's, and held. + slider.disabled = true; + expect(pan(slider, 300, 200)).toBe(true); + }); - it('takes a fresh scroll snapshot when locked a second time', () => { - const scrollY = vi.spyOn(window, 'scrollY', 'get').mockReturnValue(100); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + it('lets a pan through when a scroller under the finger can move that way', () => { + const list = scroller(400, 100); + const row = document.createElement('div'); + list.append(row); + renderHook(() => useScrollLock(true)); - const lock = renderHook(({locked}) => useScrollLock(locked), { - initialProps: {locked: true}, + expect(pan(row, 300, 200)).toBe(false); + expect(pan(row, 200, 300)).toBe(false); }); - expect(document.body.style.top).toBe('-100px'); - lock.rerender({locked: false}); - scrollY.mockReturnValue(300); - lock.rerender({locked: true}); + it('cancels a pan on a scroller whose content fits', () => { + const body = scroller(0, 0); + renderHook(() => useScrollLock(true)); - expect(document.body.style.top).toBe('-300px'); + expect(pan(body, 300, 200)).toBe(true); + }); - lock.rerender({locked: false}); - expect(window.scrollTo).toHaveBeenLastCalledWith(0, 300); - }); + it('cancels a pan past a scroller edge that would chain to the page', () => { + const list = scroller(400, 0); + renderHook(() => useScrollLock(true)); - it('stays locked while a second overlay is still open, even if the first closes out of order', () => { - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + // At the top, a finger moving down has nothing left to scroll in the list. + expect(pan(list, 200, 300)).toBe(true); + }); - const first = renderHook(() => useScrollLock(true)); - const second = renderHook(() => useScrollLock(true)); + it('lets a pan through at an edge when the scroller keeps its own overscroll', () => { + const list = scroller(400, 0, 'contain'); + renderHook(() => useScrollLock(true)); - first.unmount(); + expect(pan(list, 200, 300)).toBe(false); + }); - expect(document.body.style.position).toBe('fixed'); - expect(document.body.style.overflow).toBe('hidden'); + describe('an overlay rendered inside a scrolling container', () => { + /** + * An open modal dialog: jsdom has no top layer, so `:modal` is stubbed, + * on an engine that knows it. + */ + function modalDialog(parent: HTMLElement): HTMLDialogElement { + supportSelectors([':modal', ':popover-open']); + const dialog = document.createElement('dialog'); + dialog.setAttribute('open', ''); + const matches = Element.prototype.matches; + vi.spyOn(dialog, 'matches').mockImplementation( + selector => selector === ':modal' || matches.call(dialog, selector), + ); + parent.append(dialog); + return dialog; + } + + it("cancels a scrim drag: a modal dialog's untaken pans go to the page, not to the container", () => { + const container = scroller(400, 100); + const dialog = modalDialog(container); + renderHook(() => useScrollLock(true)); + + expect(pan(dialog, 300, 200)).toBe(true); + expect(pan(dialog, 200, 300)).toBe(true); + }); + + it('never asks an engine without `:modal` or `:popover-open` to match them: it has no element in either state', () => { + supportSelectors([]); + const container = scroller(400, 100); + const dialog = document.createElement('dialog'); + dialog.setAttribute('open', ''); + container.append(dialog); + const matches = vi.spyOn(dialog, 'matches'); + renderHook(() => useScrollLock(true)); + + pan(dialog, 300, 200); + const asked = matches.mock.calls.map(call => call[0]); + expect(asked).not.toContain(':modal'); + expect(asked).not.toContain(':popover-open'); + }); + + it('lets an error from a state the engine reports knowing surface: nothing is caught and defaulted', () => { + supportSelectors([':modal', ':popover-open']); + const dialog = document.createElement('dialog'); + document.body.append(dialog); + const matches = Element.prototype.matches; + vi.spyOn(dialog, 'matches').mockImplementation(selector => { + if (selector === ':modal') { + throw new DOMException('the engine failed', 'SyntaxError'); + } + return matches.call(dialog, selector); + }); + + expect(() => isPanAbsorbedBeforePage(dialog, 0, 50)).toThrow( + 'the engine failed', + ); + }); + + it('cancels a drag on a fixed-position overlay inside the container', () => { + const container = scroller(400, 100); + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + container.append(overlay); + renderHook(() => useScrollLock(true)); + + expect(pan(overlay, 300, 200)).toBe(true); + }); + + it('still lets a pan through to a scroller inside the dialog, or the dialog itself', () => { + const container = scroller(400, 100); + const dialog = modalDialog(container); + const list = scroller(400, 100); + const row = document.createElement('div'); + list.append(row); + dialog.append(list); + renderHook(() => useScrollLock(true)); + + expect(pan(row, 300, 200)).toBe(false); + + const scrolling = modalDialog(container); + scrolling.style.overflowY = 'auto'; + Object.defineProperty(scrolling, 'clientHeight', {value: 200}); + Object.defineProperty(scrolling, 'scrollHeight', {value: 600}); + scrolling.scrollTop = 100; + expect(pan(scrolling, 300, 200)).toBe(false); + }); + }); - second.unmount(); - }); + it('lets a pan through when the scroller under the finger is inside a shadow root', () => { + const host = document.createElement('div'); + document.body.append(host); + const list = document.createElement('div'); + list.style.overflowY = 'auto'; + Object.defineProperty(list, 'clientHeight', {value: 200}); + Object.defineProperty(list, 'scrollHeight', {value: 600}); + list.scrollTop = 100; + const row = document.createElement('div'); + list.append(row); + host.attachShadow({mode: 'open'}).append(list); + renderHook(() => useScrollLock(true)); + + expect(pan(row, 300, 200)).toBe(false); + }); - it('fully restores the body once every overlay has closed, regardless of close order', () => { - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + it('judges a swipe after a small slop, so a first wobble does not cancel a horizontal scroll', () => { + const strip = document.createElement('div'); + strip.style.overflowX = 'auto'; + Object.defineProperty(strip, 'clientWidth', {value: 200}); + Object.defineProperty(strip, 'scrollWidth', {value: 600}); + strip.scrollLeft = 100; + document.body.append(strip); + renderHook(() => useScrollLock(true)); + + touch(strip, 'touchstart', 100, 300); + const wobble = new Event('touchmove', { + bubbles: true, + cancelable: true, + composed: true, + }); + const point = (x: number, y: number) => [ + {clientX: x, clientY: y, identifier: 0, target: strip}, + ]; + Object.defineProperty(wobble, 'touches', {value: point(102, 302)}); + strip.dispatchEvent(wobble); + const swipe = new Event('touchmove', { + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperty(swipe, 'touches', {value: point(80, 303)}); + strip.dispatchEvent(swipe); + + expect(wobble.defaultPrevented).toBe(false); + expect(swipe.defaultPrevented).toBe(false); + }); - const first = renderHook(() => useScrollLock(true)); - const second = renderHook(() => useScrollLock(true)); + it('never cancels a two-finger gesture, so pinch-zoom keeps working', () => { + const scrim = document.createElement('div'); + document.body.append(scrim); + renderHook(() => useScrollLock(true)); - first.unmount(); - second.unmount(); + expect(pan(scrim, 300, 200, 2)).toBe(false); + }); + + it('decides once per touch, as the browser picks its scroller once', () => { + const list = scroller(400, 100); + renderHook(() => useScrollLock(true)); + + touch(list, 'touchstart', 100, 300); + expect(touch(list, 'touchmove', 100, 250).defaultPrevented).toBe(false); + // The list reaching its end mid-gesture does not flip the verdict. + list.scrollTop = 400; + expect(touch(list, 'touchmove', 100, 200).defaultPrevented).toBe(false); + }); - expect(document.body.style.position).toBe(''); - expect(document.body.style.overflow).toBe(''); - expect(document.body.style.top).toBe(''); - expect(document.body.style.left).toBe(''); - expect(document.body.style.right).toBe(''); + it('is gone once the last lock is released', () => { + const scrim = document.createElement('div'); + document.body.append(scrim); + const first = renderHook(() => useScrollLock(true)); + const second = renderHook(() => useScrollLock(true)); + + first.unmount(); + expect(pan(scrim, 300, 200)).toBe(true); + + second.unmount(); + expect(pan(scrim, 300, 200)).toBe(false); + }); + + it('holds no touch where the primary pointer is fine, and starts once it turns coarse', () => { + restorePointer(); + const pointer = pointerQuery(false); + restorePointer = pointer.restore; + const scrim = document.createElement('div'); + document.body.append(scrim); + renderHook(() => useScrollLock(true)); + + // A touchscreen whose primary pointer is fine keeps the browser's own + // touch behavior; `overflow: hidden` still stops wheel and keyboard. + expect(pan(scrim, 300, 200)).toBe(false); + expect(document.documentElement.style.overflow).toBe('hidden'); + + pointer.set(true); + expect(pan(scrim, 300, 200)).toBe(true); + }); }); it('holds the page still across the scrollbar it hides', () => { - // A 1024px window over a 1009px layout viewport = a 15px classic - // scrollbar. Pinning the body hides it, which would widen the page by - // those 15px and reflow everything sideways. - Object.defineProperty(window, 'innerWidth', { - value: 1024, - configurable: true, - writable: true, - }); - Object.defineProperty(document.documentElement, 'clientWidth', { - value: 1009, - configurable: true, - }); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + // Hiding the page overflow hides the classic scrollbar, which would widen + // the page by its 15px and reflow everything sideways. + classicScrollbar(); const lock = renderHook(() => useScrollLock(true)); @@ -201,36 +530,18 @@ describe('useScrollLock', () => { }); it('leaves the page alone when the scrollbar is an overlay one', () => { - Object.defineProperty(window, 'innerWidth', { - value: 1024, - configurable: true, - writable: true, - }); - Object.defineProperty(document.documentElement, 'clientWidth', { - value: 1024, - configurable: true, - }); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + classicScrollbar(1024); const lock = renderHook(() => useScrollLock(true)); expect(document.documentElement.style.scrollbarGutter).toBe(''); - expect(document.body.style.paddingRight).toBe(''); + expect(document.documentElement.style.paddingRight).toBe(''); lock.unmount(); }); it('holds the gutter for the outermost overlay only, and gives it back once', () => { - Object.defineProperty(window, 'innerWidth', { - value: 1024, - configurable: true, - writable: true, - }); - Object.defineProperty(document.documentElement, 'clientWidth', { - value: 1009, - configurable: true, - }); - vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + classicScrollbar(); const first = renderHook(() => useScrollLock(true)); const second = renderHook(() => useScrollLock(true)); diff --git a/packages/core/src/hooks/useScrollLock.ts b/packages/core/src/hooks/useScrollLock.ts index a9c042f50c027..d5bdb30c73c08 100644 --- a/packages/core/src/hooks/useScrollLock.ts +++ b/packages/core/src/hooks/useScrollLock.ts @@ -4,46 +4,49 @@ /** * @file useScrollLock.ts - * @input Uses React useEffect - * @output Exports useScrollLock hook for locking body scroll - * @position Core hook; used by Dialog to prevent background scrolling - * - * Locks body scroll when active by pinning the body with position:fixed. - * This is needed because overscroll-behavior:contain doesn't work on iOS Safari. + * @input React useEffect, rootScrollStyles, gestures/pageHold with the pan + * decision, and the scrollbar gutter hold + * @output useScrollLock, which locks page scroll behind a modal + * @position Core hook; used by Dialog, BottomSheet, Drawer, Lightbox and + * MobileNav * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts * - /packages/core/src/hooks/scrollbarGutter.ts + * - /packages/core/src/hooks/rootScrollStyles.ts + * - /packages/core/src/gestures/pageHold.ts (holdPageScroll) + * - /packages/core/src/hooks/useScrollLockImports.test.ts */ import {useEffect} from 'react'; +import {claimRootScrollStyle, reportScrollLock} from './rootScrollStyles'; +import {holdPageScroll} from '../gestures/pageHold'; +import {isPageZoomedIn, isPanAbsorbedBeforePage} from './scrollLockTouchGuard'; import {holdScrollbarGutter, type ScrollbarGutterHold} from './scrollbarGutter'; -interface ScrollLockSnapshot { - scrollX: number; - scrollY: number; - overflow: string; - position: string; - top: string; - left: string; - right: string; +// iOS Safari scrolls an `overflow: hidden` page under a finger. +// So the lock also cancels each one-finger pan that would reach the page. The +// scroll offset never changes, so unlock restores nothing, and iOS Safari's +// toolbar keeps its state. + +interface ActiveLock { + releaseRootStyle: () => void; + releasePageHold: () => void; gutter: ScrollbarGutterHold; } let lockCount = 0; -let originalBodyState: ScrollLockSnapshot | null = null; +let activeLock: ActiveLock | null = null; /** - * Locks body scroll when `isLocked` is true. - * - * Pins the body with `position: fixed` to prevent background scrolling, - * which is necessary for iOS Safari where `overscroll-behavior: contain` - * does not prevent body scroll behind modals. Restores scroll position - * on unlock. + * Locks page scroll while `isLocked` is true. * - * Pinning also hides the document's scrollbar, so the gutter that scrollbar - * occupied is held open for the duration of the lock — without it the page - * reflows sideways by ~15px the moment an overlay opens. + * The page keeps its scroll offset, so nothing jumps on lock or unlock. Where + * the primary pointer is coarse (phones and tablets), the lock cancels a + * one-finger pan that would reach the page. + * Pans that a scroller in the overlay takes, and two-finger zoom, go through. + * The gutter of a classic scrollbar stays open, so the page does not shift + * sideways. * * @example * ``` @@ -56,54 +59,46 @@ export function useScrollLock(isLocked: boolean): void { return; } - const {body} = document; - if (lockCount === 0) { - const scrollX = window.scrollX; - const scrollY = window.scrollY; - - // Taken before the pinning styles below hide the scrollbar. - const gutter = holdScrollbarGutter(body); - - originalBodyState = { - scrollX, - scrollY, - overflow: body.style.overflow, - position: body.style.position, - top: body.style.top, - left: body.style.left, - right: body.style.right, - gutter, - }; - - body.style.overflow = 'hidden'; - body.style.position = 'fixed'; - body.style.top = `-${scrollY}px`; - body.style.left = '0'; - body.style.right = '0'; - + // The hold measures before the lock writes a style, so the lock forces no + // layout of its own. `settle` writes the gutter with the styles below. + const gutter = holdScrollbarGutter(document.documentElement); + // `overscroll-behavior-y: none` also keeps the browser's own + // pull-to-refresh (Chrome on Android) from reloading the page behind + // the overlay. + const releaseRootStyle = claimRootScrollStyle({ + overflow: 'hidden', + overscrollBehaviorY: 'none', + }); + // The hold asks the gesture host only if one exists, so the lock does + // not bring the host into every overlay's import. + const releasePageHold = holdPageScroll( + document, + // A drag on a pinch-zoomed page pans the zoomed view: it goes + // through, so a zoomed-in reader can move around the overlay. + (origin, dx, dy) => + !isPageZoomedIn(document) && !isPanAbsorbedBeforePage(origin, dx, dy), + ); gutter.settle(); + activeLock = {releaseRootStyle, releasePageHold, gutter}; } lockCount += 1; + reportScrollLock(1); return () => { lockCount -= 1; + reportScrollLock(-1); - if (lockCount !== 0 || originalBodyState == null) { + if (lockCount !== 0 || activeLock == null) { return; } - const state = originalBodyState; - originalBodyState = null; - - body.style.overflow = state.overflow; - body.style.position = state.position; - body.style.top = state.top; - body.style.left = state.left; - body.style.right = state.right; - state.gutter.release(); - window.scrollTo(state.scrollX, state.scrollY); + const lock = activeLock; + activeLock = null; + lock.releasePageHold(); + lock.releaseRootStyle(); + lock.gutter.release(); }; }, [isLocked]); } diff --git a/packages/core/src/hooks/useScrollLockImports.test.ts b/packages/core/src/hooks/useScrollLockImports.test.ts new file mode 100644 index 0000000000000..d9fe6553b6386 --- /dev/null +++ b/packages/core/src/hooks/useScrollLockImports.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useScrollLockImports.test.ts + * @input useScrollLock.ts and the core modules it imports at runtime + * @output Fails when the scroll lock's import, or an overlay's import of it, + * reaches the gesture host or its arena + * @position Testing; every overlay ships what the scroll lock imports. The + * page hold asks for the document's gesture host only if one exists, so the + * host stays out of the overlays' bundles. + * + * SYNC: When modified, update useScrollLock.ts and gestures/pageHold.ts. + */ + +import {existsSync, readFileSync} from 'node:fs'; +import {dirname, join, relative, resolve} from 'node:path'; +import {describe, expect, it} from 'vitest'; + +const SRC = resolve(__dirname, '..'); + +const IMPORT = + /(?:^|[\s;])(?:import|export)\s+(type\s+)?([^'";]*?)\s*from\s*['"](\.{1,2}\/[^'"]+)['"]/g; +const BARE = /(?:^|[\s;])import\s*['"](\.{1,2}\/[^'"]+)['"]/g; + +function resolveModule(from: string, specifier: string): string | null { + const base = join(dirname(from), specifier).replace(/\.js$/, ''); + for (const candidate of [ + base, + `${base}.ts`, + `${base}.tsx`, + join(base, 'index.ts'), + join(base, 'index.tsx'), + ]) { + if (/\.tsx?$/.test(candidate) && existsSync(candidate)) { + return candidate; + } + } + return null; +} + +/** The modules `file` loads at runtime; type-only imports load nothing. */ +function runtimeImports(file: string): string[] { + const text = readFileSync(file, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + const found: string[] = []; + for (const [, typeOnly, clause = '', specifier = ''] of text.matchAll( + IMPORT, + )) { + const named = /^\{([\s\S]*)\}$/.exec(clause.trim()); + const allTypes = + named != null && + (named[1] ?? '') + .split(',') + .map(part => part.trim()) + .filter(Boolean) + .every(part => part.startsWith('type ')); + if (typeOnly == null && !allTypes) { + const target = resolveModule(file, specifier); + if (target != null) { + found.push(target); + } + } + } + for (const [, specifier = ''] of text.matchAll(BARE)) { + const target = resolveModule(file, specifier); + if (target != null) { + found.push(target); + } + } + return found; +} + +/** Every core module importing `entry` loads, relative to src. */ +function reachableFrom(entry: string): Set { + const seen = new Set(); + const queue = [resolve(SRC, entry)]; + while (queue.length > 0) { + const file = queue.pop() as string; + const key = relative(SRC, file); + if (seen.has(key)) { + continue; + } + seen.add(key); + queue.push(...runtimeImports(file)); + } + return seen; +} + +describe('useScrollLock imports', () => { + // The lock itself, and the overlays that take it: whatever the lock pulls + // in ships with each of them. + // AlertDialog is left out: its action group's press tracking brings the + // host itself. + it.each([ + 'hooks/useScrollLock.ts', + 'Dialog/Dialog.tsx', + 'BottomSheet/BottomSheet.tsx', + 'Drawer/Drawer.tsx', + 'Lightbox/Lightbox.tsx', + ])('%s reaches neither the gesture host nor its arena', entry => { + const reached = reachableFrom(entry); + const gestures = [...reached].filter(path => path.startsWith('gestures/')); + expect(gestures).not.toContain('gestures/GestureHost.ts'); + expect(gestures).not.toContain('gestures/GestureArena.ts'); + }); +}); diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs index e5bd50531b24d..01a04f39191fd 100644 --- a/scripts/touch-support-bundle.test.mjs +++ b/scripts/touch-support-bundle.test.mjs @@ -153,6 +153,28 @@ const GESTURE_USERS = new Set([ 'SegmentedControl', // DateTimeInput: through the SegmentedControl its touch field renders. 'DateTimeInput', + // useScrollLock's page hold, and the imports that reach it. + 'hooks: useScrollLock', + 'AppShell', + 'BottomSheet', + 'CommandPalette', + 'ContextMenu', + 'DateInput', + 'Dialog', + 'Drawer', + 'DropdownMenu', + 'Layer', + 'Lightbox', + 'Markdown', + 'MobileNav', + 'MoreMenu', + 'MultiSelector', + 'Pagination', + 'Popover', + 'Selector', + 'SideNav', + 'TimeInput', + 'TopNav', ]); /** Public imports that carry no gesture code, whatever the list above says. */