diff --git a/.changeset/carousel-paging.md b/.changeset/carousel-paging.md
new file mode 100644
index 0000000000000..168a618c9bc7a
--- /dev/null
+++ b/.changeset/carousel-paging.md
@@ -0,0 +1,9 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feat] Carousel: under a finger, a `hasSnap` carousel with its buttons pages like an iOS paging scroll view. Each swipe moves one child and settles on that child's start edge on UIKit's paging curve; the content follows the finger and rubber-bands at the ends, even while JavaScript is busy. A mouse, a trackpad and the keyboard keep native snapping, and so does a finger without the buttons, because the buttons are the single-pointer way to page.
+
+This changes existing `hasSnap` carousels on touch screens. A fast swipe now moves one child instead of coasting across several, and while the touch pager runs the content does not scroll natively: it fires no `scroll` events and its `scrollLeft` stays 0. To keep native scrolling under a finger, render the carousel with `hasButtons={false}`. The buttons and the handle keep their step of about one viewport, landing on a child; with `hasLoop`, Next on the last child returns to the first, as it already did with a pointer. Under Reduce Motion a programmatic scroll cross-fades on a coarse pointer and jumps at once on any other.
+
+@vjeux
diff --git a/.changeset/gestures-wheel.md b/.changeset/gestures-wheel.md
new file mode 100644
index 0000000000000..755a7032488c0
--- /dev/null
+++ b/.changeset/gestures-wheel.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[chore] Internal: a pan follows a trackpad's two-finger scroll where its owner allows it (`allowedScrollTypes: ['continuous']`, UIKit's `allowedScrollTypesMask`; none by default), along its own axis and after the scrollers under the pointer, and never a mouse wheel's notches, which scroll what is under the pointer; the screen-edge pan takes none; and a pinch takes a ctrl-wheel from a trackpad or a mouse (AST-094 FR43, DEC-24). A watcher pan never takes the wheel, and a wheel another handler already prevented stays the page's. Input the deltas cannot place, Shift+wheel included, reads as a mouse wheel; a trackpad scroll a pan follows keeps moving at the fingers' speed until their lift is read, ends there still moving, and the momentum never moves it. Not exported from the package entry point.
+
+@vjeux
diff --git a/.changeset/uikit-pager.md b/.changeset/uikit-pager.md
new file mode 100644
index 0000000000000..1a077103ead27
--- /dev/null
+++ b/.changeset/uikit-pager.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[chore] Internal: a pager for touch presentations that pages like a UIKit paging scroll view. A horizontal pan moves the pages one to one and rubber-bands at the ends. The release picks the page with UIKit's rule and settles on UIKit's paging curve on the compositor, and a finger can catch it mid-flight. It takes every pointer: a mouse drags it as a finger does, and its pan follows a trackpad's horizontal scroll, at most one page per swipe. A mouse wheel's scroll stays the page's. Its motion values are internal constants. Not exported from the package entry point.
+
+@vjeux
diff --git a/.changeset/uikit-paging-values.md b/.changeset/uikit-paging-values.md
new file mode 100644
index 0000000000000..8096b94de001e
--- /dev/null
+++ b/.changeset/uikit-paging-values.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[chore] Internal: UIKit's paging values and math for touch presentations. The thresholds that pick the page a release settles on, the settle that carries it there and `setContentOffset(animated:)` are measured on iOS 26.2 and replayed against the recorded frames to under half a pixel. Not exported from the package entry point.
+
+@vjeux
diff --git a/apps/storybook/stories/Carousel.stories.tsx b/apps/storybook/stories/Carousel.stories.tsx
index a1260a823431d..c4f4e29295016 100644
--- a/apps/storybook/stories/Carousel.stories.tsx
+++ b/apps/storybook/stories/Carousel.stories.tsx
@@ -31,6 +31,10 @@ const styles = stylex.create({
width: 160,
flexShrink: 0,
},
+ pagingCard: {
+ width: 200,
+ flexShrink: 0,
+ },
cardInner: {
padding: spacingVars['--spacing-3'],
},
@@ -59,8 +63,68 @@ const styles = stylex.create({
marginBottom: spacingVars['--spacing-2'],
fontFamily: typographyVars['--font-family-body'],
},
+ // One page per item: the pages are as wide as their inline-size container.
+ pages: {
+ maxWidth: 400,
+ containerType: 'inline-size',
+ },
+ page: {
+ width: '100cqw',
+ boxSizing: 'border-box',
+ height: 220,
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'flex-end',
+ gap: spacingVars['--spacing-1'],
+ padding: spacingVars['--spacing-4'],
+ borderRadius: radiusVars['--radius-element'],
+ color: '#fff',
+ fontFamily: typographyVars['--font-family-body'],
+ },
+ pageTitle: {
+ margin: 0,
+ fontSize: 22,
+ fontWeight: 700,
+ color: 'inherit',
+ },
+ pageBody: {
+ margin: 0,
+ fontSize: 14,
+ color: 'inherit',
+ },
+ longPageSpacer: {
+ height: 120,
+ },
+ longPageTail: {
+ height: 1600,
+ },
});
+const PAGES = [
+ {title: 'Plan', body: 'Pick a place and the dates.', color: '#3a6ea5'},
+ {title: 'Pack', body: 'A checklist for every trip.', color: '#527e51'},
+ {title: 'Go', body: 'Tickets and directions offline.', color: '#b05f34'},
+ {title: 'Share', body: 'Photos and notes for the group.', color: '#7a4f9a'},
+ {title: 'Remember', body: 'Your trips, a year later.', color: '#3a797f'},
+];
+
+function PagePanel({
+ title,
+ body,
+ color,
+}: {
+ title: string;
+ body: string;
+ color: string;
+}) {
+ return (
+
+
{title}
+
{body}
+
+ );
+}
+
const IMAGES = [
{id: 1, src: 'https://picsum.photos/id/1042/200/200', label: 'dark.jpg'},
{id: 2, src: 'https://picsum.photos/id/1043/200/200', label: 'light.jpg'},
@@ -101,7 +165,8 @@ const meta: Meta = {
},
hasSnap: {
control: 'boolean',
- description: 'Enable scroll-snap',
+ description:
+ 'Enable scroll-snap; under a finger, with the buttons, one child per swipe',
},
},
decorators: [
@@ -325,6 +390,80 @@ export const Loop: Story = {
),
};
+export const Paging: Story = {
+ name: 'Paging (Snap, Full-Width Pages)',
+ render: () => (
+
+
+ Under a finger each swipe moves one page and settles like an iOS paging
+ scroll view; a mouse, a trackpad and the keyboard snap natively
+
+ );
+}
+
+function LoopDemo() {
+ const viewportRef = useRef(null);
+ const trackRef = useRef(null);
+ const [virtual, setVirtual] = useState(0);
+ const pager = usePager({
+ viewportRef,
+ trackRef,
+ count: COUNT,
+ gap: GAP,
+ loop: true,
+ onIndexChange: () => setVirtual(pager.virtualIndex),
+ });
+ // The pages around the one on screen, each at its own place on the
+ // unbounded track: the owner renders a looping pager's copies.
+ const near = [virtual - 2, virtual - 1, virtual, virtual + 1, virtual + 2];
+ return (
+
+
+ Page {wrap(virtual) + 1} of {COUNT}, looping
+
+
+
+
+ {near.map(at => (
+
+ {wrap(at) + 1}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+const meta = {
+ title: 'Core/Pager Preview',
+ parameters: {layout: 'fullscreen'},
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Pages: Story = {
+ name: 'Pages',
+ render: () => ,
+};
+
+export const Loop: Story = {
+ name: 'Loop',
+ render: () => ,
+};
diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md
index 73a2faf4b458d..62479b77a97f3 100644
--- a/docs/specs/AST-094-touch-presentations/spec.md
+++ b/docs/specs/AST-094-touch-presentations/spec.md
@@ -103,6 +103,17 @@ a measured, sourced difference exists.
measures (0.55 for a scroll view and a sheet), and a release MUST pick its
rest point by projecting its velocity the way its UIKit counterpart does:
with a deceleration rate, or with a measured projection time.
+ A paging scroll view (the pager, and Carousel with `hasSnap` under a
+ finger) picks its rest page as its UIKit counterpart measures it, with no
+ projection: the nearest page, or the next page in the release's direction
+ when the release is at least `paging.release.advanceSpeed` (300 pt/s) fast,
+ at any position on the page. It moves at most
+ `paging.release.maxPagesFromStart` (one page) from the page the drag started
+ on, and a release moving back toward that page stays on it (FR37). Both
+ values are measured on the iOS 26.2 simulator, 2026-10-23: steady releases
+ at 10, 25 and 40 % of a 402 pt page stay at 292.7–298.6 pt/s and advance at
+ 310.7–329.9 pt/s; releases at rest stay at 45–48.5 % and advance at
+ 55–57.5 % (DEC-25).
- **FR4 — Touch motion uses named roles.** Every spring, duration and curve a
touch presentation plays MUST be a motion role named for its use
(`architecture:theme-tokens` INV3). The roles this record names are listed below with their defaults and
@@ -157,6 +168,10 @@ a measured, sourced difference exists.
and `component:ContextMenu` owns them (DEC-5). They play only in a
ContextMenu that a touch opens.
+ Implementation gap: the pager's measured paging motion (its settle
+ integrator, its 300 ms animated scroll and that scroll's sinusoidal curve,
+ DEC-25) plays from internal constants and is not yet a named role.
+
### Motion
- **FR5 — Motion runs on the compositor.** Every release and every
@@ -172,6 +187,14 @@ a measured, sourced difference exists.
MUST come from the curve, not from a layout read. A new target MUST keep the
current velocity, and a release MUST hand its velocity to the spring that
follows.
+ A pager's release slower than `paging.release.velocityFloor` (225 pt/s)
+ starts its settle from rest, as UIKit's does; a faster release hands its
+ velocity to the settle. The floor is a guess inside the measured bracket
+ (UIKit zeroes steady releases at 175 and 200 pt/s and keeps 249 pt/s, iOS
+ 26.2 simulator, 2026-10-23) until a measurement replaces it (FR2). A
+ programmatic page change on a pager (a page control's tap, a keyboard step)
+ starts from rest: this is a known gap against this requirement until the
+ pager carries the current velocity into the page change (DEC-25).
- **FR7 — Exits animate as the native counterpart's do.** A touch
presentation MUST play the exit its UIKit counterpart plays, on every touch
platform. FR8 governs their reduced-motion form. Each overlay's own record states its
@@ -575,7 +598,9 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
deltas; a lift then continues without a stop, and fingers that keep moving
take up the difference. A pan released at the lift releases with the
fingers' last velocity (FR36); a pan whose stream stops releases still. A
- pinch takes a ctrl-wheel and ends 100 ms after its last event. A BottomSheet's pull
+ window blur or a hidden page cancels a trackpad pan, as the browser taking a
+ touch cancels a finger's. A pinch takes a ctrl-wheel and ends 100 ms after
+ its last event. A BottomSheet's pull
follows a trackpad as it follows a finger: a sheet a swipe cannot close
(`purpose` `form` or `required`) stretches past its resting place on FR3's
rubber band for a finger and a trackpad alike, the same travel giving the
@@ -711,9 +736,11 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FR1, FR2 | Value check over every measured value and its uses | every value; a guess and its measurement task; a platform-specific value; two components playing the same motion | an unsourced value, an unknown source kind, a guess without a listed measurement, an unsourced platform split, or two values for one use fails |
| FR3 | Unit tests of the spring, decay, rubber band and projection math against an independent integration and values read from UIKit | under-, critically and overdamped springs; release velocities; both rates; overscroll depths | changing a formula or a constant breaks the agreement with UIKit's values |
+| FR3 pager | Unit tests of the pager's page choice against the recorded UIKit releases | releases at 292.7–298.6 pt/s that stay and 310.7–329.9 pt/s that advance, at 10, 25 and 40 % of a page; releases at rest at 45–48.5 % and 55–57.5 %; a 1.5-page drag released at 0, 600 and 3000 pt/s; a release moving back toward the start page | a page chosen by projection, a release at or past 300 pt/s that stays, or a release that travels more than one page from its start fails |
| FR4 | Role unit tests over every touch motion | each role at its table value; a theme that writes `motion` | a literal played in place of a role, a value that differs from the table at `tempo` 1 and `'standard'`, or a theme that changes a role other than through its motion knobs fails |
| FR5 | Browser proof: an animation keeps advancing frame by frame through a 1 s main-thread block, with no layout or paint per frame | Chromium phone emulation; WebKit | a script-driven or layout-animating implementation freezes or lays out |
| FR6 | Unit and browser tests catching an animation mid-flight and retargeting it | catch at peak velocity; retarget with a new spring; a retarget before the browser reports the call's start | a layout read, a jump, or a lost velocity fails |
+| FR6 pager | Unit and browser tests of a pager's settle start | releases just under and over 225 pt/s; a catch mid-settle; a programmatic page change mid-settle | a release under the floor whose settle starts moving, a release over it whose settle starts from rest, or a catch that loses the on-screen velocity fails; a programmatic page change from rest is the recorded gap |
| FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), or an opacity fade over 200 ms fails |
| FR9, FR10 | Recognizer unit tests on synthetic streams and browser tests with real touch input | drag, second finger, pinch, page zoom, main-thread block | a second finger that breaks a drag, or a page pinch blocked other than by the author's `touch-action`, fails |
| FR11, FR12 | Press and long-press tests on synthetic streams with explicit timestamps | inside and outside a scroll view; a drag the browser takes; a lift inside; a menu row under a finger; a press on a row of a menu whose rows fit and of one that scrolls; a slide-off lift on a Switch and on a Radio | the wrong profile's timing, a press that survives the browser taking the touch, or a control outside an action group activated other than by the browser's click, outside `spec:AST-020` FR9's slide-off exception, a row of a menu whose rows fit that waits 150 ms, or one of a scrolling menu that does not, or a slide-off lift that completes a control outside `spec:AST-020` FR9's exception, fails |
@@ -733,7 +760,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails |
| FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails |
| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a slider's thumb under a wheel and a trackpad scroll; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, a wheel or trackpad scroll that changes a thumb's value or does not reach the scroll under it, an idle listener in a component with no gesture, or added stylesheet bytes, fails |
-| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, or a pinch that ends after 100 ms fails |
+| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending; a trackpad pan through a window blur and through a hidden page | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, a trackpad pan a window blur or a hidden page leaves mid-gesture, or a pinch that ends after 100 ms fails |
| all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect |
## Decision log
@@ -1026,6 +1053,24 @@ from its top.
Rejected: cancelling moves only after the pan begins.
+### DEC-25 — A pager pages as UIKit measures it
+
+**Reference:** `spec:AST-094/DEC-25`
+**Decider:** `cixzhang`, `2026-11-01`
+
+A pager, and Carousel with `hasSnap` under a finger, pick the rest page and
+start the settle as UIKit's paging scroll view does on iOS 26.2. UIKit
+chooses the page by a speed threshold, the same 300 pt/s at 10, 25 and 40 %
+of a page, not by a projection, and starts a slow release's settle from rest.
+The owner tried this paging on the pager and the paging Carousel on
+2026-11-01, with a trackpad and on a phone: "trackpad is good", "phone good".
+This narrows FR3 and FR6 for paging only; every other release keeps them. A
+programmatic page change starts from rest until the pager carries velocity
+into it.
+
+Rejected: projecting a paging release with a deceleration rate; handing a
+slow paging release's velocity to the settle.
+
## Open questions
- **OQ1 — Scroll view bounce-back curve.** What curve brings a scroll view
diff --git a/docs/specs/components/Carousel/Carousel.spec.md b/docs/specs/components/Carousel/Carousel.spec.md
index 73fe470016dba..af8afd4fa5091 100644
--- a/docs/specs/components/Carousel/Carousel.spec.md
+++ b/docs/specs/components/Carousel/Carousel.spec.md
@@ -20,7 +20,7 @@ families: [family:buttons]
design_specs: []
architecture: [architecture:interaction-modality]
contributing: []
-system_specs: [spec:AST-085, spec:AST-025]
+system_specs: [spec:AST-085, spec:AST-025, spec:AST-094]
---
# Carousel component contract
@@ -83,6 +83,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| FR2 | When the focused edge scroll button runs out, focus moves to the scroll area without scrolling it. This holds when the content no longer overflows and the scroll area is out of the tab order (`spec:AST-025` FR12): it still takes focus programmatically. Focus never lands on the page body or on a hidden button. | DEC-1; `spec:AST-085` FR4 | settled |
| FR3 | The handoff happens once per edge transition, and only while the edge button held focus. An edge reached by swipe, resize, or wheel while focus is elsewhere moves nothing. | `architecture:interaction-modality` INV8 | settled |
| FR4 | An edge scroll button that is available behaves as an ordinary Button. | `family:buttons` | settled |
+| FR5 | With `hasSnap`, touch paging follows `spec:AST-094`'s pager paging rule (FR3, FR6): under a finger, a release picks its page and starts its settle as the pager does. Under a mouse or trackpad, the scroll area keeps native scroll snapping. | `spec:AST-094` FR3, FR6, DEC-25 | settled |
### Representative states
@@ -112,14 +113,16 @@ visual evidence.
removes; FR2 names the scroll area.
- `spec:AST-025` FR12 owns the scroll area's tab stop and naming.
- `family:buttons` owns the edge buttons' behavior while available.
+- `spec:AST-094` FR3 and FR6 own touch paging with `hasSnap` (FR5).
## Verification map
-| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
-| -------- | --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ |
-| FR1, AR1 | Unit and accessibility-tree tests | at rest at each edge; no overflow | A hidden button that is focusable or exposed fails | `audit:Carousel/accessibility` |
-| FR2, AR2 | Real-browser keyboard test | Enter on "Scroll right" until the end; same on the start | Focus on the page body, a hidden button, or the opposite button fails | `audit:Carousel/accessibility` |
-| FR3 | Real-browser test with swipe and resize | edge reached with focus outside the carousel | Focus moving without an edge-button press, or moving twice for one edge, fails | `audit:Carousel/accessibility` |
+| Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
+| -------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
+| FR1, AR1 | Unit and accessibility-tree tests | at rest at each edge; no overflow | A hidden button that is focusable or exposed fails | `audit:Carousel/accessibility` |
+| FR2, AR2 | Real-browser keyboard test | Enter on "Scroll right" until the end; same on the start | Focus on the page body, a hidden button, or the opposite button fails | `audit:Carousel/accessibility` |
+| FR3 | Real-browser test with swipe and resize | edge reached with focus outside the carousel | Focus moving without an edge-button press, or moving twice for one edge, fails | `audit:Carousel/accessibility` |
+| FR5 | Real-browser test with touch input on a Carousel with `hasSnap` | a short flick over 300 pt/s; a slow push before half a page; a slow release past half | a page choice or settle start that differs from the pager's, or a mouse or trackpad scroll that runs the pager instead of native snapping, fails | — |
## Decision log
diff --git a/internal/eslint-plugin-astryx/no-comment-provenance.baseline.json b/internal/eslint-plugin-astryx/no-comment-provenance.baseline.json
index f926d7d2c0812..872fdbe83c5fb 100644
--- a/internal/eslint-plugin-astryx/no-comment-provenance.baseline.json
+++ b/internal/eslint-plugin-astryx/no-comment-provenance.baseline.json
@@ -1611,7 +1611,6 @@
"FR20",
"FR20",
"FR20",
- "FR20",
"FR21",
"FR21",
"FR21",
@@ -1720,7 +1719,6 @@
"spec:AST-094",
"spec:AST-094",
"spec:AST-094",
- "spec:AST-094",
"spec:AST-094"
],
"packages/core/src/gestures/GestureRecognizer.ts": [
@@ -1803,14 +1801,12 @@
"FR19",
"FR24",
"FR43",
- "FR43",
"design:bidirectionality",
"spec quote",
"spec:AST-094",
"spec:AST-094",
"spec:AST-094",
"spec:AST-094",
- "spec:AST-094",
"spec:AST-094"
],
"packages/core/src/gestures/SwipeGestureRecognizer.ts": [
diff --git a/packages/core/src/Carousel/Carousel.doc.mjs b/packages/core/src/Carousel/Carousel.doc.mjs
index 3317a6d7a27e7..5d26892e5a824 100644
--- a/packages/core/src/Carousel/Carousel.doc.mjs
+++ b/packages/core/src/Carousel/Carousel.doc.mjs
@@ -6,13 +6,27 @@ export const docs = {
name: 'Carousel',
displayName: 'Carousel',
category: 'Container',
- keywords: ['carousel', 'slider', 'scroll', 'gallery', 'filmstrip', 'swiper', 'horizontal', 'overflow', 'snap'],
+ keywords: [
+ 'carousel',
+ 'slider',
+ 'scroll',
+ 'gallery',
+ 'filmstrip',
+ 'swiper',
+ 'horizontal',
+ 'overflow',
+ 'snap',
+ 'paging',
+ 'pager',
+ 'pages',
+ ],
usage: {
description:
'Carousel scrolls a row of items horizontally when they overflow the available width. Use it for card grids, image galleries, product lists, or any set of items that should be browsable without taking up the full page.',
bestPractices: [
{guidance: true, description: 'Enable scroll-snap when each item should land precisely at the start edge, like a gallery or product list.'},
{guidance: true, description: 'Reach for hasLoop on small, cyclable sets like a photo gallery, where wrapping past the last item back to the first feels natural.'},
+ {guidance: true, description: "Under a finger, a snapping carousel with its buttons pages like an iOS paging scroll view: each swipe moves one child and settles on its start edge. For one item per page (onboarding screens, a photo strip), size each item to the carousel's width, for example 100cqw inside an inline-size container."},
{guidance: true, description: 'Always provide an aria-label that describes what the carousel contains, like "Featured products" or "Team members".'},
{guidance: true, description: 'Carousel implements the WAI-ARIA APG carousel pattern (https://www.w3.org/WAI/ARIA/apg/patterns/carousel/) without auto-rotation: the region is labelled and carries aria-roledescription="carousel", each item is a group named "Slide N of M", and the scroll container is a tab stop while its content overflows, so keyboard users can pan it with the arrow keys; while everything fits it stays out of the tab order.'},
{guidance: true, description: 'Use a consistent gap and item width so the carousel looks intentional, not like content overflowing by accident.'},
@@ -22,27 +36,117 @@ export const docs = {
{guidance: false, description: 'Nest carousels. A carousel inside a carousel is confusing and breaks keyboard navigation.'},
],
anatomy: [
- {name: 'Scroll container', required: true, description: 'The horizontal overflow area that holds all items.'},
- {name: 'Items', required: true, description: 'The children rendered in a row inside the scroll container. With hasSnap, each item snaps to the start edge.'},
- {name: 'Fade edges', required: false, description: 'Gradient fades on the left and right edges that indicate more content is available. Enabled by default, disable with hasEdgeFade={false}.'},
- {name: 'Navigation buttons', required: false, description: 'Prev/next buttons that appear when the content can scroll in that direction. Enabled by default, disable with hasButtons={false}.'},
+ {
+ name: 'Scroll container',
+ required: true,
+ description: 'The horizontal overflow area that holds all items.',
+ },
+ {
+ name: 'Items',
+ required: true,
+ description:
+ 'The children rendered in a row inside the scroll container. With hasSnap, each item snaps to the start edge.',
+ },
+ {
+ name: 'Fade edges',
+ required: false,
+ description:
+ 'Gradient fades on the left and right edges that indicate more content is available. Enabled by default, disable with hasEdgeFade={false}.',
+ },
+ {
+ name: 'Navigation buttons',
+ required: false,
+ description:
+ 'Prev/next buttons that appear when the content can scroll in that direction. Enabled by default, disable with hasButtons={false}.',
+ },
],
},
props: [
- {name: 'children', type: 'ReactNode', description: 'Carousel items rendered in a horizontal scroll container.', required: true},
- {name: 'gap', type: "0 | 0.5 | 1 | 1.5 | 2 | 3 | 4", description: 'Gap between items using the spacing token scale.', default: '1'},
- {name: 'hasButtons', type: 'boolean', description: 'Show prev/next navigation buttons when content is scrollable.', default: 'true'},
- {name: 'hasEdgeFade', type: 'boolean', description: 'Show a gradient edge-fade mask when content overflows, signalling that more items exist off-screen.', default: 'true'},
- {name: 'hasLoop', type: 'boolean', description: 'Wrap-around scrolling: next at the end scrolls to the start, prev at the start scrolls to the end. Buttons stay visible at both edges. Only engages when content overflows.', default: 'false'},
- {name: 'hasSnap', type: 'boolean', description: 'Enable scroll-snap so each child snaps to the start edge.', default: 'false'},
- {name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Inline padding inside the scroll container, with matching scroll-padding so snap points align to the content edge.'},
- {name: 'aria-label', type: 'string', description: 'Accessible label for the carousel region.', default: "\'Carousel\'"},
- {name: 'ref', type: 'React.Ref', description: 'Ref forwarded to the root element.'},
- {name: 'handleRef', type: 'React.Ref', description: 'Imperative handle exposing scrollNext(), scrollPrev(), scrollTo(index), canScrollNext(), and canScrollPrev() for programmatic scroll control.'},
- {name: 'xstyle', type: 'StyleXStyles', description: 'StyleX styles for layout customization (margins, positioning, sizing). Must be a stylex.create() value.'},
- {name: 'className', type: 'string', description: 'CSS class name for the root element. Prefer xstyle for styling.'},
- {name: 'style', type: 'CSSProperties', description: 'Inline styles for the root element. Prefer xstyle.'},
- {name: 'data-testid', type: 'string', description: 'Test selector for automated testing frameworks.'},
+ {
+ name: 'children',
+ type: 'ReactNode',
+ description: 'Carousel items rendered in a horizontal scroll container.',
+ required: true,
+ },
+ {
+ name: 'gap',
+ type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4',
+ description: 'Gap between items using the spacing token scale.',
+ default: '1',
+ },
+ {
+ name: 'hasButtons',
+ type: 'boolean',
+ description:
+ 'Show prev/next navigation buttons when content is scrollable.',
+ default: 'true',
+ },
+ {
+ name: 'hasEdgeFade',
+ type: 'boolean',
+ description:
+ 'Show a gradient edge-fade mask when content overflows, signalling that more items exist off-screen.',
+ default: 'true',
+ },
+ {
+ name: 'hasLoop',
+ type: 'boolean',
+ description:
+ 'Wrap-around scrolling: next at the end scrolls to the start, prev at the start scrolls to the end. Buttons stay visible at both edges. Only engages when content overflows.',
+ default: 'false',
+ },
+ {
+ name: 'hasSnap',
+ type: 'boolean',
+ description:
+ 'Enable scroll-snap so each child snaps to the start edge. Under a finger, with the buttons shown, a swipe moves one child and settles like an iOS paging scroll view; a mouse, a trackpad, the keyboard, and a finger with hasButtons={false} (the buttons are the single-pointer way to page) snap natively.',
+ default: 'false',
+ },
+ {
+ name: 'padding',
+ type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10',
+ description:
+ 'Inline padding inside the scroll container, with matching scroll-padding so snap points align to the content edge.',
+ },
+ {
+ name: 'aria-label',
+ type: 'string',
+ description: 'Accessible label for the carousel region.',
+ default: "\'Carousel\'",
+ },
+ {
+ name: 'ref',
+ type: 'React.Ref',
+ description: 'Ref forwarded to the root element.',
+ },
+ {
+ name: 'handleRef',
+ type: 'React.Ref',
+ description:
+ 'Imperative handle exposing scrollNext(), scrollPrev(), scrollTo(index), canScrollNext(), and canScrollPrev() for programmatic scroll control.',
+ },
+ {
+ name: 'xstyle',
+ type: 'StyleXStyles',
+ description:
+ 'StyleX styles for layout customization (margins, positioning, sizing). Must be a stylex.create() value.',
+ },
+ {
+ name: 'className',
+ type: 'string',
+ description:
+ 'CSS class name for the root element. Prefer xstyle for styling.',
+ },
+ {
+ name: 'style',
+ type: 'CSSProperties',
+ description: 'Inline styles for the root element. Prefer xstyle.',
+ },
+ {
+ name: 'data-testid',
+ type: 'string',
+ description: 'Test selector for automated testing frameworks.',
+ },
],
playground: {
defaults: {
@@ -56,7 +160,10 @@ export const docs = {
theming: {
targets: [
{className: 'astryx-carousel'},
- {className: 'astryx-carousel-scroller', visualProps: ['gap', 'padding', 'snap', 'edgeFade']},
+ {
+ className: 'astryx-carousel-scroller',
+ visualProps: ['gap', 'padding', 'snap', 'edgeFade'],
+ },
],
},
};
@@ -67,15 +174,51 @@ export const docsZh = {
description:
'Carousel 在项目超出可用宽度时将一行项目水平滚动。适用于卡片网格、图片画廊、产品列表,或任何希望无需占据整页即可浏览的项目集合。',
bestPractices: [
- {guidance: true, description: '当每个项目应精确对齐到起始边缘时启用滚动吸附,例如画廊或产品列表。'},
- {guidance: true, description: '在小型可循环的集合(如照片画廊)上使用 hasLoop,此时从最后一项绕回第一项的滚动更自然。'},
- {guidance: true, description: '始终提供描述轮播内容的 aria-label,例如"精选产品"或"团队成员"。'},
- {guidance: true, description: 'Carousel 实现不含自动轮播的 WAI-ARIA APG carousel 模式(https://www.w3.org/WAI/ARIA/apg/patterns/carousel/):区域带有标签和 aria-roledescription="carousel",每个项目是名为“第 N 张,共 M 张”的 group,滚动容器可获得焦点,键盘用户可用方向键平移。'},
- {guidance: true, description: '使用一致的间距和项目宽度,让轮播看起来是有意为之,而不是内容意外溢出。'},
- {guidance: true, description: '信任内置导航:触控板用户可水平滑动,鼠标用户可按住 Shift 滚动滚轮来浏览项目。'},
- {guidance: false, description: '将每位用户都必须看到的内容放入轮播。并非所有人都会水平滚动,关键内容应放在首屏。'},
+ {
+ guidance: true,
+ description:
+ '当每个项目应精确对齐到起始边缘时启用滚动吸附,例如画廊或产品列表。',
+ },
+ {
+ guidance: true,
+ description:
+ '在小型可循环的集合(如照片画廊)上使用 hasLoop,此时从最后一项绕回第一项的滚动更自然。',
+ },
+ {
+ guidance: true,
+ description:
+ '用手指操作时,带按钮的吸附轮播像 iOS 分页滚动视图一样翻页:每次滑动移动一个子元素并停靠在其起始边缘。若要一页一项(如引导页或照片条),让每个项目与轮播同宽,例如在 inline-size 容器内使用 100cqw。',
+ },
+ {
+ guidance: true,
+ description:
+ '始终提供描述轮播内容的 aria-label,例如"精选产品"或"团队成员"。',
+ },
+ {
+ guidance: true,
+ description:
+ 'Carousel 实现不含自动轮播的 WAI-ARIA APG carousel 模式(https://www.w3.org/WAI/ARIA/apg/patterns/carousel/):区域带有标签和 aria-roledescription="carousel",每个项目是名为“第 N 张,共 M 张”的 group,滚动容器可获得焦点,键盘用户可用方向键平移。',
+ },
+ {
+ guidance: true,
+ description:
+ '使用一致的间距和项目宽度,让轮播看起来是有意为之,而不是内容意外溢出。',
+ },
+ {
+ guidance: true,
+ description:
+ '信任内置导航:触控板用户可水平滑动,鼠标用户可按住 Shift 滚动滚轮来浏览项目。',
+ },
+ {
+ guidance: false,
+ description:
+ '将每位用户都必须看到的内容放入轮播。并非所有人都会水平滚动,关键内容应放在首屏。',
+ },
{guidance: false, description: '自动轮播项目。让用户按自己的节奏滚动。'},
- {guidance: false, description: '嵌套轮播。轮播中嵌套轮播会造成困惑并破坏键盘导航。'},
+ {
+ guidance: false,
+ description: '嵌套轮播。轮播中嵌套轮播会造成困惑并破坏键盘导航。',
+ },
],
},
propDescriptions: {
@@ -83,29 +226,71 @@ export const docsZh = {
gap: '使用间距令牌比例的项目间距。',
hasButtons: '内容可滚动时显示上一个/下一个导航按钮。',
hasEdgeFade: '内容溢出时显示渐变边缘遮罩,提示屏幕外还有更多项目。',
- hasLoop: '环绕滚动:在末尾按"下一个"回到开头,在开头按"上一个"跳到末尾。按钮在两端始终可见。仅在内容溢出时生效。',
- hasSnap: '启用滚动吸附,使每个子元素吸附到起始边缘。',
- padding: '滚动容器内的内联内边距,并设置匹配的 scroll-padding,使吸附点与内容边缘对齐。',
+ hasLoop:
+ '环绕滚动:在末尾按"下一个"回到开头,在开头按"上一个"跳到末尾。按钮在两端始终可见。仅在内容溢出时生效。',
+ hasSnap:
+ '启用滚动吸附,使每个子元素吸附到起始边缘。用手指操作且显示按钮时,每次滑动移动一个子元素,并像 iOS 分页滚动视图一样停靠;鼠标、触控板、键盘,以及 hasButtons={false} 时的手指(按钮是单指针翻页方式)使用原生吸附。',
+ padding:
+ '滚动容器内的内联内边距,并设置匹配的 scroll-padding,使吸附点与内容边缘对齐。',
'aria-label': '轮播区域的无障碍标签。',
- handleRef: '命令式句柄,暴露 scrollNext()、scrollPrev()、scrollTo(index)、canScrollNext() 和 canScrollPrev() 以进行编程式滚动控制。',
+ handleRef:
+ '命令式句柄,暴露 scrollNext()、scrollPrev()、scrollTo(index)、canScrollNext() 和 canScrollPrev() 以进行编程式滚动控制。',
},
};
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
export const docsDense = {
- description: 'horizontal scroll container w/ fade edges, nav buttons, scroll-snap',
+ description:
+ 'horizontal scroll container w/ fade edges, nav buttons, scroll-snap',
usage: {
description:
'Carousel scrolls items horizontally when they overflow. Use for card grids, galleries, product lists.',
bestPractices: [
- {guidance: true, description: 'Enable scroll-snap when each item should land precisely at the start edge: gallery, product list.'},
- {guidance: true, description: 'Reach for hasLoop on small cyclable sets (photo gallery) where wrapping past the last item back to the first feels natural.'},
- {guidance: true, description: 'Always provide an aria-label describing what the carousel contains: "Featured products", "Team members".'},
- {guidance: true, description: 'Use consistent gap + item width so the carousel looks intentional, not like content overflowing by accident.'},
- {guidance: true, description: 'Trust built-in navigation: trackpad users swipe horizontally, mouse users hold Shift + wheel to move through items.'},
- {guidance: false, description: 'Use a carousel for content every user must see. Not everyone scrolls horizontally, so put critical content above the fold.'},
- {guidance: false, description: 'Auto-advance items. Let the user scroll at their own pace.'},
- {guidance: false, description: 'Nest carousels. A carousel inside a carousel is confusing and breaks keyboard navigation.'},
+ {
+ guidance: true,
+ description:
+ 'Enable scroll-snap when each item should land precisely at the start edge: gallery, product list.',
+ },
+ {
+ guidance: true,
+ description:
+ 'Reach for hasLoop on small cyclable sets (photo gallery) where wrapping past the last item back to the first feels natural.',
+ },
+ {
+ guidance: true,
+ description:
+ 'hasSnap + buttons, under a finger: one child per swipe, iOS paging settle; one item per page = items as wide as the carousel (e.g. 100cqw in an inline-size container).',
+ },
+ {
+ guidance: true,
+ description:
+ 'Always provide an aria-label describing what the carousel contains: "Featured products", "Team members".',
+ },
+ {
+ guidance: true,
+ description:
+ 'Use consistent gap + item width so the carousel looks intentional, not like content overflowing by accident.',
+ },
+ {
+ guidance: true,
+ description:
+ 'Trust built-in navigation: trackpad users swipe horizontally, mouse users hold Shift + wheel to move through items.',
+ },
+ {
+ guidance: false,
+ description:
+ 'Use a carousel for content every user must see. Not everyone scrolls horizontally, so put critical content above the fold.',
+ },
+ {
+ guidance: false,
+ description:
+ 'Auto-advance items. Let the user scroll at their own pace.',
+ },
+ {
+ guidance: false,
+ description:
+ 'Nest carousels. A carousel inside a carousel is confusing and breaks keyboard navigation.',
+ },
],
},
propDescriptions: {
@@ -113,11 +298,14 @@ export const docsDense = {
gap: 'item spacing via spacing token scale',
hasButtons: 'prev/next buttons when content is scrollable',
hasEdgeFade: 'gradient edge-fade mask on overflow',
- hasLoop: 'wrap-around: next at end -> start, prev at start -> end; buttons stay visible; only when overflowing',
- hasSnap: 'scroll-snap; children snap to start edge',
+ hasLoop:
+ 'wrap-around: next at end -> start, prev at start -> end; buttons stay visible; only when overflowing',
+ hasSnap:
+ 'scroll-snap; children snap to start edge; touch + buttons: one child per swipe, iOS paging physics; else (mouse, trackpad, keyboard, touch with hasButtons={false}): native snap',
padding: 'inline padding; scroll-padding keeps snap points on content edge',
'aria-label': 'accessible label for carousel region',
- handleRef: 'imperative handle: scrollNext/scrollPrev/scrollTo(index)/canScrollNext/canScrollPrev',
+ handleRef:
+ 'imperative handle: scrollNext/scrollPrev/scrollTo(index)/canScrollNext/canScrollPrev',
'data-testid': 'test selector for automated testing frameworks',
},
};
diff --git a/packages/core/src/Carousel/Carousel.tsx b/packages/core/src/Carousel/Carousel.tsx
index 8f04f0b56046d..d535b6ec95b34 100644
--- a/packages/core/src/Carousel/Carousel.tsx
+++ b/packages/core/src/Carousel/Carousel.tsx
@@ -11,7 +11,9 @@
* visual bleed allowance for child selection indicators, and Shift + wheel
* mapping so mouse users can scroll horizontally. Supports optional
* wrap-around looping and an imperative handle (handleRef) for programmatic
- * scroll control. Exposes APG
+ * scroll control. With hasSnap, under a finger and with its buttons, a
+ * swipe moves one child and settles like an iOS paging scroll view
+ * (useCarouselPaging). Otherwise the scroller snaps natively. Exposes APG
* carousel semantics: the root is a labelled region with
* aria-roledescription="carousel" and each item wrapper is a group with
* aria-roledescription="slide" named "Slide N of M". The scroll area is a
@@ -20,6 +22,7 @@
*
* SYNC: When modified, update:
* - /packages/core/src/Carousel/index.ts (exports)
+ * - /packages/core/src/Carousel/useCarouselPaging.ts
* - /apps/storybook/stories/Carousel.stories.tsx
* - /packages/cli/assets/templates/blocks/components/Carousel/ (showcase blocks)
*/
@@ -49,6 +52,9 @@ import {useScrollOverflow} from '../hooks/useScrollOverflow';
import {isRtlElement} from '../hooks/isRtlElement';
import type {BaseProps} from '../BaseProps';
import {mergeProps, rtlStyles} from '../utils';
+import {useTouchPresentation} from '../utils/touchPresentation';
+import {reducedMotionFade} from '../motion/reducedMotion';
+import {useCarouselPaging} from './useCarouselPaging';
import type {SpacingStep} from '../utils/types';
import {themeProps} from '../utils/themeProps';
@@ -133,6 +139,9 @@ export interface CarouselProps extends BaseProps {
hasLoop?: boolean;
/**
* Enable scroll-snap on items. Each direct child snaps to the start edge.
+ * Under a finger, with the buttons shown, a swipe moves one child and
+ * settles like an iOS paging scroll view. A mouse, a trackpad, the
+ * keyboard and a finger without the buttons snap natively.
* @default false
*/
hasSnap?: boolean;
@@ -158,6 +167,40 @@ export interface CarouselProps extends BaseProps {
// Styles
// =============================================================================
+/**
+ * Runs a programmatic scroll as the Reduce Motion policy asks. It is smooth
+ * when motion is not reduced. Under Reduce Motion, it jumps at once on a fine
+ * pointer. On a coarse pointer, it cross-fades, and the scroller jumps while
+ * it is transparent.
+ */
+function presentScroll(
+ el: HTMLElement,
+ scroll: (behavior: ScrollBehavior) => void,
+): void {
+ const fade = reducedMotionFade(el);
+ if (fade == null) {
+ scroll('smooth');
+ return;
+ }
+ if (fade.duration === 0 || typeof el.animate !== 'function') {
+ scroll('auto');
+ return;
+ }
+ const timing = {duration: fade.duration / 2, easing: fade.easing};
+ const out = el.animate([{opacity: 1}, {opacity: 0}], {
+ ...timing,
+ fill: 'forwards',
+ });
+ void out.finished.then(
+ () => {
+ scroll('auto');
+ el.animate([{opacity: 0}, {opacity: 1}], timing);
+ out.cancel();
+ },
+ () => {},
+ );
+}
+
const styles = stylex.create({
root: {
position: 'relative',
@@ -208,6 +251,23 @@ const styles = stylex.create({
display: 'flex',
flexShrink: 0,
},
+ // A snapping carousel with buttons holds its items in one row that the
+ // touch pager can move. It renders on every pointer, so hydration never
+ // moves the items.
+ track: {
+ display: 'flex',
+ alignItems: 'center',
+ flexShrink: 0,
+ },
+ // Touch paging: the pager moves the track; nothing scrolls natively, not
+ // even to bring a focused element into view (the pager does that). A
+ // clipping box is not a scroll container, so its automatic minimum width
+ // is its content's. A zero minimum keeps it at the carousel's width.
+ pagingTouchScroller: {
+ overflowX: 'clip',
+ overflowY: 'clip',
+ minWidth: 0,
+ },
// Overlay on top layer — covers the carousel anchor area
buttonOverlay: {
display: 'flex',
@@ -364,6 +424,25 @@ export function Carousel({
// renders even when some children are conditionally omitted.
const slides = Children.toArray(children);
+ // Snapping under a finger, a coarse primary pointer on any touch device:
+ // the shared pager moves the track one child per swipe. A mouse, a
+ // trackpad and the keyboard keep native snapping, on the platform's own
+ // scroll physics. Without the buttons, a finger snaps natively too. The
+ // edge buttons are the single-pointer alternative to the pager's swipe
+ // (WCAG 2.5.1), and native scrolling needs none.
+ const trackRef = useRef(null);
+ const {isTouch} = useTouchPresentation();
+ const hasTrack = hasSnap && hasButtons;
+ const isTouchPaging = hasTrack && isTouch;
+ const paging = useCarouselPaging({
+ isEnabled: isTouchPaging,
+ scrollElRef,
+ trackRef,
+ count: slides.length,
+ loop: hasLoop,
+ layout: `${gap}:${padding ?? ''}`,
+ });
+
const layer = useLayer({
mode: 'context',
lightDismiss: false,
@@ -412,18 +491,14 @@ export function Carousel({
const scrollBy = useCallback(
(direction: -1 | 1) => {
+ if (isTouchPaging) {
+ paging.goBy(direction);
+ return;
+ }
const el = scrollElRef.current;
if (!el) {
return;
}
- // Respect the user's reduced-motion preference — mirrors the CSS
- // scroll-behavior override so button-driven scrolling doesn't animate
- // for users who opted out of motion.
- const prefersReducedMotion =
- typeof window !== 'undefined' &&
- typeof window.matchMedia === 'function' &&
- window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- const behavior = prefersReducedMotion ? 'auto' : 'smooth';
// Under RTL the scroll axis is inverted (start is scrollLeft 0, the end
// is negative), so flip the physical delta sign.
const rtlSign = isRtlElement(el) ? -1 : 1;
@@ -437,51 +512,68 @@ export function Carousel({
const atEnd = direction === 1 && !overflowEnd;
const atStart = direction === -1 && !overflowStart;
if (atEnd || atStart) {
- el.scrollBy({left: rtlSign * -direction * el.scrollWidth, behavior});
+ presentScroll(el, behavior =>
+ el.scrollBy({
+ left: rtlSign * -direction * el.scrollWidth,
+ behavior,
+ }),
+ );
return;
}
}
- const firstChild = el.firstElementChild as HTMLElement | null;
- const itemWidth = firstChild ? firstChild.offsetWidth : 0;
+ const firstChild = (
+ hasTrack ? trackRef.current?.firstElementChild : el.firstElementChild
+ ) as HTMLElement | null | undefined;
+ const itemWidth = firstChild?.offsetWidth ?? 0;
const amount = el.clientWidth - itemWidth * 0.5;
- el.scrollBy({
- // `direction` is the logical intent (-1 = toward content start, +1 =
- // toward content end).
- left: rtlSign * direction * Math.max(amount, itemWidth),
- behavior,
- });
+ presentScroll(el, behavior =>
+ el.scrollBy({
+ // `direction` is the logical intent (-1 = toward content start, +1 =
+ // toward content end).
+ left: rtlSign * direction * Math.max(amount, itemWidth),
+ behavior,
+ }),
+ );
},
- [hasLoop, hasOverflow, overflowStart, overflowEnd],
+ [
+ isTouchPaging,
+ paging,
+ hasTrack,
+ hasLoop,
+ hasOverflow,
+ overflowStart,
+ overflowEnd,
+ ],
);
- const scrollToIndex = useCallback((index: number) => {
- const el = scrollElRef.current;
- const items = el?.children;
- if (!el || !items || items.length === 0) {
- return;
- }
- const clamped = Math.max(0, Math.min(index, items.length - 1));
- const target = items[clamped] as HTMLElement;
- const prefersReducedMotion =
- typeof window !== 'undefined' &&
- typeof window.matchMedia === 'function' &&
- window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- // Bring the item to the start edge by scrolling the container by the
- // measured gap between the item and the container edge. Using scrollBy
- // (not scrollIntoView) keeps the scroll contained to the carousel and
- // never moves ancestors or the page. In RTL the start edge is the right
- // edge, so align the trailing edges instead of the leading ones.
- const containerRect = el.getBoundingClientRect();
- const itemRect = target.getBoundingClientRect();
- const delta = isRtlElement(el)
- ? itemRect.right - containerRect.right
- : itemRect.left - containerRect.left;
- el.scrollBy({
- left: delta,
- behavior: prefersReducedMotion ? 'auto' : 'smooth',
- });
- }, []);
+ const scrollToIndex = useCallback(
+ (index: number) => {
+ if (isTouchPaging) {
+ paging.goTo(index);
+ return;
+ }
+ const el = scrollElRef.current;
+ const items = hasTrack ? trackRef.current?.children : el?.children;
+ if (!el || !items || items.length === 0) {
+ return;
+ }
+ const clamped = Math.max(0, Math.min(index, items.length - 1));
+ const target = items[clamped] as HTMLElement;
+ // Bring the item to the start edge by scrolling the container by the
+ // measured gap between the item and the container edge. Using scrollBy
+ // (not scrollIntoView) keeps the scroll contained to the carousel and
+ // never moves ancestors or the page. In RTL the start edge is the right
+ // edge, so align the trailing edges instead of the leading ones.
+ const containerRect = el.getBoundingClientRect();
+ const itemRect = target.getBoundingClientRect();
+ const delta = isRtlElement(el)
+ ? itemRect.right - containerRect.right
+ : itemRect.left - containerRect.left;
+ presentScroll(el, behavior => el.scrollBy({left: delta, behavior}));
+ },
+ [isTouchPaging, paging, hasTrack],
+ );
useImperativeHandle(
handleRef,
@@ -491,16 +583,43 @@ export function Carousel({
scrollTo: (index: number) => scrollToIndex(index),
// With loop, either direction is reachable whenever the content
// overflows; otherwise reflect the live per-edge overflow state.
- canScrollNext: () => (hasLoop ? hasOverflow : overflowEnd),
- canScrollPrev: () => (hasLoop ? hasOverflow : overflowStart),
+ canScrollNext: () =>
+ isTouchPaging
+ ? paging.canGoForward
+ : hasLoop
+ ? hasOverflow
+ : overflowEnd,
+ canScrollPrev: () =>
+ isTouchPaging
+ ? paging.canGoBack
+ : hasLoop
+ ? hasOverflow
+ : overflowStart,
}),
- [scrollBy, scrollToIndex, hasLoop, hasOverflow, overflowStart, overflowEnd],
+ [
+ scrollBy,
+ scrollToIndex,
+ isTouchPaging,
+ paging,
+ hasLoop,
+ hasOverflow,
+ overflowStart,
+ overflowEnd,
+ ],
);
// With loop, the buttons stay reachable at both edges as long as there's
// something to scroll; without loop they follow the per-edge overflow state.
- const canScrollStart = hasLoop && hasOverflow ? true : overflowStart;
- const canScrollEnd = hasLoop && hasOverflow ? true : overflowEnd;
+ const canScrollStart = isTouchPaging
+ ? paging.canGoBack
+ : hasLoop && hasOverflow
+ ? true
+ : overflowStart;
+ const canScrollEnd = isTouchPaging
+ ? paging.canGoForward
+ : hasLoop && hasOverflow
+ ? true
+ : overflowEnd;
// Keep the keyboard user where they are when a nav button disables under
// them. The browser drops focus off a control it disables, so reaching an
@@ -565,12 +684,18 @@ export function Carousel({
receiver.focus({preventScroll: true});
}, [canScrollStart, canScrollEnd]);
+ // Under the touch pager nothing scrolls natively: the fade follows its page.
+ const fadesStart = isTouchPaging ? paging.page > 0 : overflowStart;
+ const fadesEnd = isTouchPaging
+ ? paging.page < paging.pageCount - 1
+ : overflowEnd;
+ const overflows = isTouchPaging ? paging.pageCount > 1 : hasOverflow;
const fadeStyle = hasEdgeFade
- ? (hasLoop && hasOverflow) || (overflowStart && overflowEnd)
+ ? (hasLoop && overflows) || (fadesStart && fadesEnd)
? styles.fadeBoth
- : overflowStart
+ : fadesStart
? styles.fadeStart
- : overflowEnd
+ : fadesEnd
? styles.fadeEnd
: null
: null;
@@ -583,6 +708,26 @@ export function Carousel({
height: 'anchor-size(height)',
};
+ const renderSlides = () =>
+ slides.map((child, index) => (
+ // APG carousel pattern: each slide container is a group with
+ // aria-roledescription="slide" and an "N of M" accessible name so
+ // ATs announce slide boundaries and position instead of anonymous
+ // generics.
+
+ {child}
+
+ ));
+
return (
)}
@@ -607,6 +752,8 @@ export function Carousel({
aria-label={ariaLabel}
tabIndex={hasOverflow ? 0 : -1}
onWheel={handleWheel}
+ onKeyDown={isTouchPaging ? paging.onKeyDown : undefined}
+ onFocus={isTouchPaging ? paging.onFocus : undefined}
{...mergeProps(
themeProps('carousel-scroller', {
gap,
@@ -616,30 +763,20 @@ export function Carousel({
}),
stylex.props(
styles.scroller,
- gapStyles[gap],
+ !hasTrack && gapStyles[gap],
padding != null && paddingStyles[padding],
- hasSnap && styles.snap,
+ hasSnap && !isTouchPaging && styles.snap,
+ isTouchPaging && styles.pagingTouchScroller,
fadeStyle,
),
)}>
- {slides.map((child, index) => (
- // APG carousel pattern: each slide container is a group with
- // aria-roledescription="slide" and an "N of M" accessible name so
- // ATs announce slide boundaries and position instead of anonymous
- // generics.
-
- {child}
+ {hasTrack ? (
+
+ {renderSlides()}
- ))}
+ ) : (
+ renderSlides()
+ )}
{hasButtons &&
diff --git a/packages/core/src/Carousel/CarouselPaging.test.tsx b/packages/core/src/Carousel/CarouselPaging.test.tsx
new file mode 100644
index 0000000000000..c360f64718662
--- /dev/null
+++ b/packages/core/src/Carousel/CarouselPaging.test.tsx
@@ -0,0 +1,430 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file CarouselPaging.test.tsx
+ * @input vitest (jsdom), Testing Library, Carousel with `hasSnap`, a stubbed
+ * `(pointer: coarse)` media query and stubbed slide geometry
+ * @output Proves a snapping carousel's DOM and wiring. Its slides sit in a
+ * track. A pointer, or a carousel without buttons, snaps natively. Under a
+ * finger, the shared pager pages the children: keyboard, buttons, handle,
+ * focus, the end clamp, the loop, layers and the edge fade
+ * @position Testing; the real-touch proofs are
+ * __tests__/CarouselPaging.interaction.browser.spec.ts
+ */
+
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {createRef} from 'react';
+import {act, fireEvent, render, screen} from '@testing-library/react';
+import {Carousel, type CarouselHandle} from './Carousel';
+
+class MockResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+const WIDTH = 300;
+
+/** The slides' stubbed geometry: each `slide` px wide, `gap` px apart. */
+const geometry = {slide: WIDTH, gap: 0};
+
+/** jsdom lays nothing out: slides sit in a row from the track's start. */
+function stubLayout() {
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
+ function (this: HTMLElement) {
+ const {slide, gap} = geometry;
+ if (this.getAttribute('aria-roledescription') === 'slide') {
+ const index = Array.prototype.indexOf.call(
+ this.parentElement?.children ?? [],
+ this,
+ );
+ return new DOMRect(index * (slide + gap), 0, slide, 100);
+ }
+ const slides = this.querySelectorAll(':scope > [aria-roledescription]');
+ if (
+ slides.length > 0 &&
+ this.parentElement?.getAttribute('role') !== 'region'
+ ) {
+ const n = slides.length;
+ return new DOMRect(0, 0, n * slide + (n - 1) * gap, 100);
+ }
+ return new DOMRect(0, 0, WIDTH, 100);
+ },
+ );
+ vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(
+ function (this: HTMLElement) {
+ return this.getAttribute('aria-roledescription') === 'slide'
+ ? geometry.slide
+ : WIDTH;
+ },
+ );
+}
+
+/** The frame callbacks the pager left for the next frame. */
+async function nextFrame() {
+ await act(async () => {
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)));
+ });
+}
+
+/**
+ * A coarse (finger) or fine primary pointer, on any engine (one touch model:
+ * nothing asks for iOS); `reduced` turns Reduce Motion on.
+ */
+function stubPointer(coarse: boolean, {reduced = false} = {}) {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn((query: string) => ({
+ matches: query.includes('pointer: coarse')
+ ? coarse
+ : reduced && query.includes('prefers-reduced-motion: reduce'),
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ );
+}
+
+beforeEach(() => {
+ vi.stubGlobal('ResizeObserver', MockResizeObserver);
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(WIDTH);
+ geometry.slide = WIDTH;
+ geometry.gap = 0;
+ stubLayout();
+ if (typeof HTMLElement.prototype.scrollTo !== 'function') {
+ HTMLElement.prototype.scrollTo = function () {};
+ }
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+function getScroller() {
+ return screen.getByRole('region').firstElementChild as HTMLElement;
+}
+
+function getTrack() {
+ return getScroller().firstElementChild as HTMLElement;
+}
+
+function trackX() {
+ const value = getTrack().style.translate;
+ return value === '' ? 0 : Number.parseFloat(value);
+}
+
+function renderPages(
+ props: Partial> = {},
+) {
+ const handleRef = createRef();
+ render(
+
+
+
+ );
+ geometry.gap = 8;
+ const view = render(pages(2));
+ act(() => handleRef.current?.scrollTo(2));
+ expect(trackX()).toBe(-2 * (WIDTH + 8));
+ geometry.gap = 24;
+ view.rerender(pages(4));
+ expect(trackX()).toBe(-2 * (WIDTH + 24));
+ });
+
+ it('without the buttons it snaps natively, so a single pointer can still page (WCAG 2.5.1)', () => {
+ renderPages({hasButtons: false});
+ const scroller = getScroller();
+ expect(getComputedStyle(scroller).overflowX).not.toBe('clip');
+ expect(getComputedStyle(scroller).scrollSnapType).toBe('x mandatory');
+ // No track either: the slides are the scroller's own children.
+ expect(scroller.children[0]).toHaveAttribute(
+ 'aria-roledescription',
+ 'slide',
+ );
+ });
+
+ it('with hasLoop, a step past either end goes to the other one, as it does natively', () => {
+ const handle = renderPages({hasLoop: true});
+ expect(handle.current?.canScrollPrev()).toBe(true);
+ act(() => handle.current?.scrollPrev());
+ expect(trackX()).toBe(-3 * WIDTH);
+ act(() => handle.current?.scrollNext());
+ expect(trackX()).toBe(0);
+ expect(handle.current?.canScrollNext()).toBe(true);
+ });
+
+ it('the edge fade follows the page: the end at the first, the start at the last', () => {
+ renderPages({hasEdgeFade: true});
+ const scroller = getScroller();
+ expect(getComputedStyle(scroller).maskImage).toContain('to left');
+ fireEvent.keyDown(scroller, {key: 'End'});
+ expect(getComputedStyle(scroller).maskImage).toContain('to right');
+ expect(getComputedStyle(scroller).maskImage).not.toContain('calc(100%');
+ });
+ });
+});
diff --git a/packages/core/src/Carousel/__tests__/CarouselPaging.interaction.browser.spec.ts b/packages/core/src/Carousel/__tests__/CarouselPaging.interaction.browser.spec.ts
new file mode 100644
index 0000000000000..029aaa4254110
--- /dev/null
+++ b/packages/core/src/Carousel/__tests__/CarouselPaging.interaction.browser.spec.ts
@@ -0,0 +1,841 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file CarouselPaging.interaction.browser.spec.ts
+ * @input The built "Paging" Carousel stories and "With Padding", real touch
+ * input (Chromium CDP, WebKit's input command) and the interaction harness
+ * @output Proof that a snapping Carousel with buttons on a touch screen pages
+ * like a UIKit paging scroll view whose pages are its children. It covers
+ * drags, flicks, cards, padding, the edge fade, vertical drags, Next,
+ * Reduce Motion, the loop, a busy main thread, and storyboards
+ * @position Browser proof for Carousel `hasSnap` under a finger
+ * (useCarouselPaging + hooks/usePager.ts), run by `pnpm test:interaction`
+ */
+
+import {expect, test, type Locator, type Page} from '@playwright/test';
+import {
+ animationStoryboard,
+ armHold,
+ busyWindowProof,
+ compareCurve,
+ curveOf,
+ drag,
+ flick,
+ markElement,
+ openStory,
+ releaseAnimations,
+ sampleCurve,
+ startScreencast,
+ timeline,
+ toTouchEvents,
+ touchDriver,
+ trackMarker,
+ waitForRest,
+ withStorybook,
+ type HeldAnimation,
+ type TouchDriver,
+} from '@astryxdesign/interaction-harness';
+import {solvePaging} from '../../motion/paging';
+
+const storybook = withStorybook(test);
+
+declare global {
+ interface Window {
+ frameTimes: number[];
+ touchTimes: [string, number][];
+ armFrames: () => void;
+ }
+}
+
+/**
+ * For a failure's load report, installed before each story loads. It records
+ * the frames the page draws from each touch, press or click until 2 s after
+ * the last one. It also records each touch event's own time, read first by a
+ * window capture listener, before any listener can stop it.
+ */
+function installLoadRecorders() {
+ window.frameTimes = [];
+ let framesUntil = 0;
+ let framing = false;
+ const frame = (time: number) => {
+ window.frameTimes.push(time);
+ if (performance.now() < framesUntil) {
+ requestAnimationFrame(frame);
+ } else {
+ framing = false;
+ }
+ };
+ window.armFrames = () => {
+ framesUntil = performance.now() + 2000;
+ if (!framing) {
+ framing = true;
+ requestAnimationFrame(frame);
+ }
+ };
+ for (const type of ['touchstart', 'pointerdown', 'click']) {
+ addEventListener(type, window.armFrames, {capture: true, passive: true});
+ }
+ window.touchTimes = [];
+ for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) {
+ addEventListener(
+ type,
+ event => window.touchTimes.push([type, event.timeStamp]),
+ {capture: true, passive: true},
+ );
+ }
+}
+
+/**
+ * The touch script the case played last, and the page's clock when it began.
+ * The times (ms) are those of the steps that reach the page as events: a move
+ * where no finger moved sends none.
+ */
+let played: {steps: ReadonlyArray; from: number} | null = null;
+test.beforeEach(async ({page}) => {
+ played = null;
+ await page.addInitScript(installLoadRecorders);
+});
+
+/** The touch driver, noting each script it plays for the load report. */
+async function recordingScripts(page: Page): Promise {
+ const driver = await touchDriver(page);
+ return {
+ ...driver,
+ play: async (script, options) => {
+ const where = new Map();
+ const steps: number[] = [];
+ for (const step of toTouchEvents(script)) {
+ const moved = step.changed.some(
+ finger => where.get(finger.id) !== `${finger.x},${finger.y}`,
+ );
+ for (const finger of step.changed) {
+ where.set(finger.id, `${finger.x},${finger.y}`);
+ }
+ if (step.type !== 'touchMove' || moved) {
+ steps.push(step.t);
+ }
+ }
+ played = {steps, from: await page.evaluate(() => performance.now())};
+ return driver.play(script, options);
+ },
+ };
+}
+
+/**
+ * What the page received and drew while a case ran. It goes before a failed
+ * case's own messages, so a failure under load names its cause with numbers.
+ * Touches that took over 30 ms longer than the script are a stall. A closest
+ * gap under a quarter of the script's is a burst. A gap over 50 ms between
+ * the frames drawn from the input on is frame starvation.
+ */
+async function loadReport(page: Page): Promise {
+ const drawn = await page.evaluate(() => ({
+ frames: window.frameTimes ?? [],
+ touches: window.touchTimes ?? [],
+ origin: performance.timeOrigin,
+ }));
+ // Some engines stamp events in epoch milliseconds.
+ const at = (time: number) => (time > 1e12 ? time - drawn.origin : time);
+ const ms = (value: number) => `${value.toFixed(1)} ms`;
+ const causes: string[] = [];
+ const script = played;
+ const touches = drawn.touches
+ .map(([, time]) => at(time))
+ .filter(time => script == null || time >= script.from);
+ if (script != null && script.steps.length > 1 && touches.length > 1) {
+ const gaps = (times: ReadonlyArray) =>
+ times.slice(1).map((time, i) => time - times[i]);
+ const arrived = gaps(touches);
+ const scripted = gaps(script.steps);
+ const longest = Math.max(...arrived);
+ const closest = Math.min(...arrived);
+ const scriptedClosest = Math.min(...scripted);
+ const took = touches[touches.length - 1] - touches[0];
+ const scriptedTook =
+ script.steps[script.steps.length - 1] - script.steps[0];
+ const named = [
+ took > scriptedTook + 30
+ ? `a stall (the touches took ${ms(took)}, scripted ${ms(scriptedTook)}; the longest gap ${ms(longest)})`
+ : null,
+ closest < scriptedClosest / 4
+ ? `a burst (touch events ${ms(closest)} apart, the script's closest ${ms(scriptedClosest)})`
+ : null,
+ ].filter(Boolean);
+ const span = `${touches.length} touch events over ${ms(took)}, scripted ${script.steps.length} over ${ms(scriptedTook)}, ${ms(closest)}–${ms(longest)} apart`;
+ causes.push(
+ named.length > 0
+ ? `real-time touch input with ${named.join(' and ')}: ${span}`
+ : `touch input on its script: ${span}`,
+ );
+ }
+ if (drawn.frames.length > 1) {
+ const longest = Math.max(
+ ...drawn.frames.slice(1).map((time, i) => time - drawn.frames[i]),
+ );
+ causes.push(
+ longest > 50
+ ? `frame starvation: no frame drawn for ${ms(longest)}`
+ : `frames on pace (longest gap ${ms(longest)})`,
+ );
+ }
+ return `Load report: ${causes.length > 0 ? causes.join('; ') : 'no input or frames recorded'}.`;
+}
+
+// A failed case's messages begin with its load report.
+test.afterEach(async ({page}, testInfo) => {
+ if (testInfo.status === testInfo.expectedStatus || page.isClosed()) {
+ return;
+ }
+ const report = await loadReport(page).catch(
+ (error: unknown) => `Load report unavailable: ${String(error)}.`,
+ );
+ for (const error of testInfo.errors) {
+ error.message = `${report}\n${error.message ?? ''}`;
+ if (error.stack != null) {
+ error.stack = `${report}\n${error.stack}`;
+ }
+ }
+});
+
+interface OpenCarousel {
+ driver: TouchDriver;
+ scroller: Locator;
+ track: Locator;
+ /** One page plus the gap, px. */
+ stride: number;
+ /** A point on the pages: their vertical middle, near the trailing edge. */
+ grab: {x: number; y: number};
+ width: number;
+}
+
+async function openPaging(
+ page: Page,
+ story = 'core-carousel--paging',
+): Promise {
+ // The phone projects have a coarse primary pointer: the touch presentation
+ // on any engine (one touch model; nothing asks for iOS).
+ await openStory(page, storybook(), story);
+ const driver = await recordingScripts(page);
+ const region = page.getByRole('region').first();
+ const scroller = region.locator('> div').first();
+ const track = scroller.locator('> div').first();
+ const geometry = await scroller.evaluate(el => {
+ const box = el.getBoundingClientRect();
+ const trackEl = el.firstElementChild as HTMLElement;
+ const gap = parseFloat(getComputedStyle(trackEl).columnGap) || 0;
+ return {
+ x: box.x,
+ y: box.y,
+ width: el.clientWidth,
+ height: box.height,
+ gap,
+ };
+ });
+ return {
+ driver,
+ scroller,
+ track,
+ stride: geometry.width + geometry.gap,
+ width: geometry.width,
+ grab: {
+ x: geometry.x + geometry.width * 0.8,
+ y: geometry.y + geometry.height / 2,
+ },
+ };
+}
+
+/**
+ * How far a settle's frames are from UIKit's paging settle (`solvePaging`),
+ * in px, for the release velocity that fits them best. The settle starts from
+ * the velocity the pan read at the lift. The stories do not expose it, and
+ * bunched input on a loaded runner makes it faster than the script's. So the
+ * curve is judged for its own release velocity: its shape, start and end are
+ * UIKit's, whatever the lift.
+ */
+function pagingFit(frames: ReadonlyArray<{t: number; x: number}>): {
+ velocity: number;
+ maxError: number;
+} {
+ const from = frames[0].x;
+ const to = frames[frames.length - 1].x;
+ const errorAt = (velocity: number) => {
+ const curve = solvePaging({from, velocity, to});
+ return Math.max(...frames.map(f => Math.abs(curve.at(f.t / 1000) - f.x)));
+ };
+ let velocity = 0;
+ let maxError = errorAt(0);
+ for (let v = -40_000; v <= 40_000; v += 250) {
+ const error = errorAt(v);
+ if (error < maxError) {
+ velocity = v;
+ maxError = error;
+ }
+ }
+ for (let step = 125; step >= 1; step /= 2) {
+ for (const v of [velocity - step, velocity + step]) {
+ const error = errorAt(v);
+ if (error < maxError) {
+ velocity = v;
+ maxError = error;
+ }
+ }
+ }
+ return {velocity, maxError};
+}
+
+const trackX = async (track: Locator) =>
+ track.evaluate(el => {
+ const value = getComputedStyle(el).translate;
+ return value === 'none' ? 0 : parseFloat(value);
+ });
+
+/** The title of the page under the viewport's centre. */
+const pageOnScreen = async (scroller: Locator) =>
+ scroller.evaluate(el => {
+ const box = el.getBoundingClientRect();
+ const hit = document.elementFromPoint(
+ box.x + box.width / 2,
+ box.y + box.height / 2,
+ );
+ return hit?.closest('[aria-roledescription="slide"]')?.querySelector('h3')
+ ?.textContent;
+ });
+
+test('the pager owns horizontal pans; nothing scrolls natively', async ({
+ page,
+}) => {
+ const {scroller} = await openPaging(page);
+ const style = await scroller.evaluate(el => ({
+ touchAction: getComputedStyle(el).touchAction,
+ overflowX: getComputedStyle(el).overflowX,
+ }));
+ expect(style.touchAction).toBe('pan-y pinch-zoom');
+ expect(style.overflowX).toBe('clip');
+});
+
+test('a slow drag past half a page and a fast flick each move exactly one page', async ({
+ page,
+}) => {
+ const {driver, scroller, track, stride, width, grab} = await openPaging(page);
+ expect(await pageOnScreen(scroller)).toBe('Plan');
+ // 60 % of a page at under 300 px/s, then still: the nearest page.
+ await driver.play(
+ drag(
+ grab,
+ {x: grab.x - 0.6 * width, y: grab.y},
+ {durationMs: 1000, restMs: 120},
+ ),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-stride, 0);
+ expect(await pageOnScreen(scroller)).toBe('Pack');
+ // A hard flick from rest: still one page.
+ await driver.play(
+ flick(grab, {velocity: {x: -3000, y: 0}, durationMs: 80, hz: 120}),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-2 * stride, 0);
+ expect(await pageOnScreen(scroller)).toBe('Go');
+});
+
+test('a vertical drag leaves the pages alone', async ({page}) => {
+ const {driver, track, grab} = await openPaging(page);
+ await driver.play(
+ drag(grab, {x: grab.x - 20, y: grab.y - 120}, {durationMs: 300}),
+ );
+ await page.waitForTimeout(100);
+ expect(await trackX(track)).toBeCloseTo(0, 0);
+});
+
+test("the Next button plays setContentOffset(animated:)'s curve", async ({
+ page,
+}) => {
+ const {driver, track, stride} = await openPaging(page);
+ const next = page.getByRole('button', {name: 'Scroll right'});
+ const box = await next.boundingBox();
+ if (box == null) {
+ throw new Error('no Next button');
+ }
+ // The page holds the curve at its first frame itself (armHold). A hold sent
+ // once the curve runs reaches a loaded page late, after a short curve can
+ // have ended.
+ const hold = await armHold(page, track);
+ await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2});
+ const held = await hold.held();
+ const end = Math.max(...held.map(a => a.curveEndMs));
+ expect(end).toBeGreaterThan(290);
+ expect(end).toBeLessThan(310);
+ const samples = curveOf(
+ await sampleCurve(track, timeline(end)),
+ 'translateX',
+ );
+ const result = compareCurve(
+ samples,
+ t => (-stride * (1 - Math.cos(Math.PI * Math.min(1, t / 300)))) / 2,
+ 0.5,
+ );
+ expect(result.maxError).toBeLessThan(0.5);
+ await releaseAnimations(page);
+});
+
+test('with hasLoop, a swipe past the last page rubber-bands back, and Next there returns to the first', async ({
+ page,
+}) => {
+ const {driver, scroller, track, grab} = await openPaging(
+ page,
+ 'core-carousel--paging-loop',
+ );
+ const titles: (string | null | undefined)[] = [];
+ for (let i = 0; i < 5; i++) {
+ await driver.play(
+ flick(grab, {velocity: {x: -1500, y: 0}, durationMs: 100, hz: 120}),
+ );
+ await waitForRest(track);
+ titles.push(await pageOnScreen(scroller));
+ }
+ // A paging scroll view does not loop: the fifth flick returns to the last.
+ expect(titles).toEqual(['Pack', 'Go', 'Share', 'Remember', 'Remember']);
+ const next = page.getByRole('button', {name: 'Scroll right'});
+ const box = await next.boundingBox();
+ if (box == null) {
+ throw new Error('no Next button');
+ }
+ await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2});
+ await waitForRest(track);
+ expect(await pageOnScreen(scroller)).toBe('Plan');
+});
+
+/** Each slide's start edge from the track's, and where the track can go. */
+const slideGeometry = async (scroller: Locator) =>
+ scroller.evaluate(el => {
+ const trackEl = el.firstElementChild as HTMLElement;
+ const box = trackEl.getBoundingClientRect();
+ const style = getComputedStyle(el);
+ const inner =
+ el.clientWidth -
+ parseFloat(style.paddingLeft) -
+ parseFloat(style.paddingRight);
+ const slides = Array.from(trackEl.children).map(child => {
+ const rect = child.getBoundingClientRect();
+ return {start: rect.left - box.left, end: rect.right - box.left};
+ });
+ const extent = Math.max(...slides.map(slide => slide.end));
+ return {
+ starts: slides.map(slide => slide.start),
+ end: extent - inner,
+ viewLeft: el.getBoundingClientRect().left,
+ padding: parseFloat(style.paddingLeft),
+ };
+ });
+
+test('cards: a flick moves one card, and the last cards rest where the row ends', async ({
+ page,
+}) => {
+ const {driver, scroller, track, grab} = await openPaging(
+ page,
+ 'core-carousel--paging-cards',
+ );
+ const {starts, end} = await slideGeometry(scroller);
+ expect(end).toBeGreaterThan(starts[1]);
+ await driver.play(
+ flick(grab, {velocity: {x: -1500, y: 0}, durationMs: 100, hz: 120}),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-starts[1], 0);
+ for (let i = 0; i < starts.length; i++) {
+ await driver.play(
+ flick(grab, {velocity: {x: -1500, y: 0}, durationMs: 100, hz: 120}),
+ );
+ await waitForRest(track);
+ }
+ expect(await trackX(track)).toBeCloseTo(-end, 0);
+ await expect(page.getByRole('button', {name: 'Scroll right'})).toBeDisabled();
+});
+
+test('padding keeps its inset: a flick lands the next child at the padded start edge', async ({
+ page,
+}) => {
+ await openStory(page, storybook(), 'core-carousel--with-padding');
+ const driver = await recordingScripts(page);
+ const scroller = page.getByRole('region').first().locator('> div').first();
+ const track = scroller.locator('> div').first();
+ const {starts, viewLeft, padding} = await slideGeometry(scroller);
+ expect(padding).toBeGreaterThan(0);
+ const box = await scroller.boundingBox();
+ if (box == null) {
+ throw new Error('no scroller');
+ }
+ await driver.play(
+ flick(
+ {x: box.x + box.width * 0.8, y: box.y + box.height / 2},
+ {velocity: {x: -1500, y: 0}, durationMs: 100, hz: 120},
+ ),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-starts[1], 0);
+ const second = await page
+ .locator('[aria-roledescription="slide"]')
+ .nth(1)
+ .boundingBox();
+ expect(second?.x).toBeCloseTo(viewLeft + padding, 0);
+});
+
+test('the edge fade follows the page: toward the end on the first page, toward the start on the last', async ({
+ page,
+}) => {
+ const {driver, scroller, track, grab} = await openPaging(page);
+ const mask = async () =>
+ scroller.evaluate(el => {
+ const style = getComputedStyle(el) as CSSStyleDeclaration & {
+ webkitMaskImage?: string;
+ };
+ return style.maskImage !== '' && style.maskImage !== 'none'
+ ? style.maskImage
+ : (style.webkitMaskImage ?? '');
+ });
+ expect(await mask()).toContain('to left');
+ for (let i = 0; i < 4; i++) {
+ await driver.play(
+ flick(grab, {velocity: {x: -1500, y: 0}, durationMs: 100, hz: 120}),
+ );
+ await waitForRest(track);
+ }
+ expect(await mask()).toContain('to right');
+ expect(await mask()).not.toContain('calc(100%');
+});
+
+test('in a scrolling page: a vertical drag scrolls the page, a horizontal one pages, and a diagonal one goes to its dominant axis (35° pages, 50° scrolls)', async ({
+ page,
+ browserName,
+}) => {
+ test.skip(
+ browserName === 'webkit',
+ 'injected touches do not scroll natively in Linux WebKit (harness README)',
+ );
+ const {driver, scroller, track, stride, width, grab} = await openPaging(
+ page,
+ 'core-carousel--paging-in-long-page',
+ );
+ const scrollY = async () => page.evaluate(() => window.scrollY);
+ // Steep (vertical): the page scrolls, the pages stay.
+ await driver.play(
+ drag(
+ grab,
+ {x: grab.x - 10, y: grab.y - 150},
+ {durationMs: 400, restMs: 120},
+ ),
+ );
+ await page.waitForTimeout(300);
+ const scrolled = await scrollY();
+ expect(scrolled).toBeGreaterThan(80);
+ expect(await trackX(track)).toBeCloseTo(0, 0);
+ // Horizontal: one page, the page holds still.
+ const box = await scroller.boundingBox();
+ if (box == null) {
+ throw new Error('no scroller');
+ }
+ const mid = {x: box.x + box.width * 0.8, y: box.y + box.height / 2};
+ await driver.play(
+ drag(
+ mid,
+ {x: mid.x - 0.6 * width, y: mid.y},
+ {durationMs: 600, restMs: 120},
+ ),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-stride, 0);
+ expect(await scrollY()).toBeCloseTo(scrolled, 0);
+ // UIKit (a pager inside a vertical scroll view): the drag's dominant axis
+ // decides, once. A drag 35° from horizontal is the pager's: past half a
+ // page it pages, and the page holds still. That holds here because Chromium
+ // holds back moves inside its touch slop, so the pan begins on its first
+ // move.
+ // Mobile Safari claims a slow drag for the page at about 10–11 px, before
+ // the pan's 10 pt start. So on the iOS 26.2 simulator, a drag steeper than
+ // about 27° scrolls the page: 25° pages, while 30° and 35° scroll.
+ const diagonal = (degrees: number, length: number) => {
+ const angle = (degrees * Math.PI) / 180;
+ return drag(
+ mid,
+ {
+ x: mid.x - length * Math.cos(angle),
+ y: mid.y - length * Math.sin(angle),
+ },
+ {durationMs: 700, restMs: 120},
+ );
+ };
+ await driver.play(
+ diagonal(35, (0.6 * width) / Math.cos((35 * Math.PI) / 180)),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-2 * stride, 0);
+ expect(await scrollY()).toBeCloseTo(scrolled, 0);
+ // 50° is the page's: it scrolls, and the pages stay.
+ await driver.play(diagonal(50, 200));
+ await page.waitForTimeout(300);
+ expect(await trackX(track)).toBeCloseTo(-2 * stride, 0);
+ expect(await scrollY()).toBeGreaterThan(scrolled + 80);
+});
+
+test('on an Android phone (no -webkit-touch-callout) the pager pages too: one touch model, no platform idiom', async ({
+ page,
+}) => {
+ const {driver, scroller, track, stride, width, grab} = await openPaging(page);
+ expect(
+ await scroller.evaluate(el => ({
+ overflowX: getComputedStyle(el).overflowX,
+ coarse: matchMedia('(pointer: coarse)').matches,
+ ios: CSS.supports('(-webkit-touch-callout: none)'),
+ })),
+ ).toEqual({overflowX: 'clip', coarse: true, ios: false});
+ // The pager follows the finger past half a page: the next page, whatever
+ // the release speed.
+ await driver.play(
+ drag(
+ grab,
+ {x: grab.x - 0.6 * width, y: grab.y},
+ {durationMs: 1000, restMs: 0},
+ ),
+ );
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-stride, 0);
+ // The scroller never scrolled natively.
+ expect(await scroller.evaluate(el => el.scrollLeft)).toBe(0);
+});
+
+test("under Reduce Motion a scrolling carousel's Next cross-fades and jumps while transparent (design:reduced-motion DR2, a coarse pointer)", async ({
+ page,
+}) => {
+ await page.emulateMedia({reducedMotion: 'reduce'});
+ await openStory(page, storybook(), 'core-carousel--default');
+ const scroller = page.getByRole('region').first().locator('> div').first();
+ const hold = await armHold(page, scroller);
+ await page.getByRole('button', {name: 'Scroll right'}).click();
+ const held = await hold.held();
+ expect(held.length).toBeGreaterThan(0);
+ for (const a of held) {
+ // Opacity only (DR1), each half of the 200 ms fade.
+ expect(
+ a.keyframes.every(k =>
+ Object.keys(k).every(key =>
+ [
+ 'opacity',
+ 'offset',
+ 'easing',
+ 'composite',
+ 'computedOffset',
+ ].includes(key),
+ ),
+ ),
+ ).toBe(true);
+ expect(a.curveEndMs).toBeLessThanOrEqual(100);
+ }
+ // Still in place while the fade-out holds: it jumps once transparent.
+ expect(await scroller.evaluate(el => el.scrollLeft)).toBe(0);
+ await releaseAnimations(page);
+ await expect
+ .poll(async () => scroller.evaluate(el => el.scrollLeft))
+ .toBeGreaterThan(0);
+});
+
+/**
+ * At most a jump: `property` holds its start value, then its end value, and
+ * never one in between. A cross-fade moves the track while it is transparent
+ * (design:reduced-motion DR1).
+ */
+const holdsOrJumps = (
+ keyframes: ReadonlyArray>,
+ property: string,
+): boolean => {
+ const values = new Set();
+ for (const k of keyframes) {
+ const v = k[property];
+ if (typeof v === 'string' || typeof v === 'number') {
+ values.add(String(v));
+ }
+ }
+ return values.size <= 2;
+};
+
+test('under Reduce Motion the settle and Next do not travel: an opacity cross-fade of at most 200 ms on a coarse pointer (design:reduced-motion DR2)', async ({
+ page,
+}) => {
+ await page.emulateMedia({reducedMotion: 'reduce'});
+ const {driver, track, grab, stride} = await openPaging(page);
+ // The phone projects have a coarse pointer, the case DR2 covers.
+ expect(
+ await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
+ ).toBe(true);
+ const crossFadesOnly = (animations: ReadonlyArray) => {
+ expect(animations.length).toBeGreaterThan(0);
+ for (const a of animations) {
+ // Nothing travels: the track holds, then jumps under the fade (DR1).
+ expect(holdsOrJumps(a.keyframes, 'translate')).toBe(true);
+ expect(holdsOrJumps(a.keyframes, 'transform')).toBe(true);
+ expect(a.curveEndMs).toBeLessThanOrEqual(200);
+ }
+ };
+ const settle = await armHold(page, track);
+ await driver.play(flick(grab, {velocity: {x: -1500, y: 0}, durationMs: 100}));
+ crossFadesOnly(await settle.held());
+ await releaseAnimations(page);
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-stride, 0);
+ const next = await armHold(page, track);
+ await page.getByRole('button', {name: 'Scroll right'}).click();
+ crossFadesOnly(await next.held());
+ await releaseAnimations(page);
+ await waitForRest(track);
+ expect(await trackX(track)).toBeCloseTo(-2 * stride, 0);
+});
+
+test('a drag writes only styles: no render, no layout change in the tree', async ({
+ page,
+}) => {
+ const {driver, scroller, width, grab} = await openPaging(page);
+ await scroller.evaluate(el => {
+ const records: string[] = [];
+ (window as unknown as {mutations: string[]}).mutations = records;
+ new MutationObserver(list => {
+ for (const m of list) {
+ const target = m.target as HTMLElement;
+ const role =
+ target === el.firstElementChild
+ ? 'track'
+ : (target.getAttribute('aria-roledescription') ?? target.tagName);
+ records.push(`${m.type}:${m.attributeName ?? ''}:${role}`);
+ }
+ }).observe(el, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ characterData: true,
+ });
+ });
+ await driver.play(
+ drag(
+ grab,
+ {x: grab.x - 0.3 * width, y: grab.y},
+ {durationMs: 500, restMs: 120},
+ ),
+ );
+ const mutations = await page.evaluate(
+ () => (window as unknown as {mutations: string[]}).mutations,
+ );
+ // The track's translate (per move) and the slides' layers (at the drag's
+ // start and at rest); nothing else.
+ expect(mutations.length).toBeGreaterThan(10);
+ for (const m of mutations) {
+ expect(m).toMatch(/^attributes:style:(track|slide)$/);
+ }
+});
+
+test('a settle keeps advancing every frame through a blocked main thread', async ({
+ page,
+ browserName,
+}) => {
+ test.skip(
+ browserName === 'webkit',
+ "WebKit's screencast clock is not the page's, so a 0.3 s settle cannot be placed inside the block; Mobile Safari on the iOS simulator is the WebKit evidence",
+ );
+ const {driver, scroller, width, grab} = await openPaging(page);
+ const second = page.locator('[aria-roledescription="slide"]').nth(1);
+ await markElement(second, {inset: 8});
+ // 100 ms after the finger lifts, block the main thread for 800 ms.
+ await scroller.evaluate(el => {
+ el.addEventListener(
+ 'touchend',
+ () => {
+ setTimeout(() => {
+ const from = performance.timeOrigin + performance.now();
+ const end = performance.now() + 800;
+ while (performance.now() < end) {
+ // busy
+ }
+ (window as unknown as {busy: unknown}).busy = {
+ from,
+ to: performance.timeOrigin + performance.now(),
+ spins: 0,
+ };
+ }, 100);
+ },
+ {once: true, passive: true},
+ );
+ });
+ const cast = await startScreencast(page);
+ await page.waitForTimeout(300);
+ await driver.play(
+ flick(grab, {velocity: {x: -1200, y: 0}, durationMs: 80, hz: 120}),
+ );
+ await page.waitForTimeout(1300);
+ const busy = await page.evaluate(
+ () =>
+ (window as unknown as {busy: {from: number; to: number; spins: number}})
+ .busy,
+ );
+ const tracked = await trackMarker(page, await cast.stop());
+ // The settle lasts about 0.33 s from the lift: judge the block's first 200 ms.
+ const spanned = {...busy, to: busy.from + 200};
+ const proof = busyWindowProof(tracked, spanned, {
+ axis: 'x',
+ settledWithin: 1,
+ });
+ test.info().annotations.push({
+ type: 'busy window',
+ description: JSON.stringify({...proof, window: undefined}),
+ });
+ expect(proof.frames).toBeGreaterThanOrEqual(proof.expectedFrames - 2);
+ expect(proof.changed).toBeGreaterThanOrEqual(proof.frames - 3);
+ expect(width).toBeGreaterThan(0);
+});
+
+for (const [name, story, velocity] of [
+ ['swipe', 'core-carousel--paging', -1200],
+ ['cards', 'core-carousel--paging-cards', -1200],
+] as const) {
+ test(`storyboard: ${name} @storyboard`, async ({page}) => {
+ const {driver, scroller, track, grab, width} = await openPaging(
+ page,
+ story,
+ );
+ const from = velocity < 0 ? grab : {x: grab.x - width * 0.6, y: grab.y};
+ const hold = await armHold(page, track);
+ await driver.play(
+ flick(from, {velocity: {x: velocity, y: 0}, durationMs: 80, hz: 120}),
+ );
+ const held = await hold.held();
+ const box = await scroller.boundingBox();
+ if (box == null) {
+ throw new Error('no scroller');
+ }
+ const {storyboard} = await animationStoryboard(page, {
+ name: `carousel-paging-${name}`,
+ held,
+ clip: {x: box.x, y: box.y, width: box.width, height: box.height},
+ measure: async () => ({x: await trackX(track)}),
+ });
+ await storyboard.write(test.info());
+ // Every frame, the landing included, is on UIKit's paging settle for the
+ // release velocity the curve starts from, within 1.5 px. The frames are
+ // on the film's timeline, from the earliest held start; the settle is the
+ // track's translate.
+ const origin = Math.min(
+ ...held.filter(a => Number.isFinite(a.curveEndMs)).map(a => a.offsetMs),
+ );
+ const settle = held.find(a =>
+ a.keyframes.some(k => k.translate != null && k.translate !== 'none'),
+ );
+ expect(settle).toBeDefined();
+ const frames = storyboard.frames.map(f => ({
+ t: f.t + origin - (settle?.offsetMs ?? 0),
+ x: (f.data as {x: number}).x,
+ }));
+ const fit = pagingFit(frames.filter(f => f.t >= 0));
+ test.info().annotations.push({
+ type: 'paging fit',
+ description: JSON.stringify(fit),
+ });
+ expect(fit.maxError).toBeLessThan(1.5);
+ await releaseAnimations(page);
+ });
+}
diff --git a/packages/core/src/Carousel/useCarouselPaging.ts b/packages/core/src/Carousel/useCarouselPaging.ts
new file mode 100644
index 0000000000000..06c307087ab8f
--- /dev/null
+++ b/packages/core/src/Carousel/useCarouselPaging.ts
@@ -0,0 +1,289 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useCarouselPaging.ts
+ * @input The carousel's scroller and track, whether the touch presentation
+ * is active, the slide count and the loop flag; the shared pager
+ * @output Exports `useCarouselPaging`: a snapping Carousel's touch
+ * presentation (the page, the buttons, the keyboard and focus)
+ * @position Internal to Carousel (`hasSnap`, under a finger, with buttons)
+ *
+ * SYNC: When modified, update:
+ * - /packages/core/src/Carousel/Carousel.tsx
+ * - /packages/core/src/Carousel/CarouselPaging.test.tsx
+ */
+
+import {useState, useSyncExternalStore, type RefObject} from 'react';
+import {createPager, type Pager} from '../hooks/usePager';
+import {isRtlElement} from '../hooks/isRtlElement';
+import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
+
+export interface CarouselPagingOptions {
+ /** The pager runs (a snapping carousel with its buttons, under a finger). */
+ isEnabled: boolean;
+ scrollElRef: RefObject;
+ trackRef: RefObject;
+ /** How many slides render. */
+ count: number;
+ /** `hasLoop`: a step past either end goes to the other one. */
+ loop: boolean;
+ /** The layout props the pages depend on (`gap`, `padding`): a change re-measures them. */
+ layout?: unknown;
+}
+
+export interface CarouselPaging {
+ /** The page on screen or heading there, in [0, pageCount). */
+ page: number;
+ /** How many pages the children make (the last ones can share one). */
+ pageCount: number;
+ /** The buttons' and the handle's step toward the end (+1) or the start (−1). */
+ goBy(direction: -1 | 1): void;
+ /** Bring slide `index` to the start edge, as far as the content allows. */
+ goTo(index: number): void;
+ canGoBack: boolean;
+ canGoForward: boolean;
+ /** Arrow keys, Home and End on the scroller. */
+ onKeyDown(event: React.KeyboardEvent): void;
+ /** Focus entering a slide that is not wholly on screen brings it there. */
+ onFocus(event: React.FocusEvent): void;
+}
+
+/** The slide element of `track` that contains `node`. */
+function slideIndexOf(track: HTMLElement, node: Node | null): number {
+ let child: Node | null = node;
+ while (child != null && child.parentNode !== track) {
+ child = child.parentNode;
+ }
+ return child == null
+ ? -1
+ : Array.prototype.indexOf.call(track.children, child);
+}
+
+/**
+ * Under a finger, a snapping carousel is a UIKit paging scroll view whose pages
+ * are its children. The shared pager (`pages: 'children'`) moves the track one
+ * child per swipe. Each child rests at its start edge, where the native snap
+ * places it, and the last ones rest where the content ends. The pager attaches
+ * only while the touch presentation is active. Otherwise the carousel scrolls
+ * natively and snaps.
+ *
+ * The buttons and the handle keep their native step of about a viewport, and
+ * land on a child. With `hasLoop`, a step past either end goes to the other
+ * end, as it does natively. An arrow key moves one child. The children within
+ * a viewport of the visible content get their own layers, so they are painted
+ * before they move in. In Mobile Safari, a busy main thread cannot paint them
+ * while the track moves.
+ */
+export function useCarouselPaging({
+ isEnabled,
+ scrollElRef,
+ trackRef,
+ count,
+ loop,
+ layout,
+}: CarouselPagingOptions): CarouselPaging {
+ // The page and the page count are the pager's: rendered through an
+ // external store that its reports (and the take-over below) notify.
+ const [pageStore] = useState(() => {
+ const listeners = new Set<() => void>();
+ return {
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ notify: () => {
+ for (const listener of listeners) {
+ listener();
+ }
+ },
+ };
+ });
+
+ const [pager] = useState(() =>
+ createPager({
+ count: Math.max(1, count),
+ pages: 'children',
+ onIndexChange: () => pageStore.notify(),
+ onPagesChange: () => pageStore.notify(),
+ onSettle: () => promote(),
+ shouldBeginPan: () => {
+ promote();
+ return true;
+ },
+ }),
+ );
+
+ /**
+ * Gives the children within a viewport of the content on screen their own
+ * layers, and takes them from the rest. Rects are read in the track's own
+ * CSS pixels, as the pager reads them (an ancestor's `zoom` divided out).
+ */
+ function promote(): void {
+ const viewport = scrollElRef.current;
+ const track = trackRef.current;
+ if (viewport == null || track == null) {
+ return;
+ }
+ const box = track.getBoundingClientRect();
+ const used = Number.parseFloat(getComputedStyle(track).width);
+ const scale = used > 0 && box.width > 0 ? box.width / used : 1;
+ const rtl = isRtlElement(viewport);
+ const width = viewport.clientWidth;
+ const at = pager.presentation();
+ for (const child of Array.from(track.children)) {
+ const rect = child.getBoundingClientRect();
+ const start =
+ (rtl ? box.right - rect.right : rect.left - box.left) / scale;
+ const end = start + rect.width / scale;
+ const isNear = end > at - width && start < at + 2 * width;
+ (child as HTMLElement).style.setProperty(
+ 'will-change',
+ isNear ? 'transform' : '',
+ );
+ }
+ }
+
+ // Synchronizes the pager with the slides and the layout they sit in: it
+ // measures the children again. A clamp it makes is not reported; the page
+ // shown is derived from the pager during render (`page` below).
+ useIsomorphicLayoutEffect(() => {
+ pager.setOptions({count: Math.max(1, count)});
+ if (isEnabled) {
+ promote();
+ }
+ }, [pager, count, isEnabled, layout]);
+
+ useIsomorphicLayoutEffect(() => {
+ const viewport = scrollElRef.current;
+ const track = trackRef.current;
+ if (!isEnabled || viewport == null || track == null) {
+ return undefined;
+ }
+ // Take over from native scrolling on the child it showed.
+ const nativeOffset = Math.abs(viewport.scrollLeft);
+ viewport.scrollTo({left: 0, behavior: 'instant'});
+ const detach = pager.attach(viewport, track);
+ pager.goTo(pager.nearestPage(nativeOffset), {
+ animated: false,
+ reason: 'controlled',
+ });
+ promote();
+ // The page shown is the pager's: render it.
+ pageStore.notify();
+ return () => {
+ const offset = pager.pageOffset(pager.index);
+ detach();
+ track.style.removeProperty('translate');
+ for (const child of Array.from(track.children)) {
+ (child as HTMLElement).style.removeProperty('will-change');
+ }
+ // Hand the child back to native scrolling.
+ viewport.scrollTo({
+ left: (isRtlElement(viewport) ? -1 : 1) * offset,
+ behavior: 'instant',
+ });
+ };
+ }, [pager, isEnabled, scrollElRef, trackRef]);
+
+ function goTo(index: number): void {
+ pager.goTo(index);
+ }
+
+ function goBy(direction: -1 | 1): void {
+ const last = pager.pageCount - 1;
+ const from = pager.index;
+ if (loop && last > 0) {
+ if (direction === 1 && from >= last) {
+ pager.goTo(0);
+ return;
+ }
+ if (direction === -1 && from <= 0) {
+ pager.goTo(last);
+ return;
+ }
+ }
+ // The native step: a viewport less half a child, at least a child.
+ const viewport = scrollElRef.current;
+ const first = trackRef.current?.firstElementChild as HTMLElement | null;
+ const itemWidth = first?.offsetWidth ?? 0;
+ const step = Math.max(
+ (viewport?.clientWidth ?? 0) - itemWidth * 0.5,
+ itemWidth,
+ );
+ const near = pager.nearestPage(pager.pageOffset(from) + direction * step);
+ pager.goTo(
+ direction === 1 ? Math.max(near, from + 1) : Math.min(near, from - 1),
+ );
+ }
+
+ const page = useSyncExternalStore(
+ pageStore.subscribe,
+ () => pager.index,
+ () => pager.index,
+ );
+ const pageCount = useSyncExternalStore(
+ pageStore.subscribe,
+ () => pager.pageCount,
+ () => pager.pageCount,
+ );
+ const canLoop = loop && pageCount > 1;
+ const canGoBack = canLoop || page > 0;
+ const canGoForward = canLoop || page < pageCount - 1;
+
+ function onKeyDown(event: React.KeyboardEvent): void {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ const rtl = isRtlElement(event.currentTarget);
+ switch (event.key) {
+ case 'ArrowRight':
+ pager.goTo(pager.index + (rtl ? -1 : 1));
+ break;
+ case 'ArrowLeft':
+ pager.goTo(pager.index + (rtl ? 1 : -1));
+ break;
+ case 'Home':
+ pager.goTo(0);
+ break;
+ case 'End':
+ pager.goTo(pager.pageCount - 1);
+ break;
+ default:
+ return;
+ }
+ event.preventDefault();
+ }
+
+ function onFocus(event: React.FocusEvent): void {
+ const viewport = scrollElRef.current;
+ const track = trackRef.current;
+ if (viewport == null || track == null || !track.contains(event.target)) {
+ return;
+ }
+ const index = slideIndexOf(track, event.target);
+ if (index < 0) {
+ return;
+ }
+ const slide = track.children[index].getBoundingClientRect();
+ const view = viewport.getBoundingClientRect();
+ if (slide.left >= view.left - 0.5 && slide.right <= view.right + 0.5) {
+ return;
+ }
+ pager.goTo(index);
+ }
+
+ return {
+ page,
+ pageCount,
+ goBy,
+ goTo,
+ canGoBack,
+ canGoForward,
+ onKeyDown,
+ onFocus,
+ };
+}
diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts
index 1d11b2df6a563..a3731a5d1b149 100644
--- a/packages/core/src/gestures/GestureArena.ts
+++ b/packages/core/src/gestures/GestureArena.ts
@@ -228,6 +228,7 @@ export class GestureArena {
target: input.target,
scroll: input.scroll ?? null,
takenByBrowser: false,
+ ...(input.scrollType == null ? {} : {scrollType: input.scrollType}),
};
const participants: GestureRecognizer[] = [];
for (const [depth, recognizer] of input.recognizers.entries()) {
diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts
index b90fd81a8c473..2c40c6e0a8fa7 100644
--- a/packages/core/src/gestures/GestureHost.ts
+++ b/packages/core/src/gestures/GestureHost.ts
@@ -140,9 +140,14 @@ import {
import type {GestureRecognizer, SelectionLock} from './GestureRecognizer';
import type {PressBounds} from './PressTracker';
import type {PanGestureRecognizer} from './PanGestureRecognizer';
-import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll';
+import {
+ canElementScroll,
+ createNativeScrollProbe,
+ parentAcrossShadow,
+} from './nativeScroll';
import {containsAlongFlatTree} from '../utils/flatTree';
import {isIosWebKit} from '../utils/touchQueries';
+import {type WheelSegment, WheelSegmenter} from './wheelSegmenter';
import {
gestureHostStyle,
type GestureHostStyle,
@@ -158,6 +163,7 @@ import type {
GestureEnvironment,
GestureTouch,
PointerKind,
+ ScrollType,
TouchBeganInput,
TouchConsumer,
TouchInput,
@@ -186,6 +192,8 @@ interface Registration {
hasTouchEndListener: boolean;
/** Whether the element carries the host's shared blocking touchmove handler. */
hasTouchMoveListener: boolean;
+ /** Whether the element carries the host's wheel handler. */
+ hasWheelListener: boolean;
/** Inline values the host replaced, to restore on detach. */
readonly savedStyle: Map;
/**
@@ -305,6 +313,16 @@ function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
);
}
+/**
+ * Whether a recognizer takes a contact that comes with Touch Events and a
+ * long press's contextmenu: a finger's or a pen's. A scroll-only recognizer
+ * (`allowedPointerTypes: []`) and a mouse-only one take neither.
+ */
+function takesTouchContacts(recognizer: GestureRecognizer): boolean {
+ const types = recognizer.allowedPointerTypes;
+ return types == null || types.has('touch') || types.has('pen');
+}
+
/**
* A pan (or a pan subclass), told apart by its family rather than its class,
* so a page that constructs no pan never ships PanGestureRecognizer.
@@ -381,6 +399,207 @@ function landedOn(
return event.composedPath()[0] ?? retargeted;
}
+/**
+ * How long after its last event a pinch or a mouse wheel's notches end, ms.
+ * The web reports no wheel phases, so a quiet wheel is a lifted one.
+ */
+const WHEEL_END_MS = 100;
+/**
+ * How long after its last event a vertical trackpad scroll ends, ms (a
+ * sheet's pull), whoever keeps it. A slow drag's stream pauses longer than
+ * WHEEL_END_MS between events.
+ */
+const WHEEL_FOLLOW_END_V_MS = 180;
+/**
+ * The same horizontally, ms (a pager's or a row's swipe), whose slow
+ * swipes pause longer still.
+ */
+const WHEEL_FOLLOW_END_MS = 220;
+/** A line-mode wheel's line, px, as the platforms scroll it. */
+const WHEEL_LINE_PX = 40;
+/** The span a ctrl-wheel pinch's two contacts start at, px. */
+const WHEEL_PINCH_SPAN = 100;
+
+/** A wheel gesture: the contacts it drives in the arena, or none where the page keeps it. */
+interface WheelGesture {
+ readonly kind: 'pan' | 'pinch';
+ /** A mouse wheel's notches, or a trackpad's continuous scroll. */
+ readonly scrollType: ScrollType;
+ readonly ids: ReadonlyArray;
+ /** A pan's contact, or a pinch's center. */
+ x: number;
+ y: number;
+ /** A pinch's span between its two contacts. */
+ span: number;
+ /** The latest wheel's time. */
+ last: number;
+ /**
+ * How long after its last wheel it ends, ms. A trackpad pan stream waits
+ * out its axis's pause bound, whoever keeps it. A pinch or a mouse wheel's
+ * notches wait 100 ms.
+ */
+ readonly endMs: number;
+ timer: unknown;
+ /** The recognizers that took it; none where the page keeps it. */
+ readonly takers: ReadonlyArray;
+ /** A trackpad scroll a pan follows, until its fingers lift. */
+ follow?: WheelFollow;
+}
+
+/** A trackpad scroll a pan follows: where its fingers lift, and what waits until then. */
+interface WheelFollow {
+ /** The fingers lifted: the momentum after them never moves the pan. */
+ lifted: boolean;
+ /**
+ * Shrinking deltas held until the splitter decides. They are the fingers'
+ * when larger ones follow, and the start of momentum when it reads the
+ * lift.
+ */
+ heldX: number;
+ heldY: number;
+ /** The time of the fingers' last move the pan took. */
+ lastMove: number;
+ /** The fingers' travel as of that move: the contact without any held delta. */
+ trueX: number;
+ trueY: number;
+ /** Where the pan was shown at that move, which the undecided stretch moves on from. */
+ baseX: number;
+ baseY: number;
+ /** The fingers' speed at that move, content px per ms. */
+ speedX: number;
+ speedY: number;
+}
+
+/**
+ * How long after its last wheel a wheel gesture ends, ms. A trackpad's
+ * two-finger scroll waits out a slow drag's pauses for its axis, whoever
+ * keeps the stream. A pinch and a mouse wheel's notches end 100 ms after
+ * their last wheel.
+ */
+function wheelEndMs(
+ kind: 'pan' | 'pinch',
+ scrollType: ScrollType,
+ isHorizontal: boolean,
+): number {
+ if (kind === 'pinch' || scrollType !== 'continuous') {
+ return WHEEL_END_MS;
+ }
+ return isHorizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS;
+}
+
+/**
+ * A provisional lead `debt` after a move of `moved`. The debt is what a
+ * followed pan shows past the fingers' travel, or short of it. Half the move
+ * is absorbed, so the pan keeps moving the way the fingers do.
+ */
+function absorbedDebt(debt: number, moved: number): number {
+ return Math.sign(debt) * Math.max(0, Math.abs(debt) - Math.abs(moved) / 2);
+}
+
+/**
+ * `target` where it is ahead of `shown` in the direction of `speed`, else
+ * `shown`: a reconcile that never steps back.
+ */
+function ahead(shown: number, target: number, speed: number): number {
+ return speed === 0 || (target - shown) * Math.sign(speed) > 0
+ ? target
+ : shown;
+}
+
+/**
+ * A wheel's deltas in CSS px: a line is `WHEEL_LINE_PX`, a page the
+ * viewport.
+ */
+function wheelPixels(
+ wheel: WheelEvent,
+ view: Window | null,
+): {dx: number; dy: number} {
+ if (wheel.deltaMode === 1) {
+ return {dx: wheel.deltaX * WHEEL_LINE_PX, dy: wheel.deltaY * WHEEL_LINE_PX};
+ }
+ if (wheel.deltaMode === 2) {
+ return {
+ dx: wheel.deltaX * (view?.innerWidth ?? 0),
+ dy: wheel.deltaY * (view?.innerHeight ?? 0),
+ };
+ }
+ return {dx: wheel.deltaX, dy: wheel.deltaY};
+}
+
+/**
+ * The web's best reading of what scrolled: a mouse wheel's notches
+ * (`discrete`) or a trackpad's scroll (`continuous`). Input it cannot tell
+ * apart counts as a mouse wheel's, which a pan that takes only a trackpad's
+ * scroll leaves to the page. These read as a mouse wheel's:
+ * - a line- or page-mode delta;
+ * - a scroll with Shift held (Shift+wheel);
+ * - a pixel delta a hair off a whole pixel (a mouse wheel on Windows);
+ * - a scroll with no legacy `wheelDelta` (an engine that sends none);
+ * - a delta that is not a third of its legacy `wheelDelta`;
+ * - a 120 tick over a 40 px delta.
+ * A trackpad's precise deltas, a third of their legacy `wheelDelta` in
+ * Chromium and WebKit, read as a trackpad's.
+ */
+function scrollTypeOf(wheel: WheelEvent): ScrollType {
+ if (wheel.deltaMode !== 0 || wheel.shiftKey) {
+ return 'discrete';
+ }
+ const remainder = (value: number) => {
+ const fraction = Math.abs(value - Math.round(value));
+ return fraction > 0 && fraction < 0.01;
+ };
+ if (remainder(wheel.deltaX) || remainder(wheel.deltaY)) {
+ return 'discrete';
+ }
+ const legacy = wheel as WheelEvent & {
+ wheelDeltaX?: number;
+ wheelDeltaY?: number;
+ };
+ if (
+ typeof legacy.wheelDeltaY !== 'number' ||
+ typeof legacy.wheelDeltaX !== 'number'
+ ) {
+ return 'discrete';
+ }
+ const precise = (delta: number, legacyDelta: number) =>
+ delta === 0
+ ? legacyDelta === 0
+ : Math.abs(Math.abs(legacyDelta) - 3 * Math.abs(delta)) <=
+ Math.max(1.5, 0.05 * Math.abs(delta) * 3);
+ if (
+ !precise(wheel.deltaY, legacy.wheelDeltaY) ||
+ !precise(wheel.deltaX, legacy.wheelDeltaX)
+ ) {
+ return 'discrete';
+ }
+ const tick = (delta: number, legacyDelta: number) =>
+ legacyDelta !== 0 && legacyDelta % 120 === 0 && delta % 40 === 0;
+ return tick(wheel.deltaY, legacy.wheelDeltaY) ||
+ tick(wheel.deltaX, legacy.wheelDeltaX)
+ ? 'discrete'
+ : 'continuous';
+}
+
+/** Read without the pan class, which a page with no pan never ships. */
+function isScrollEdgePan(recognizer: GestureRecognizer): boolean {
+ return (recognizer as {scrollEdge?: boolean}).scrollEdge === true;
+}
+
+/**
+ * Whether a pan takes a contact moving by (cx, cy), by its own direction
+ * rule, `axisRatio` included. A move its rule leaves undecided stays the
+ * page's.
+ */
+function panTakesMove(
+ recognizer: GestureRecognizer,
+ cx: number,
+ cy: number,
+): boolean {
+ return (
+ (internals(recognizer).classify?.({x: cx, y: cy}) ?? 'begin') === 'begin'
+ );
+}
+
/**
* An event's time on the arena's clock (`performance.now()`). Some engines
* and DOM emulators stamp events in epoch milliseconds instead.
@@ -530,6 +749,16 @@ export class GestureHost {
*/
private fingerTargets: ReadonlyArray = [];
private nextId = 1;
+ /** The wheel gesture in progress. */
+ private wheel: WheelGesture | null = null;
+ /** The wheel latch's input-stream subscription, while a gesture is latched. */
+ private stopWheelLatch: (() => void) | null = null;
+ /** Splits the wheel stream into gestures and reads a trackpad's lift. */
+ private readonly segmenter = new WheelSegmenter();
+ /** The latest wheel's segment. */
+ private wheelSegment: WheelSegment | null = null;
+ /** Wheels already taken: each element on the path with a listener hears one. */
+ private readonly wheelsSeen = new WeakSet();
private isInstalled = false;
/**
* Whether the primary pointer is coarse (`spec:AST-094` FR19), read when
@@ -648,6 +877,16 @@ export class GestureHost {
if (recognizer.state !== 'possible' || recognizer.isInvolved()) {
recognizer.cancel();
}
+ const wheel = this.wheel;
+ if (
+ wheel != null &&
+ wheel.takers.includes(recognizer) &&
+ !this.wheelHasTaker(wheel)
+ ) {
+ // At its last wheel: a later stamp would move the arena's clock past
+ // the next input's own time.
+ this.endWheel(wheel.last);
+ }
this.refresh(element, registration);
// Even when the element keeps other attachments: one left disabled
// keeps no listener or observer (FR24).
@@ -1055,6 +1294,7 @@ export class GestureHost {
consumer: null,
hasTouchEndListener: false,
hasTouchMoveListener: false,
+ hasWheelListener: false,
savedStyle: new Map(),
authorTouchAction: null,
manageStyle: true,
@@ -1135,6 +1375,26 @@ export class GestureHost {
element.removeEventListener('touchmove', this.onBlockingTouchMove);
registration.hasTouchMoveListener = false;
}
+ // Wheel and trackpad input, only where an enabled recognizer takes it. A
+ // non-passive wheel listener makes the browser's scrolling over the
+ // element wait for the main thread.
+ const needsWheel = registration.recognizers.some(
+ recognizer =>
+ recognizer.isEnabled &&
+ // A watcher never takes the wheel: the page keeps it.
+ recognizer.claimsTouches &&
+ recognizer.allowedScrollTypes.size > 0 &&
+ // A wheel drives a pan or a pinch only.
+ (internals(recognizer).family === 'pan' ||
+ internals(recognizer).family === 'pinch'),
+ );
+ if (needsWheel && !registration.hasWheelListener) {
+ element.addEventListener('wheel', this.onWheel, {passive: false});
+ registration.hasWheelListener = true;
+ } else if (!needsWheel && registration.hasWheelListener) {
+ element.removeEventListener('wheel', this.onWheel);
+ registration.hasWheelListener = false;
+ }
}
private applyStyle(element: HTMLElement, registration: Registration): void {
@@ -1290,6 +1550,9 @@ export class GestureHost {
if (registration.hasTouchMoveListener) {
element.removeEventListener('touchmove', this.onBlockingTouchMove);
}
+ if (registration.hasWheelListener) {
+ element.removeEventListener('wheel', this.onWheel);
+ }
this.registry.delete(element);
this.uninstallIfIdle();
}
@@ -1394,7 +1657,13 @@ export class GestureHost {
for (const [element, registration] of this.registry) {
const consumer = registration.consumer;
if (
- registration.recognizers.some(recognizer => recognizer.isEnabled) ||
+ registration.recognizers.some(
+ recognizer =>
+ recognizer.isEnabled &&
+ // One that takes only scroll input has no press to arm.
+ (recognizer.allowedPointerTypes == null ||
+ recognizer.allowedPointerTypes.size > 0),
+ ) ||
(consumer != null && consumer.isEnabled?.() !== false)
) {
wanted.add(element);
@@ -1545,8 +1814,9 @@ export class GestureHost {
/**
* Whether an enabled participant could claim a touch or cancel a lift: a
- * consumer, or a recognizer that claims touches. With only watchers
- * attached, the host's listeners are all passive (`spec:AST-094` FR20).
+ * consumer, or a recognizer that claims touches and takes a finger or a
+ * pen. With only watchers, or only scroll-only recognizers
+ * (`allowedPointerTypes: []`), the host's listeners are all passive.
*/
private hasClaimer(): boolean {
for (const registration of this.registry.values()) {
@@ -1554,7 +1824,10 @@ export class GestureHost {
if (
(consumer != null && consumer.isEnabled?.() !== false) ||
registration.recognizers.some(
- recognizer => recognizer.isEnabled && recognizer.claimsTouches,
+ recognizer =>
+ recognizer.isEnabled &&
+ recognizer.claimsTouches &&
+ takesTouchContacts(recognizer),
)
) {
return true;
@@ -1827,6 +2100,487 @@ export class GestureHost {
}
}
+ /**
+ * Wheel and trackpad input. A wheel gesture drives the recognizers that
+ * allow its scroll type, as a contact that moves with the content. A pan
+ * takes it along its own axis, once no scroller between the pointer and
+ * the pan's element can scroll that way. The gesture's first wheel decides
+ * this. A pinch takes a ctrl-wheel, as a trackpad pinches, as two contacts
+ * whose span follows exp(-deltaY / 100). A wheel the page keeps is never
+ * prevented, and a wheel a recognizer takes always is. A gesture ends its
+ * end window after its last wheel (wheelEndMs), at that wheel's time, so
+ * its release keeps its velocity. A gesture a recognizer took is latched
+ * to it, as a browser latches a scroller. While the gesture lasts, the
+ * shared input stream hands this every wheel first, wherever it lands. So
+ * a pan whose element moves from under a still pointer keeps the gesture.
+ * The page listener exists only during a gesture.
+ */
+ private readonly onWheel = (event: Event): void => {
+ const wheel = event as WheelEvent;
+ if (this.wheelsSeen.has(wheel)) {
+ return;
+ }
+ this.wheelsSeen.add(wheel);
+ const time = eventTime(wheel);
+ const kind = wheel.ctrlKey ? 'pinch' : 'pan';
+ let gesture = this.wheel;
+ if (
+ gesture != null &&
+ (gesture.kind !== kind || time - gesture.last >= gesture.endMs)
+ ) {
+ this.endWheel(time);
+ gesture = null;
+ }
+ if (
+ gesture != null &&
+ gesture.ids.length > 0 &&
+ gesture.follow?.lifted !== true &&
+ !this.wheelHasTaker(gesture)
+ ) {
+ // Every recognizer that took it has left (detached, disabled or
+ // cancelled), so the rest of the gesture is the page's. A followed
+ // pan's own lift does not count. Its momentum stays latched and
+ // prevented until the splitter's next gesture or the end timer, and
+ // moves nothing. The release is stamped at its last wheel.
+ this.endWheel(gesture.last);
+ const left = wheelPixels(wheel, this.doc.defaultView);
+ gesture = {
+ kind,
+ scrollType: scrollTypeOf(wheel),
+ ids: [],
+ x: wheel.clientX,
+ y: wheel.clientY,
+ span: WHEEL_PINCH_SPAN,
+ last: time,
+ endMs: wheelEndMs(
+ kind,
+ scrollTypeOf(wheel),
+ Math.abs(left.dx) > Math.abs(left.dy),
+ ),
+ timer: null,
+ takers: [],
+ };
+ this.wheel = gesture;
+ }
+ const {dx, dy} = wheelPixels(wheel, this.doc.defaultView);
+ const horizontal = Math.abs(dx) > Math.abs(dy);
+ // The splitter reads a pan's stream only. A ctrl-wheel pinch has no
+ // fingers' phase or momentum to tell apart, and ends 100 ms after its
+ // last wheel.
+ let seg: WheelSegment | null = null;
+ if (kind === 'pan') {
+ seg = this.segmenter.push(
+ time,
+ horizontal ? dx : dy,
+ horizontal ? WHEEL_FOLLOW_END_MS : WHEEL_FOLLOW_END_V_MS,
+ );
+ } else {
+ this.segmenter.end();
+ }
+ this.wheelSegment = seg;
+ if (seg?.boundary != null) {
+ if (gesture != null) {
+ this.endWheel(time);
+ gesture = null;
+ }
+ }
+ if (gesture == null) {
+ gesture = this.beginWheel(wheel, kind, dx, dy, time);
+ this.wheel = gesture;
+ if (gesture.ids.length > 0) {
+ this.latchWheel();
+ }
+ }
+ gesture.last = time;
+ if (gesture.timer != null) {
+ this.clock.clearTimeout(gesture.timer);
+ }
+ // A trackpad scroll waits out a slow drag's pauses, whoever
+ // keeps it; a pinch or a mouse wheel's notches 100 ms.
+ gesture.timer = this.clock.setTimeout(() => {
+ this.segmenter.end();
+ this.endWheel();
+ }, gesture.endMs);
+ if (gesture.ids.length === 0) {
+ return;
+ }
+ wheel.preventDefault();
+ if (gesture.kind === 'pan' && gesture.follow) {
+ const f = gesture.follow;
+ if (f.lifted) {
+ return;
+ }
+ if (this.wheelSegment?.liftNow === true) {
+ // Momentum began: the fingers lifted before the held deltas. The pan
+ // keeps the travel it showed through the undecided stretch. It lets
+ // go here, still moving at the fingers' speed, so its release carries
+ // on from it. The rest of the momentum never moves the pan.
+ this.followProvisionally(gesture, f, time);
+ f.lifted = true;
+ this.arena.ended([
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
+ ]);
+ return;
+ }
+ if (this.wheelSegment?.decaying === true) {
+ // Undecided: the real deltas wait for the splitter while the
+ // fingers' last speed carries the pan on.
+ f.heldX += dx;
+ f.heldY += dy;
+ this.followProvisionally(gesture, f, time);
+ return;
+ }
+ // The fingers' own delta; the held ones were theirs too. Their moves
+ // absorb what the pan shows ahead of their travel, or behind it, half a
+ // move at a time. So the pan never steps back, and ends on their travel.
+ const beforeX = f.trueX - f.heldX;
+ const beforeY = f.trueY - f.heldY;
+ const debtX = absorbedDebt(gesture.x - beforeX, dx);
+ const debtY = absorbedDebt(gesture.y - beforeY, dy);
+ const trueX = beforeX - dx;
+ const trueY = beforeY - dy;
+ const elapsed = time - f.lastMove;
+ if (elapsed > 0) {
+ f.speedX = (trueX - f.trueX) / elapsed;
+ f.speedY = (trueY - f.trueY) / elapsed;
+ }
+ f.trueX = trueX;
+ f.trueY = trueY;
+ f.heldX = 0;
+ f.heldY = 0;
+ f.lastMove = time;
+ gesture.x = trueX + debtX;
+ gesture.y = trueY + debtY;
+ f.baseX = gesture.x;
+ f.baseY = gesture.y;
+ this.arena.moved([
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
+ ]);
+ return;
+ }
+ if (gesture.kind === 'pan') {
+ gesture.x -= dx;
+ gesture.y -= dy;
+ this.arena.moved([
+ {id: gesture.ids[0], x: gesture.x, y: gesture.y, time},
+ ]);
+ } else {
+ gesture.span *= Math.exp(-dy / 100);
+ this.arena.moved(this.pinchContacts(gesture, time));
+ }
+ };
+
+ /**
+ * Move a followed pan through the splitter's undecided stretch. It moves on
+ * from where it was shown at the fingers' last move, at their last speed.
+ * It never passes what that speed projects for the time since.
+ */
+ private followProvisionally(
+ gesture: WheelGesture,
+ f: WheelFollow,
+ time: number,
+ ): void {
+ const elapsed = Math.max(0, time - f.lastMove);
+ const x = f.baseX + f.speedX * elapsed;
+ const y = f.baseY + f.speedY * elapsed;
+ if (x === gesture.x && y === gesture.y) {
+ return;
+ }
+ gesture.x = x;
+ gesture.y = y;
+ this.arena.moved([{id: gesture.ids[0], x, y, time}]);
+ }
+
+ /** Start a wheel gesture: who takes it, decided once, or nobody. */
+ private beginWheel(
+ wheel: WheelEvent,
+ kind: 'pan' | 'pinch',
+ dx: number,
+ dy: number,
+ time: number,
+ ): WheelGesture {
+ const type = scrollTypeOf(wheel);
+ const isHorizontal = Math.abs(dx) > Math.abs(dy);
+ const kept: WheelGesture = {
+ kind,
+ scrollType: type,
+ ids: [],
+ x: wheel.clientX,
+ y: wheel.clientY,
+ span: WHEEL_PINCH_SPAN,
+ last: time,
+ endMs: wheelEndMs(kind, type, isHorizontal),
+ timer: null,
+ takers: [],
+ };
+ // A contact already down keeps its gesture; the wheel stays the page's.
+ // So does a first wheel another handler already prevented, or one that
+ // cannot be cancelled: no recognizer could take it from the page.
+ if (
+ this.arena.touches.length > 0 ||
+ (dx === 0 && dy === 0) ||
+ wheel.defaultPrevented ||
+ !wheel.cancelable
+ ) {
+ return kept;
+ }
+ // Every scroller under the pointer, the page included, as a finger's
+ // probe reads them, without touch-action, which a wheel ignores.
+ // What the wheel landed on: at a shadow host's listener the event's
+ // target is the host, and a scroller inside its tree must count.
+ const target = landedOn(wheel);
+ const scroll = createNativeScrollProbe(target, {
+ readsTouchAction: false,
+ });
+ const takers: GestureRecognizer[] = [];
+ for (
+ let node: Node | null = target instanceof Node ? target : null;
+ node != null;
+ node = parentAcrossShadow(node)
+ ) {
+ if (!(node instanceof Element)) {
+ continue;
+ }
+ for (const recognizer of this.registry.get(node)?.recognizers ?? []) {
+ // A pan follows a trackpad, never a mouse wheel's notches, which
+ // scroll what is under the pointer.
+ if (
+ !recognizer.isEnabled ||
+ !recognizer.claimsTouches ||
+ !recognizer.allowedScrollTypes.has(type) ||
+ internals(recognizer).family !== kind ||
+ (kind === 'pan' && type !== 'continuous')
+ ) {
+ continue;
+ }
+ if (
+ kind === 'pan' &&
+ (!panTakesMove(recognizer, -dx, -dy) ||
+ this.scrollerTakes(target, node, dx, dy, isHorizontal) ||
+ // A scroll-edge pan only past every scroller's edge.
+ (isScrollEdgePan(recognizer) && scroll.canScroll(-dx, -dy)))
+ ) {
+ continue;
+ }
+ takers.push(recognizer);
+ }
+ }
+ if (takers.length === 0) {
+ return kept;
+ }
+ const base = {
+ pointerType: 'mouse' as const,
+ target,
+ scroll,
+ scrollType: type,
+ recognizers: takers,
+ consumer: null,
+ };
+ const ids =
+ kind === 'pan' ? [this.nextId++] : [this.nextId++, this.nextId++];
+ const gesture: WheelGesture = {...kept, ids, takers};
+ if (kind === 'pan' && type === 'continuous') {
+ gesture.follow = {
+ lifted: false,
+ heldX: 0,
+ heldY: 0,
+ lastMove: time,
+ trueX: kept.x,
+ trueY: kept.y,
+ baseX: kept.x,
+ baseY: kept.y,
+ speedX: 0,
+ speedY: 0,
+ };
+ }
+ this.arena.began(
+ kind === 'pan'
+ ? [{...base, id: ids[0], x: wheel.clientX, y: wheel.clientY, time}]
+ : this.pinchContacts(gesture, time).map(input => ({
+ ...base,
+ ...input,
+ })),
+ );
+ // Every recognizer it was offered to may decline it (its own
+ // shouldReceiveTouch). Then nobody took the wheel, and it scrolls the
+ // page from its first step, as with no gesture system.
+ if (ids.every(id => this.arena.participantsOf(id).length === 0)) {
+ this.arena.cancelled(ids, time);
+ return kept;
+ }
+ return gesture;
+ }
+
+ /**
+ * Whether a scroller between `target` and `boundary` (both included)
+ * scrolls this wheel now, along the wheel's major axis. It uses the edge
+ * test a finger's probe uses (canElementScroll).
+ */
+ private scrollerTakes(
+ target: unknown,
+ boundary: Element,
+ dx: number,
+ dy: number,
+ isHorizontal: boolean,
+ ): boolean {
+ for (
+ let node: Node | null = target instanceof Node ? target : null;
+ node != null;
+ node = parentAcrossShadow(node)
+ ) {
+ if (
+ node instanceof Element &&
+ canElementScroll(node, isHorizontal ? -dx : 0, isHorizontal ? 0 : -dy)
+ ) {
+ return true;
+ }
+ if (node === boundary) {
+ break;
+ }
+ }
+ return false;
+ }
+
+ /** A ctrl-wheel pinch's two contacts, `span` apart around its center. */
+ private pinchContacts(
+ gesture: WheelGesture,
+ time: number,
+ ): {id: number; x: number; y: number; time: number}[] {
+ return [
+ {id: gesture.ids[0], x: gesture.x - gesture.span / 2, y: gesture.y, time},
+ {id: gesture.ids[1], x: gesture.x + gesture.span / 2, y: gesture.y, time},
+ ];
+ }
+
+ /** Whether a recognizer that took this wheel gesture still takes part in it. */
+ private wheelHasTaker(gesture: WheelGesture): boolean {
+ // A cancelled or failed recognizer keeps that state until its contacts
+ // end, which a wheel gesture's do only here.
+ return gesture.takers.some(recognizer => {
+ const {state} = recognizer;
+ return (
+ recognizer.isEnabled &&
+ (state === 'began' ||
+ state === 'changed' ||
+ (state === 'possible' && recognizer.isInvolved()))
+ );
+ });
+ }
+
+ /**
+ * Route every wheel on the page to the live wheel gesture, from its claim.
+ * The route is the shared input stream's capture listener on the window,
+ * which runs before any document listener. It is non-passive only while it
+ * is subscribed.
+ */
+ private latchWheel(): void {
+ const view = this.doc.defaultView;
+ if (this.stopWheelLatch == null && view != null) {
+ const cancelWheel = () => {
+ const ids = this.dropDrivenWheel();
+ if (ids.length > 0) {
+ this.arena.cancelled(ids, this.arena.now());
+ }
+ };
+ const stops = [
+ subscribeInput(view, 'wheel', this.onWheel, {cancels: true}),
+ // A page that loses focus or hides ends the gesture, as it ends
+ // every touch. An element's blur reaches the window's capture phase
+ // too and means nothing here.
+ subscribeInput(view, 'blur', event => {
+ if (!(event.target instanceof view.Node)) {
+ cancelWheel();
+ }
+ }),
+ subscribeInput(view, 'visibilitychange', () => {
+ if (this.doc.visibilityState === 'hidden') {
+ cancelWheel();
+ }
+ }),
+ ];
+ this.stopWheelLatch = () => {
+ for (const stop of stops) {
+ stop();
+ }
+ };
+ }
+ }
+
+ /**
+ * End the wheel gesture a recognizer still drives, when the page loses
+ * focus or hides, and return its contacts to cancel. The rest of its
+ * stream is the page's until the stream ends, as a touch the browser takes
+ * is. A gesture the page keeps stays as it is. So does a pan its lift
+ * released: its momentum stays latched and prevented until the stream ends.
+ */
+ private dropDrivenWheel(): ReadonlyArray {
+ const gesture = this.wheel;
+ if (
+ gesture == null ||
+ gesture.ids.length === 0 ||
+ gesture.follow?.lifted === true
+ ) {
+ return [];
+ }
+ const kept: WheelGesture = {...gesture, ids: [], takers: []};
+ delete kept.follow;
+ this.wheel = kept;
+ this.stopWheelLatch?.();
+ this.stopWheelLatch = null;
+ return gesture.ids;
+ }
+
+ /**
+ * End the wheel gesture in progress. On its own timer, it ends its end
+ * window after its last wheel. A taker that leaves (a detach, a disable)
+ * passes that last wheel's time. An input that ends it sooner (a press,
+ * the next gesture's first wheel) passes its own time. Both times are on
+ * the wheels' time base (eventTime), never the host's clock, which need
+ * not share it.
+ */
+ private endWheel(endTime?: number): void {
+ const gesture = this.wheel;
+ this.stopWheelLatch?.();
+ this.stopWheelLatch = null;
+ if (gesture == null) {
+ return;
+ }
+ this.wheel = null;
+ if (gesture.timer != null) {
+ this.clock.clearTimeout(gesture.timer);
+ }
+ if (gesture.ids.length === 0 || gesture.follow?.lifted) {
+ return;
+ }
+ // Fingers that stop without a lift end on their travel, where it runs
+ // ahead of what the pan shows. What the undecided stretch showed past it
+ // stays, as a step back would show.
+ if (gesture.follow != null) {
+ const f = gesture.follow;
+ const x = ahead(gesture.x, f.trueX - f.heldX, f.speedX);
+ const y = ahead(gesture.y, f.trueY - f.heldY, f.speedY);
+ if (x !== gesture.x || y !== gesture.y) {
+ gesture.x = x;
+ gesture.y = y;
+ this.arena.moved([{id: gesture.ids[0], x, y, time: gesture.last}]);
+ }
+ }
+ // A trackpad scroll whose fingers stop releases still, past the
+ // velocity's stillness interval. A pinch releases 100 ms after its last
+ // wheel, and a lift released the pan already. The release is never later
+ // than the input that ends it (a press, the next gesture's first wheel).
+ // The arena's clock only moves forward, so a later stamp would move that
+ // input's times too.
+ const end = gesture.last + gesture.endMs;
+ const time =
+ endTime == null ? end : Math.max(gesture.last, Math.min(end, endTime));
+ this.arena.ended(
+ gesture.kind === 'pan'
+ ? [{id: gesture.ids[0], x: gesture.x, y: gesture.y, time}]
+ : this.pinchContacts(gesture, time),
+ );
+ }
+
/** Undo `lockSelection` for a contact that ended; idempotent. */
private unlockSelection(id: number): void {
this.lockedBy.get(id)?.();
@@ -1869,6 +2623,7 @@ export class GestureHost {
}
private readonly onTouchStart = (event: TouchEvent): void => {
+ this.endWheel(eventTime(event));
this.noteFingersDown(event);
// Where the host reads a finger from its pointer events (no ontouchstart,
// as after touch emulation turns on), its Touch Events would track it twice.
@@ -2554,6 +3309,7 @@ export class GestureHost {
// --- Pointer events --------------------------------------------------------
private readonly onPointerDown = (event: PointerEvent): void => {
+ this.endWheel(eventTime(event));
// A new press: whatever click is still owed to the last one is not this.
disarmClickGuard(this.doc);
if (!isPointerKind(event.pointerType)) {
@@ -3024,6 +3780,8 @@ export class GestureHost {
this.unpairedTouches.clear();
this.lastFed.clear();
this.pointerFed.clear();
+ // A wheel gesture a recognizer still drives is cancelled with them.
+ ids.push(...this.dropDrivenWheel());
if (ids.length > 0) {
this.arena.cancelled(ids, time);
}
diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts
index e68007b6b78ea..76223c75c7489 100644
--- a/packages/core/src/gestures/GestureRecognizer.ts
+++ b/packages/core/src/gestures/GestureRecognizer.ts
@@ -36,6 +36,7 @@ import type {
GestureTouch,
Point,
PointerKind,
+ ScrollType,
} from './types';
import {DEFAULT_ENVIRONMENT} from './types';
@@ -81,6 +82,16 @@ export interface GestureRecognizerOptions {
delaysTouchesEnded?: boolean;
/** Pointer kinds this recognizer listens to (UIKit's `allowedTouchTypes`). @default all */
allowedPointerTypes?: ReadonlyArray;
+ /**
+ * Scroll input this recognizer takes, as UIKit's `allowedScrollTypesMask`.
+ * A pan follows a trackpad's two-finger scroll (`continuous`), never a
+ * mouse wheel's notches. It moves as a contact with the content, along its
+ * own axis, after the scrollers under the pointer. A pinch takes a
+ * ctrl-wheel of either, as a trackpad pinches.
+ * @default none; a pinch takes both, the screen-edge pan none whatever a
+ * caller passes
+ */
+ allowedScrollTypes?: ReadonlyArray;
/** Ignore a touch of a different kind than the ones already tracked. @default true */
requiresExclusiveTouchType?: boolean;
/**
@@ -202,6 +213,8 @@ export abstract class GestureRecognizer {
delaysTouchesBegan: boolean;
delaysTouchesEnded: boolean;
allowedPointerTypes: ReadonlySet | null;
+ /** See GestureRecognizerOptions.allowedScrollTypes. */
+ readonly allowedScrollTypes: ReadonlySet;
requiresExclusiveTouchType: boolean;
/**
* The element (or any object) the recognizer is attached to. The DOM host
@@ -247,6 +260,9 @@ export abstract class GestureRecognizer {
options.allowedPointerTypes == null
? null
: new Set(options.allowedPointerTypes);
+ this.allowedScrollTypes = new Set(
+ options.allowedScrollTypes ?? this.defaultScrollTypes,
+ );
this.requiresExclusiveTouchType =
options.requiresExclusiveTouchType ?? true;
}
@@ -479,6 +495,11 @@ export abstract class GestureRecognizer {
/** Clear per-gesture state before the next attempt. */
protected reset(): void {}
+ /** The scroll types taken when the options name none (UIKit's: none). */
+ protected get defaultScrollTypes(): ReadonlyArray {
+ return [];
+ }
+
/**
* Whether a touch may join. The base checks enabled, pointer kind and
* exclusivity of kind; subclasses narrow it (a one-finger pan ignores a
@@ -488,6 +509,13 @@ export abstract class GestureRecognizer {
if (!this.isEnabled) {
return false;
}
+ if (touch.scrollType != null) {
+ // Scroll input is no pointer: only the scroll types allowed count.
+ return (
+ this.allowedScrollTypes.has(touch.scrollType) &&
+ (this.options.shouldReceiveTouch?.(this, touch) ?? true)
+ );
+ }
if (
this.allowedPointerTypes != null &&
!this.allowedPointerTypes.has(touch.pointerType)
diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts
index ac21d5637a7fd..2f65f77b7121c 100644
--- a/packages/core/src/gestures/PanGestureRecognizer.ts
+++ b/packages/core/src/gestures/PanGestureRecognizer.ts
@@ -73,6 +73,14 @@ export interface PanGestureRecognizerOptions extends GestureRecognizerOptions;
/** Begin only when the native scrollers under the touch are at the edge. */
scrollEdge?: boolean;
}
diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts
index 6ca75bb627714..900c348dc9d97 100644
--- a/packages/core/src/gestures/PinchGestureRecognizer.ts
+++ b/packages/core/src/gestures/PinchGestureRecognizer.ts
@@ -27,7 +27,12 @@ import {
type GestureRecognizerOptions,
type NativeTouchBehavior,
} from './GestureRecognizer';
-import type {GestureEventContext, GestureTouch, Point} from './types';
+import type {
+ GestureEventContext,
+ GestureTouch,
+ Point,
+ ScrollType,
+} from './types';
import {VelocityTracker} from './VelocityTracker';
export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions {
@@ -51,6 +56,16 @@ export class PinchGestureRecognizer extends GestureRecognizer {
this.hysteresis = options.hysteresis ?? UIKIT_PINCH.hysteresis.value;
}
+ /** @internal The host's brand: a ctrl-wheel drives a pinch (recognizerInternals). */
+ protected get family(): 'pinch' {
+ return 'pinch';
+ }
+
+ /** A ctrl-wheel drives a pinch, from a trackpad or a mouse. */
+ protected override get defaultScrollTypes(): ReadonlyArray {
+ return ['continuous', 'discrete'];
+ }
+
/** Current span over the span the fingers started at. */
get scale(): number {
return this.scaleValue;
diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md
index 57b3bbf7a7ad5..861a8495736a4 100644
--- a/packages/core/src/gestures/README.md
+++ b/packages/core/src/gestures/README.md
@@ -452,6 +452,68 @@ anywhere (often on a plain row no recognizer is on) fails a pan limited to
one finger, so the two fingers zoom the page (WCAG 1.4.4) instead of the pan
claiming them midway.
+## Wheel and trackpad
+
+A recognizer takes scroll input only where it allows that scroll type
+(`allowedScrollTypes`, UIKit's `allowedScrollTypesMask`): `continuous`
+for a trackpad's two-finger scroll or pinch, `discrete` for a mouse wheel's
+notches. A pan follows a trackpad, never a mouse wheel (`spec:AST-094` FR43,
+DEC-24): it allows only `continuous`, and none by default, so it takes a
+trackpad's scroll only where its owner allows it, its element then getting
+the one wheel listener, and a mouse wheel's notches always scroll what is
+under the pointer. A pinch allows both, for a ctrl-wheel from a trackpad or
+a mouse. The screen-edge pan takes none, as it takes no mouse, trackpad or
+pen (FR43). A control's thumb, a slider's or a switch's, tracks as a control,
+not a pan, and takes no wheel, so a page scroll over it never changes its
+value (DEC-23). The web reports no wheel phases or device, so the deltas
+tell, and what they cannot tell apart reads as a mouse wheel: a line- or
+page-mode delta; a scroll with Shift held (Shift+wheel); a pixel delta a hair
+off a whole pixel (a mouse wheel on Windows); a scroll with no legacy `wheelDelta` (an engine that
+sends none); a delta that is not a third of its legacy `wheelDelta`; or a 120
+tick over a 40 px delta. A trackpad's precise deltas, a third of their legacy
+`wheelDelta` (Chromium and WebKit), read as a trackpad.
+
+A wheel gesture drives a pan as a contact moving with the content, along the
+pan's own axis, and only once every scroller between the pointer and the
+pan's element is at its edge that way, decided at the gesture's first wheel:
+a wheel the pan does not take is never prevented. A watcher
+(`claimsTouches: false`) never takes one, and a gesture whose first wheel
+another handler already prevented, or that cannot be cancelled, stays the
+page's. A scroll-edge pan (a pull to
+refresh) takes one only if no scroller under the pointer could scroll for it,
+its own scroll owner and the page included, as its finger's rule reads them
+(`spec:AST-094` FR19); `touch-action` narrows only a finger's pan, so a wheel
+reads every scroller past it. A ctrl-wheel (a trackpad
+pinch, or Control and a mouse wheel) drives a pinch, whose scale follows
+`exp(-deltaY / 100)`; a pinch takes it by default and ends 100 ms after its
+last wheel. While the fingers move, a trackpad scroll ends 180 ms after its
+last wheel vertically and 220 ms horizontally, whoever keeps it (a pan, a
+scroller under the pointer, or the page), as a slow drag pauses between
+wheels (DEC-24). A gesture's release is never stamped
+later than an input that ends it sooner (FR16).
+
+The web reports no trackpad contact or phase either, so one splitter reads
+every trackpad stream (`wheelSegmenter.ts`, DEC-24): at the fourth or later
+delta of a shrinking run, once the run has lost a fifth of its size, the
+fingers lifted and momentum began. Until it decides,
+the shrinking deltas wait, and a pan it follows moves on at the fingers' last
+speed, never past what that speed projects. At the lift the pan keeps that
+travel and lets go there, still moving at that speed, and the momentum after
+it never moves it; when the fingers carry on instead, their moves absorb
+what the pan showed past their travel, or short of it, so it never steps
+back and ends on their travel. Fingers that stop release still, once the end
+time passes. In momentum, a reversal, a delta that grows
+against the decay, or a 50 ms pause starts a new gesture. A finger, mouse or
+pen that comes down ends it, and a wheel claim keeps the page's text
+selection. A gesture a
+recognizer took is latched to it, as a browser latches a scroller: while it
+lasts, the host hears every step first, through the shared input stream's
+window-capture listener, wherever it lands, so a pan whose element moves from
+under a still pointer keeps the gesture. A page that loses focus or hides
+cancels a wheel gesture a recognizer still drives, as it cancels every touch;
+the rest of that stream is the page's. A pan its lift released keeps its
+momentum latched until the stream ends.
+
## Time
Every input carries its own timestamp (`event.timeStamp`), and timers are
diff --git a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts
index d9ca6f5c4d1a1..85653cd918a89 100644
--- a/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts
+++ b/packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts
@@ -71,12 +71,12 @@ interface EdgeLayout {
}
/**
- * A screen-edge pan is a finger's: it takes no `allowedPointerTypes`
- * (`spec:AST-094` FR43).
+ * A screen-edge pan is a finger's: it takes no `allowedPointerTypes` and no
+ * `allowedScrollTypes`, so neither a mouse, a trackpad nor a pen drives it.
*/
export interface ScreenEdgePanGestureRecognizerOptions extends Omit<
PanGestureRecognizerOptions,
- 'direction' | 'allowedPointerTypes'
+ 'direction' | 'allowedPointerTypes' | 'allowedScrollTypes'
> {
/** The edges a touch may start from. @default ['leading'] */
edges?: ReadonlyArray;
@@ -109,9 +109,11 @@ export class ScreenEdgePanGestureRecognizer extends PanGestureRecognizer {
maximumNumberOfTouches: 1,
hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,
...options,
- // A finger's only: under a mouse, a trackpad or a pen the screen-edge
- // pan stays off (`spec:AST-094` FR43), whatever a caller passes.
+ // A finger's only (`spec:AST-094` FR43). Under a mouse, a trackpad or
+ // a pen, the screen-edge pan stays off, whatever a caller passes. It
+ // takes no wheel or trackpad scrolling.
allowedPointerTypes: ['touch'],
+ allowedScrollTypes: [],
});
this.edges = options.edges ?? ['leading'];
this.edgeRegion =
diff --git a/packages/core/src/gestures/__tests__/GestureHost.test.ts b/packages/core/src/gestures/__tests__/GestureHost.test.ts
index 590ff8a26edd7..d4c8265481261 100644
--- a/packages/core/src/gestures/__tests__/GestureHost.test.ts
+++ b/packages/core/src/gestures/__tests__/GestureHost.test.ts
@@ -566,7 +566,9 @@ describe('GestureHost', () => {
.filter(t => /^(touch|pointer)/.test(t));
// A fine primary pointer: nothing on the page while idle, and on the
// element the one listener its press needs, plus the claiming pan's
- // blocking touchmove, which FR24 counts outside that budget (FR19).
+ // blocking touchmove, which FR24 counts outside that budget (FR19). A pan
+ // takes no scroll unless its owner allows a trackpad's, so no wheel
+ // listener (FR43, DEC-24).
const fine = new GestureHost(document, {
clock,
isPrimaryPointerCoarse: () => false,
diff --git a/packages/core/src/gestures/__tests__/browser/bareFixture.ts b/packages/core/src/gestures/__tests__/browser/bareFixture.ts
index ed73146690d74..eabd17f666ef1 100644
--- a/packages/core/src/gestures/__tests__/browser/bareFixture.ts
+++ b/packages/core/src/gestures/__tests__/browser/bareFixture.ts
@@ -15,6 +15,7 @@
import {getGestureHost} from '../../GestureHost';
import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer';
import {PanGestureRecognizer} from '../../PanGestureRecognizer';
+import {PinchGestureRecognizer} from '../../PinchGestureRecognizer';
import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer';
import {lockSelection} from '../../../utils/selectionLock';
import {TapGestureRecognizer} from '../../TapGestureRecognizer';
@@ -22,10 +23,12 @@ import {TapGestureRecognizer} from '../../TapGestureRecognizer';
declare global {
interface Window {
__pageTouch: number;
+ __wheelLog: string[];
__api: {
host: ReturnType;
LongPressGestureRecognizer: typeof LongPressGestureRecognizer;
PanGestureRecognizer: typeof PanGestureRecognizer;
+ PinchGestureRecognizer: typeof PinchGestureRecognizer;
ScreenEdgePanGestureRecognizer: typeof ScreenEdgePanGestureRecognizer;
TapGestureRecognizer: typeof TapGestureRecognizer;
lockSelection: typeof lockSelection;
@@ -36,6 +39,7 @@ declare global {
// The touch listeners on the window and the document, as the engine sees
// them: what makes the page pay for touches.
window.__pageTouch = 0;
+window.__wheelLog = [];
const live = new Set();
const listenerIds = new WeakMap