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 +

+ + {PAGES.map(page => ( + + ))} + +
+ ), +}; + +export const PagingLoop: Story = { + name: 'Paging (Loop)', + render: () => ( +
+

+ Next on the last page returns to the first; Prev on the first goes to + the last +

+ + {PAGES.map(page => ( + + ))} + +
+ ), +}; + +export const PagingInLongPage: Story = { + name: 'Paging (In a Long Page)', + render: () => ( +
+

+ A vertical drag on the pages scrolls the page; a horizontal one pages +

+
+ + {PAGES.map(page => ( + + ))} + +
+
+ ), +}; + +export const PagingCards: Story = { + name: 'Paging (Cards)', + render: () => ( +
+

+ Under a finger each swipe moves one card; the last cards rest where the + row ends +

+ + {PAGES.map(page => ( + +
+

{page.title}

+

{page.body}

+
+
+ ))} +
+
+ ), +}; + export const ImperativeControl: Story = { name: 'Imperative Control (handleRef)', render: function ImperativeControlStory() { diff --git a/apps/storybook/stories/PagerPreview.stories.tsx b/apps/storybook/stories/PagerPreview.stories.tsx new file mode 100644 index 0000000000000..addc49243b742 --- /dev/null +++ b/apps/storybook/stories/PagerPreview.stories.tsx @@ -0,0 +1,205 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PagerPreview.stories.tsx + * @input The internal pager (hooks/usePager.ts) from Core's source + * @output Two sandbox stories: five pages, and five pages that loop + * @position Preview build only, for trying the pager on a phone and on a + * Mac trackpad; not for merge (the pager is internal to Core) + */ + +import {useRef, useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import * as stylex from '@stylexjs/stylex'; +import {usePager} from '@astryxdesign/core/hooks/usePager'; + +const COUNT = 5; +const GAP = 16; + +const styles = stylex.create({ + root: { + fontFamily: 'system-ui, sans-serif', + paddingBlock: 12, + }, + status: { + marginInline: 16, + marginBlock: 0, + fontSize: 16, + }, + hint: { + marginInline: 16, + marginBlockStart: 4, + marginBlockEnd: 12, + fontSize: 13, + color: '#555', + }, + viewport: { + overflow: 'hidden', + height: '60vh', + }, + flexTrack: { + display: 'flex', + gap: GAP, + height: '100%', + }, + loopTrack: { + position: 'relative', + height: '100%', + }, + page: { + flexGrow: 0, + flexShrink: 0, + flexBasis: '100%', + height: '100%', + display: 'grid', + placeItems: 'center', + fontSize: 64, + fontWeight: 600, + color: '#1b1b1b', + userSelect: 'none', + willChange: 'transform', + }, + loopPage: { + position: 'absolute', + top: 0, + bottom: 0, + width: '100%', + }, + buttons: { + display: 'flex', + gap: 8, + margin: 16, + }, +}); + +const dynamic = stylex.create({ + hue: (page: number) => ({ + backgroundColor: `hsl(${page * 70} 60% 72%)`, + }), + at: (virtual: number) => ({ + left: `calc(${virtual} * (100% + ${GAP}px))`, + }), +}); + +/** A page index wrapped into [0, COUNT). */ +function wrap(index: number): number { + return ((index % COUNT) + COUNT) % COUNT; +} + +function Hint() { + return ( +

+ Swipe or fling with a finger, drag with a mouse, or swipe with two fingers + on a trackpad. +

+ ); +} + +function PagesDemo() { + const viewportRef = useRef(null); + const trackRef = useRef(null); + const [index, setIndex] = useState(0); + const pager = usePager({ + viewportRef, + trackRef, + count: COUNT, + gap: GAP, + onIndexChange: page => setIndex(page), + }); + return ( +
+

+ Page {index + 1} of {COUNT} +

+ +
+
+ {Array.from({length: COUNT}, (_, page) => ( +
+ {page + 1} +
+ ))} +
+
+
+ + +
+
+ ); +} + +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( + +
Page one
+
+ Page two +
+
+ Page three +
+
Page four
+
, + ); + return handleRef; +} + +/** Six 120 px cards 10 px apart: they end at 770 px, 470 px past the 300 px view. */ +function renderCards( + props: Partial> = {}, +) { + geometry.slide = 120; + geometry.gap = 10; + const handleRef = createRef(); + render( + + {Array.from({length: 6}, (_, i) => ( +
Card {i + 1}
+ ))} +
, + ); + return handleRef; +} + +describe('Carousel hasSnap', () => { + it('with its buttons, holds the slides in a track; with a pointer it snaps natively', () => { + stubPointer(false); + renderPages(); + const scroller = getScroller(); + const track = getTrack(); + expect(track.children).toHaveLength(4); + for (const slide of Array.from(track.children)) { + expect(slide).toHaveAttribute('aria-roledescription', 'slide'); + expect(getComputedStyle(slide).scrollSnapAlign).toBe('start'); + } + expect(getComputedStyle(scroller).scrollSnapType).toBe('x mandatory'); + // The pager is not attached: keys scroll natively, the track stays. + fireEvent.keyDown(scroller, {key: 'ArrowRight'}); + expect(track.style.translate).toBe(''); + }); + + it("without the snap, the slides stay the scroller's own children", () => { + stubPointer(true); + render( + +
One
+
Two
+
, + ); + expect(getScroller().children[0]).toHaveAttribute( + 'aria-roledescription', + 'slide', + ); + }); + + it('with a pointer, Next scrolls about a viewport, as it always has', () => { + stubPointer(false); + const handle = renderCards(); + const scroller = getScroller(); + const scrollBy = vi.fn(); + scroller.scrollBy = scrollBy; + act(() => handle.current?.scrollNext()); + // A viewport less half a card. + expect(scrollBy).toHaveBeenCalledWith( + expect.objectContaining({left: WIDTH - 60}), + ); + }); + + it('with a pointer under Reduce Motion, Next jumps at once (design:reduced-motion DR3)', () => { + stubPointer(false, {reduced: true}); + const handle = renderPages(); + const scroller = getScroller(); + const scrollBy = vi.fn(); + scroller.scrollBy = scrollBy; + const animate = vi.fn(); + scroller.animate = animate; + act(() => handle.current?.scrollNext()); + expect(scrollBy).toHaveBeenCalledWith({left: WIDTH, behavior: 'auto'}); + expect(animate).not.toHaveBeenCalled(); + }); + + it('under a finger and Reduce Motion, a scrolling carousel cross-fades in 200 ms and jumps while transparent (design:reduced-motion DR2)', async () => { + stubPointer(true, {reduced: true}); + const handleRef = createRef(); + render( + +
One
+
Two
+
Three
+
, + ); + const scroller = getScroller(); + const scrollBy = vi.fn(); + scroller.scrollBy = scrollBy; + const cancel = vi.fn(); + const animate = vi.fn(() => ({finished: Promise.resolve(), cancel})); + scroller.animate = animate as unknown as HTMLElement['animate']; + act(() => handleRef.current?.scrollNext()); + const easing = 'cubic-bezier(0.42, 0, 0.58, 1)'; + expect(animate).toHaveBeenCalledWith([{opacity: 1}, {opacity: 0}], { + duration: 100, + easing, + fill: 'forwards', + }); + expect(scrollBy).not.toHaveBeenCalled(); + await act(async () => Promise.resolve()); + expect(scrollBy).toHaveBeenCalledWith( + expect.objectContaining({behavior: 'auto'}), + ); + expect(animate).toHaveBeenLastCalledWith([{opacity: 0}, {opacity: 1}], { + duration: 100, + easing, + }); + expect(cancel).toHaveBeenCalled(); + }); + + it('an Android finger pages like any other: one touch model, no platform idiom', () => { + stubPointer(true); + vi.stubGlobal('CSS', {supports: () => false}); + renderPages(); + const scroller = getScroller(); + // The pager owns the pans; nothing scrolls natively. + expect(getComputedStyle(scroller).overflowX).toBe('clip'); + expect(getComputedStyle(scroller).scrollSnapType).not.toBe('x mandatory'); + }); + + describe('under a finger (any touch device)', () => { + beforeEach(() => stubPointer(true)); + + it('moves the track instead of scrolling: nothing scrolls natively', () => { + renderPages(); + const scroller = getScroller(); + // jsdom drops touch-action; the browser proofs check 'pan-y pinch-zoom'. + expect(getComputedStyle(scroller).overflowX).toBe('clip'); + expect(getComputedStyle(scroller).overflowY).toBe('clip'); + expect(getComputedStyle(scroller).scrollSnapType).not.toBe('x mandatory'); + }); + + it('arrow keys move one child, Home and End to the ends', () => { + renderPages(); + const scroller = getScroller(); + act(() => scroller.focus()); + fireEvent.keyDown(scroller, {key: 'ArrowRight'}); + expect(trackX()).toBe(-WIDTH); + fireEvent.keyDown(scroller, {key: 'End'}); + expect(trackX()).toBe(-3 * WIDTH); + fireEvent.keyDown(scroller, {key: 'ArrowRight'}); + expect(trackX()).toBe(-3 * WIDTH); + fireEvent.keyDown(scroller, {key: 'Home'}); + expect(trackX()).toBe(0); + fireEvent.keyDown(scroller, {key: 'ArrowLeft'}); + expect(trackX()).toBe(0); + }); + + it('the buttons and the handle move one full-width child, and know the ends', () => { + const handle = renderPages(); + const previous = screen.getByRole('button', {name: 'Scroll left'}); + const next = screen.getByRole('button', {name: 'Scroll right'}); + expect(previous).toBeDisabled(); + expect(handle.current?.canScrollPrev()).toBe(false); + fireEvent.click(next); + expect(trackX()).toBe(-WIDTH); + expect(previous).not.toBeDisabled(); + act(() => handle.current?.scrollTo(3)); + expect(trackX()).toBe(-3 * WIDTH); + expect(next).toBeDisabled(); + expect(handle.current?.canScrollNext()).toBe(false); + act(() => handle.current?.scrollPrev()); + expect(trackX()).toBe(-2 * WIDTH); + }); + + it('cards: an arrow moves one card, the buttons about a viewport onto a card, and the last cards share the end', () => { + const handle = renderCards(); + const scroller = getScroller(); + const next = screen.getByRole('button', {name: 'Scroll right'}); + act(() => scroller.focus()); + fireEvent.keyDown(scroller, {key: 'ArrowRight'}); + expect(trackX()).toBe(-130); + // From 130 px a viewport less half a card is 370 px: the card at 390. + fireEvent.click(next); + expect(trackX()).toBe(-390); + // Past 470 px the track would leave the view: the end, for both last cards. + fireEvent.click(next); + expect(trackX()).toBe(-470); + expect(next).toBeDisabled(); + act(() => handle.current?.scrollTo(0)); + act(() => handle.current?.scrollTo(5)); + expect(trackX()).toBe(-470); + }); + + it('focus moving into a slide off screen brings it to the start edge; one on screen stays', () => { + renderPages(); + act(() => screen.getByRole('button', {name: 'Inside three'}).focus()); + expect(trackX()).toBe(-2 * WIDTH); + }); + + it('focus moving into a card already on screen leaves the track where it is', () => { + geometry.slide = 120; + geometry.gap = 10; + render( + +
Card one
+
+ Card two +
+
Card three
+
Card four
+
, + ); + act(() => screen.getByRole('button', {name: 'Inside two'}).focus()); + expect(trackX()).toBe(0); + }); + + it('gives the children within a viewport of the content on screen their own layers', () => { + renderPages(); + const slides = Array.from(getTrack().children) as HTMLElement[]; + expect(slides.map(slide => slide.style.willChange)).toEqual([ + 'transform', + 'transform', + '', + '', + ]); + }); + + it('fewer slides clamp the page shown, and more keep the pager and the controls in step', async () => { + const handleRef = createRef(); + const pages = (n: number) => ( + + {Array.from({length: n}, (_, i) => ( +
Page {i + 1}
+ ))} +
+ ); + const view = render(pages(4)); + act(() => handleRef.current?.scrollTo(3)); + expect(trackX()).toBe(-3 * WIDTH); + view.rerender(pages(2)); + await nextFrame(); + expect(trackX()).toBe(-WIDTH); + expect(handleRef.current?.canScrollNext()).toBe(false); + view.rerender(pages(4)); + await nextFrame(); + expect(trackX()).toBe(-WIDTH); + expect(handleRef.current?.canScrollNext()).toBe(true); + expect(handleRef.current?.canScrollPrev()).toBe(true); + }); + + it('a gap change reaches the pager: the child keeps its place', () => { + const handleRef = createRef(); + const pages = (g: 2 | 4) => ( + +
Page one
+
Page two
+
Page three
+
Page four
+
+ ); + 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(); let nextListenerId = 1; @@ -92,6 +96,7 @@ window.__api = { host: getGestureHost(document), LongPressGestureRecognizer, PanGestureRecognizer, + PinchGestureRecognizer, ScreenEdgePanGestureRecognizer, TapGestureRecognizer, lockSelection, diff --git a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts index 85340e6ebc954..49b23388361ab 100644 --- a/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts +++ b/packages/core/src/gestures/__tests__/browser/gestures.interaction.browser.spec.ts @@ -25,6 +25,16 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {build} from 'esbuild'; +import {webInputDriver, WHEEL_SKIP} from '@astryxdesign/interaction-harness'; + +/** + * Chromium's driver sends each wheel as one notch, `wheelDeltaY` -120 + * whatever its delta, which the host reads as a mouse wheel's (FR43); + * WebKit's sends a trackpad's precise deltas (`wheelDeltaY` three times the + * delta), so it carries the trackpad-only cases. + */ +const TRACKPAD_SKIP = + "Chromium's driver sends each wheel as a mouse wheel's notch (wheelDeltaY -120 whatever its delta); WebKit's driver sends a trackpad's precise deltas"; import {expect, test, type Page} from '@playwright/test'; interface Entry { @@ -1710,6 +1720,284 @@ test.describe('under a fine primary pointer (spec:AST-094 FR43, FR24)', () => { test.describe('where the primary pointer is fine', () => { test.use({hasTouch: false, isMobile: false}); + test('a scroll-edge pan takes a trackpad scroll only at the top of the page that scrolls it, pulling the content down; mid-page the page scrolls (spec:AST-094 FR43, FR19)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
content
'; + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + pull.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pull:${to}`), + ); + host.attach(document.getElementById('content') as HTMLElement, pull); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const at = {x: 100, y: 200}; + // Mid-page, a scroll up is the page's. + await page.evaluate(() => window.scrollTo(0, 400)); + await page.waitForTimeout(100); + for (let step = 0; step < 4; step += 1) { + await driver.wheel({...at, dy: -16}); + } + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeLessThan(400); + expect(await page.evaluate(() => window.__wheelLog)).toEqual([]); + // At the top, scrolling down is the page's too. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + await driver.wheel({...at, dy: 16}); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + expect(await page.evaluate(() => window.__wheelLog)).toEqual([]); + // At the top, pulling the content down: the pan's, and the page holds. + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(100); + for (let step = 0; step < 4; step += 1) { + await driver.wheel({...at, dy: -16}); + } + await page.waitForTimeout(250); + const pull = await page.evaluate(() => window.__wheelLog); + expect(pull[0]).toBe('pull:began'); + expect(pull).toContain('pull:ended'); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); + + test('a wheel gesture stays with the pan that took it when the pan moves its element from under a still cursor, as a browser latches a scroller (spec:AST-094 FR43)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
card
'; + const card = document.getElementById('card') as HTMLElement; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + // The card follows the pan, as a swipe would move it. + pan.addTransitionListener((_recognizer, _from, to) => { + card.style.transform = `translateX(${pan.translation().x}px)`; + window.__wheelLog.push(`pan:${to}:${Math.round(pan.translation().x)}`); + }); + host.attach(card, pan); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const card = await center(page, 'card'); + // Six 16 px steps at a still cursor: the card leaves it after two. + for (let step = 0; step < 6; step += 1) { + await driver.wheel({...card, dx: 16}); + } + await page.waitForTimeout(250); + const pan = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + const moved = pan.filter(entry => entry.startsWith('pan:changed:')); + // Every step after the first moved the card, and none scrolled the page. + expect(moved).toHaveLength(5); + expect(pan.slice(-2).map(entry => entry.split(':')[1])).toEqual([ + 'ended', + 'possible', + ]); + expect(await page.evaluate(() => window.scrollX)).toBe(0); + }); + + /** A page with a horizontal pan that takes only a trackpad's scroll; its moves land on `window.__wheelLog`. */ + async function trackpadPanPage(page: Page) { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = + '
card
'; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((_recognizer, _from, to) => { + window.__wheelLog.push( + `pan:${to}:${pan.translation().x.toFixed(2)}:${Math.round(pan.velocity().x)}`, + ); + }); + host.attach(document.getElementById('card') as HTMLElement, pan); + // After every listener of the host's: whether each wheel reached the + // page (not prevented) or was the pan's. + window.addEventListener('wheel', event => { + window.__wheelLog.push(`wheel:${String(event.defaultPrevented)}`); + }); + }); + } + + /** The pan's log entries after `deltas`, one wheel each, and a quiet 300 ms. */ + async function trackpadRun(page: Page, deltas: number[]) { + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const at = await center(page, 'card'); + for (const dx of deltas) { + await driver.wheel({...at, dx}); + } + await page.waitForTimeout(300); + const log = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + const moves = log + .filter(entry => /^pan:(changed|ended):/.test(entry)) + .map(entry => Number(entry.split(':')[2])); + return {log, moves}; + } + + test("keeps a trackpad pan moving at the fingers' speed through the splitter's undecided stretch and lets it go at the lift still moving: no stall (spec:AST-094 FR43)", async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await trackpadPanPage(page); + // Four steady wheels, four shrinking ones (the lift is read at the + // fourth), then momentum. + const {log, moves} = await trackpadRun( + page, + [12, 12, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4], + ); + const ended = log.filter(entry => entry.startsWith('pan:ended:')); + expect(ended).toHaveLength(1); + // The fingers' travel at their last steady wheel is -38 (48 px past the + // 10 px hysteresis); the pan moved on past it through the shrinking + // wheels, at least a wheel and a half's worth, never stepping back. + for (const [i, x] of moves.entries()) { + if (i > 0) { + expect(x).toBeLessThanOrEqual(moves[i - 1]); + } + } + expect(moves.at(-1)).toBeLessThan(-38 - 18); + // Let go still moving, and the momentum after it moved nothing. + expect(Math.abs(Number(ended[0].split(':')[3]))).toBeGreaterThan(100); + expect( + log.slice(log.indexOf(ended[0]) + 1).map(entry => entry.split(':')[1]), + ).toEqual(['possible']); + // No wheel reached the page, the momentum after the lift included + // (spec:AST-094 FR43). + const wheels = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('wheel:')), + ); + expect(wheels).toEqual(Array.from({length: 12}, () => 'wheel:true')); + }); + + test('absorbs what the undecided stretch showed when the fingers carry on: never a step back, and the pan ends on their travel (spec:AST-094 FR43)', async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_SKIP); + await trackpadPanPage(page); + const deltas = [12, 12, 12, 12, 11, 10, 12, 12, 12, 12, 12, 12]; + const {moves} = await trackpadRun(page, deltas); + for (const [i, x] of moves.entries()) { + if (i > 0) { + expect(x).toBeLessThanOrEqual(moves[i - 1]); + } + } + const travel = deltas.reduce((sum, delta) => sum + delta, 0) - 10; + expect(moves.at(-1)).toBeCloseTo(-travel, 1); + }); + + test("a pan that takes a trackpad's scroll follows a real trackpad scroll along its axis and keeps the page still, never a mouse wheel's notches, which scroll the page; a wheel across it scrolls the page, and a pinch follows a ctrl-wheel (spec:AST-094 FR43, DEC-24)", async ({ + page, + browserName, + }) => { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer, PinchGestureRecognizer} = window.__api; + document.body.innerHTML = + '
strip
photo
'; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pan:${to}:${Math.round(pan.translation().x)}`), + ); + host.attach(document.getElementById('strip') as HTMLElement, pan); + const pinch = new PinchGestureRecognizer(); + pinch.addTransitionListener((_recognizer, _from, to) => + window.__wheelLog.push(`pinch:${to}:${pinch.scale.toFixed(2)}`), + ); + host.attach(document.getElementById('photo') as HTMLElement, pinch); + }); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + const strip = await center(page, 'strip'); + for (let step = 0; step < 6; step += 1) { + await driver.wheel({...strip, dx: 8}); + } + await page.waitForTimeout(250); + const pan = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pan:')), + ); + if (browserName === 'chromium') { + // Chromium's driver sends a mouse wheel's notches (wheelDeltaX -120 + // whatever the delta): a pan never takes them, and they scroll the page. + expect(pan).toEqual([]); + expect(await page.evaluate(() => window.scrollX)).toBeGreaterThan(0); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(250); + } else { + // WebKit's sends a trackpad's precise deltas, which the pan follows. + expect(pan[0]).toMatch(/^pan:began:/); + expect(pan.some(entry => entry.startsWith('pan:ended:'))).toBe(true); + // The content moves the way the fingers do: a scroll right drags it left. + const moved = pan.filter(entry => entry.startsWith('pan:changed:')); + expect(Number(moved.at(-1)?.split(':')[2])).toBeLessThan(0); + expect(await page.evaluate(() => window.scrollX)).toBe(0); + } + // Across the pan's axis the wheel is the page's. + await driver.wheel({...strip, dy: 60}); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(250); + const photo = await center(page, 'photo'); + for (let step = 0; step < 5; step += 1) { + await driver.wheel({...photo, dy: -10, ctrl: true}); + } + await page.waitForTimeout(250); + const pinch = await page.evaluate(() => + window.__wheelLog.filter(entry => entry.startsWith('pinch:')), + ); + expect(pinch[0]).toMatch(/^pinch:began:/); + expect(pinch.some(entry => entry.startsWith('pinch:ended:'))).toBe(true); + expect( + Math.max(...pinch.map(entry => Number(entry.split(':')[2]))), + ).toBeGreaterThan(1); + }); test("the host registers no page touch listener while idle and warns for nothing, though the page attaches recognizers: an element keeps only a claiming recognizer's blocking touchmove; a mouse drag pans (spec:AST-094 FR43, FR24, FR19)", async ({ page, diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index cf68c863b8940..37f8796de614d 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -20,16 +20,49 @@ import type { TouchConsumer, } from '../types'; -/** A clock that only moves when the test says so. */ +/** + * A clock that only moves when the test says so. Its timers run only when a + * test calls `runDue`. + */ export class ManualClock implements GestureClock { time = 0; + private readonly pending = new Map< + number, + {deadline: number; callback: () => void} + >(); + private nextHandle = 1; now(): number { return this.time; } - setTimeout(): number { - return 0; + setTimeout(callback: () => void, ms: number): number { + const handle = this.nextHandle++; + this.pending.set(handle, {deadline: this.time + ms, callback}); + return handle; + } + clearTimeout(handle: unknown): void { + this.pending.delete(handle as number); + } + /** Move to `time`, running every timer due by then, earliest first. */ + runDue(time: number): void { + for (;;) { + let due: [number, {deadline: number; callback: () => void}] | null = null; + for (const entry of this.pending) { + if ( + entry[1].deadline <= time && + (due == null || entry[1].deadline < due[1].deadline) + ) { + due = entry; + } + } + if (due == null) { + break; + } + this.pending.delete(due[0]); + this.time = Math.max(this.time, due[1].deadline); + due[1].callback(); + } + this.time = time; } - clearTimeout(): void {} } export interface Harness { diff --git a/packages/core/src/gestures/__tests__/wheel.test.ts b/packages/core/src/gestures/__tests__/wheel.test.ts new file mode 100644 index 0000000000000..9efd70aaf6ff4 --- /dev/null +++ b/packages/core/src/gestures/__tests__/wheel.test.ts @@ -0,0 +1,1491 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheel.test.ts + * @input A GestureHost on a manual clock, with pans and pinches that allow + * scroll input, driven by synthetic WheelEvents + * @output Tests for spec:AST-094 FR43's wheel and trackpad input: a pan + * takes scrolling only where it allows that scroll type (UIKit's + * allowedScrollTypesMask, none by default), along its own axis, after the + * scrollers under the pointer; a pinch takes a ctrl-wheel; a wheel gesture + * ends 100 ms after its last event + * @position Unit tests of the host's wheel input (GestureHost.ts) + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../GestureHost'; +import {createNativeScrollProbe} from '../nativeScroll'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {ManualClock} from './harness'; + +let clock: ManualClock; +let host: GestureHost; +let detachers: (() => void)[]; + +function mount(html: string): Record { + document.body.innerHTML = html; + const elements: Record = {}; + for (const element of document.querySelectorAll('[id]')) { + elements[element.id] = element; + } + return elements; +} + +function wheel( + target: EventTarget, + init: { + x?: number; + y?: number; + dx?: number; + dy?: number; + mode?: number; + ctrl?: boolean; + /** The legacy `wheelDeltaX`/`wheelDeltaY` Chromium and WebKit send. */ + legacy?: {x: number; y: number}; + /** A trackpad's wheel: its legacy delta three times its delta. */ + trackpad?: boolean; + /** Shift held, as Shift+wheel sends. */ + shift?: boolean; + time: number; + }, +): WheelEvent { + clock.time = init.time; + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + composed: true, + clientX: init.x ?? 20, + clientY: init.y ?? 20, + deltaX: init.dx ?? 0, + deltaY: init.dy ?? 0, + deltaMode: init.mode ?? 0, + ctrlKey: init.ctrl ?? false, + shiftKey: init.shift ?? false, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + const legacy = + init.legacy ?? + (init.trackpad === true + ? {x: -3 * (init.dx ?? 0), y: -3 * (init.dy ?? 0)} + : null); + if (legacy != null) { + Object.defineProperty(event, 'wheelDeltaX', {value: legacy.x}); + Object.defineProperty(event, 'wheelDeltaY', {value: legacy.y}); + } + target.dispatchEvent(event); + return event; +} + +function statesOf(recognizer: PanGestureRecognizer | PinchGestureRecognizer) { + const states: string[] = []; + recognizer.addTransitionListener((_recognizer, _from, to) => states.push(to)); + return states; +} + +/** jsdom has no layout: give a scroller its horizontal metrics. */ +function scrollableX( + element: HTMLElement, + metrics: {scrollLeft: number; scrollWidth: number; clientWidth: number}, +): void { + element.style.overflowX = 'auto'; + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** jsdom has no layout: give a scroller its vertical metrics. */ +function scrollableY( + element: HTMLElement, + metrics: {scrollTop: number; scrollHeight: number; clientHeight: number}, +): void { + element.style.overflowY = 'auto'; + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +beforeEach(() => { + clock = new ManualClock(); + host = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers = []; +}); + +afterEach(() => { + for (const detach of detachers.splice(0)) { + detach(); + } + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('wheel and trackpad input (spec:AST-094 FR43)', () => { + it.each([ + [ + 'a 50° swipe is the pan’s when its axisRatio is 0.5, as a finger’s is', + 0.5, + 6.4, + 7.7, + true, + ], + [ + 'a 35° swipe is the page’s when its axisRatio of 2 leaves it undecided', + 2, + 8.2, + 5.7, + false, + ], + ] as const)( + 'reads a trackpad’s direction by the pan’s own rule: %s', + (_name, axisRatio, dx, dy, takes) => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + axisRatio, + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect( + wheel(box, {trackpad: true, dx, dy, time: 0}).defaultPrevented, + ).toBe(takes); + for (const time of [16, 32, 48]) { + wheel(box, {trackpad: true, dx, dy, time}); + } + expect(states.includes('began')).toBe(takes); + }, + ); + + it.each([ + ['a blur', 'blur', false], + ['a hidden page', 'hidden', false], + ['a blur, under a coarse primary pointer', 'blur', true], + ] as const)( + 'cancels a trackpad pan mid-swipe at %s: it reads cancelled, never ended, the rest of that stream is the page’s, and the next stream is decided afresh', + (_name, how, isCoarse) => { + const {box, outside} = mount( + '
', + ); + const subject = isCoarse + ? new GestureHost(document, {clock, isPrimaryPointerCoarse: () => true}) + : host; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(subject.attach(box, pan)); + for (const time of [0, 16, 32]) { + clock.runDue(time); + expect( + wheel(box, {trackpad: true, dx: 10, time}).defaultPrevented, + ).toBe(true); + } + expect(states.at(-1)).toBe('changed'); + clock.runDue(40); + if (how === 'blur') { + window.dispatchEvent(new Event('blur')); + } else { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + detachers.push(() => { + delete (document as {visibilityState?: unknown}).visibilityState; + }); + document.dispatchEvent(new Event('visibilitychange')); + } + expect(states.slice(-2)).toEqual(['cancelled', 'possible']); + // The rest of the stream is the page's: the fingers' wheels, then the + // momentum after their lift. + const rest = [10, 10, 10, 10, 12, 11.4, 10.8, 10.3, 9.8, 9.3, 8.8, 8.4]; + rest.forEach((dx, i) => { + expect( + wheel(box, {trackpad: true, dx, time: 48 + i * 16}).defaultPrevented, + ).toBe(false); + }); + expect(states.filter(state => state === 'began')).toHaveLength(1); + expect(states).not.toContain('ended'); + // The latch is gone: a wheel outside the pan is the page's. + expect( + wheel(outside, {trackpad: true, dx: 8, time: 48 + 12 * 16}) + .defaultPrevented, + ).toBe(false); + // After the stream's pause, a new stream is decided afresh. + clock.runDue(48 + 12 * 16 + 400); + expect( + wheel(box, {trackpad: true, dx: 10, time: 700}).defaultPrevented, + ).toBe(true); + }, + ); + + it('reads a right-to-left scroller under the pointer by its own range: at its start a scroll toward it is the pan’s, a scroll away from it the scroller’s', () => { + const {strip, scroller, item} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(strip, pan)); + // Right to left, scrollLeft runs from 0 at the start to minus the + // overflow: at 0 the scroller cannot scroll right any further. + scrollableX(scroller, {scrollLeft: 0, scrollWidth: 300, clientWidth: 200}); + expect( + wheel(item, {trackpad: true, dx: -20, time: 0}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + // The scroller's stream ends 220 ms after its last wheel (FR43); the + // next one is decided afresh. + expect( + wheel(item, {trackpad: true, dx: 20, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(item, {trackpad: true, dx: 20, time: 416}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a right-to-left scroller by a finger's edge test: one WebKit leaves a pixel past its reported range is at its end, and a scroll on toward it is the pan’s", () => { + const {strip, scroller, item} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(strip, pan)); + // A fractional width rounds the reported overflow down to 99, while + // WebKit scrolls to -100 (measured): past the reported end, so at it. + scrollableX(scroller, { + scrollLeft: -100, + scrollWidth: 299, + clientWidth: 200, + }); + expect( + wheel(item, {trackpad: true, dx: -20, time: 0}).defaultPrevented, + ).toBe(true); + expect( + wheel(item, {trackpad: true, dx: -20, time: 16}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a scroller's ends as a finger's probe does: 0.75 px short of its end it can still scroll, so the stream is the scroller's; within half a pixel, the pan's (spec:AST-094 FR43, FR19)", () => { + const {sheet, list, row} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(sheet, pan)); + scrollableY(list, { + scrollTop: 299.25, + scrollHeight: 400, + clientHeight: 100, + }); + expect( + createNativeScrollProbe(row, {readsTouchAction: false}).canScroll(0, -8), + ).toBe(true); + expect(wheel(row, {trackpad: true, dy: 8, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + Object.defineProperty(list, 'scrollTop', { + configurable: true, + value: 299.6, + }); + expect( + createNativeScrollProbe(row, {readsTouchAction: false}).canScroll(0, -8), + ).toBe(false); + expect( + wheel(row, {trackpad: true, dy: 8, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(row, {trackpad: true, dy: 8, time: 416}).defaultPrevented, + ).toBe(true); + expect(states[0]).toBe('began'); + }); + + it('finds a scroller inside a shadow tree between the pointer and the pan, from what the wheel landed on', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
'; + const scroller = shadow.getElementById('scroller') as HTMLElement; + const item = shadow.getElementById('item') as HTMLElement; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(outer, pan)); + scrollableX(scroller, {scrollLeft: 50, scrollWidth: 300, clientWidth: 200}); + expect(wheel(item, {dx: 20, time: 0}).defaultPrevented).toBe(false); + expect(wheel(item, {dx: 20, time: 16}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it("takes a wheel on a scroll-edge pan only while no scroller under the pointer could scroll for it, its own scroll owner above it included, as a finger's (FR19)", () => { + const {owner, content} = mount( + '
', + ); + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pull); + detachers.push(host.attach(content, pull)); + // Mid-scroll: a scroll up is the owner's. + scrollableY(owner, {scrollTop: 50, scrollHeight: 300, clientHeight: 200}); + expect( + wheel(content, {trackpad: true, dy: -20, time: 0}).defaultPrevented, + ).toBe(false); + expect( + wheel(content, {trackpad: true, dy: -20, time: 16}).defaultPrevented, + ).toBe(false); + // At the top, a scroll down is the owner's too. + scrollableY(owner, {scrollTop: 0, scrollHeight: 300, clientHeight: 200}); + expect( + wheel(content, {trackpad: true, dy: 20, time: 200}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + // At the top, pulling the content down: the pan's. + expect( + wheel(content, {trackpad: true, dy: -20, time: 400}).defaultPrevented, + ).toBe(true); + expect( + wheel(content, {trackpad: true, dy: -20, time: 416}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + }); + + it("reads a wheel's scrollers past touch-action, which only narrows a finger's pan: content with touch-action none still leaves a mid-scroll wheel to its owner", () => { + const {owner, content} = mount( + '
', + ); + const pull = new PanGestureRecognizer({ + direction: 'vertical', + scrollEdge: true, + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pull); + detachers.push(host.attach(content, pull)); + scrollableY(owner, {scrollTop: 50, scrollHeight: 300, clientHeight: 200}); + expect(wheel(content, {dy: -20, time: 0}).defaultPrevented).toBe(false); + expect(wheel(content, {dy: -20, time: 16}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('latches a wheel gesture to the pan that took it, as a browser latches a scroller: a step that lands outside the moved element still drives the pan and is prevented, until the gesture ends', () => { + const {wrapper, card} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(card, pan)); + const added = vi.spyOn(window, 'addEventListener'); + expect( + wheel(card, {trackpad: true, dx: 16, time: 0}).defaultPrevented, + ).toBe(true); + expect( + wheel(card, {trackpad: true, dx: 16, time: 16}).defaultPrevented, + ).toBe(true); + expect(states).toEqual(['began', 'changed']); + const afterTwo = pan.translation().x; + // The card has moved out from under a still cursor: the next steps land + // on the wrapper. + for (const time of [32, 48, 64, 80]) { + expect( + wheel(wrapper, {trackpad: true, dx: 16, time}).defaultPrevented, + ).toBe(true); + } + expect(pan.translation().x).toBe(afterTwo - 64); + expect(states.at(-1)).toBe('changed'); + // One capturing, non-passive wheel listener on the window: the shared + // input stream's (architecture:react-component-runtime INV13). + expect( + (added.mock.calls as unknown[][]).filter( + call => + call[0] === 'wheel' && + (call[2] as AddEventListenerOptions | undefined)?.capture === true && + (call[2] as AddEventListenerOptions | undefined)?.passive === false, + ), + ).toHaveLength(1); + const removed = vi.spyOn(window, 'removeEventListener'); + // A followed trackpad swipe ends 220 ms after its last wheel. + clock.runDue(80 + 220); + expect(states.slice(-2)).toEqual(['ended', 'possible']); + expect( + (removed.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toHaveLength(1); + // Over: a wheel on the wrapper is the page's again. + expect( + wheel(wrapper, {trackpad: true, dx: 16, time: 400}).defaultPrevented, + ).toBe(false); + }); + + it("hears a latched wheel through the shared input stream, so an app's window-capture stopPropagation of a wheel outside the moved element cannot hand it to the page (architecture:react-component-runtime INV13)", () => { + const {card, outside} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(card, pan)); + // The app stops every wheel that does not land on the card, on the + // window's capture phase. + const stop = (event: Event) => { + if (event.target !== card) { + event.stopPropagation(); + } + }; + window.addEventListener('wheel', stop, {capture: true}); + try { + wheel(card, {trackpad: true, dx: 16, time: 0}); + wheel(card, {trackpad: true, dx: 16, time: 16}); + expect(pan.state).toBe('changed'); + // The card moved from under the cursor: the next step lands outside. + const step = wheel(outside, {trackpad: true, dx: 16, time: 32}); + expect(step.defaultPrevented).toBe(true); + expect(pan.translation().x).toBeLessThan(-16); + } finally { + window.removeEventListener('wheel', stop, {capture: true}); + } + }); + + it("ends a wheel gesture whose pan is detached mid-gesture at once, and takes the window's wheel listener with it (FR24)", () => { + const {card} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(card, pan); + wheel(card, {trackpad: true, dx: 16, time: 0}); + wheel(card, {trackpad: true, dx: 16, time: 16}); + expect(pan.state).toBe('changed'); + const removed = vi.spyOn(window, 'removeEventListener'); + detach(); + // The latch leaves the shared stream, and its window listener goes with + // its last subscriber. + expect( + (removed.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toHaveLength(1); + expect( + wheel(document.body, {trackpad: true, dx: 16, time: 32}).defaultPrevented, + ).toBe(false); + }); + + it('arms nothing else for a recognizer that takes only scroll input (no pointer types): where the primary pointer is fine its element gets the wheel listener alone, and no finger, mouse or pen starts it', () => { + const {content} = mount('
'); + const added = vi.spyOn(content, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(content, pan)); + expect( + (added.mock.calls as unknown[][]).map(call => String(call[0])), + ).toEqual(['wheel']); + for (const pointerType of ['mouse', 'pen', 'touch']) { + for (const [type, y] of [ + ['pointerdown', 10], + ['pointermove', 40], + ['pointermove', 80], + ['pointerup', 80], + ] as const) { + content.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 7, + pointerType, + clientX: 10, + clientY: y, + buttons: type === 'pointerup' ? 0 : 1, + }), + ); + } + } + expect(states).toEqual([]); + }); + + it('makes no touch listener cancelling for a scroll-only claimer: with it beside a watcher, the touchend stays passive and the contextmenu off (spec:AST-094 FR20, FR24)', () => { + const {watched, pull} = mount( + '
', + ); + const added = vi.spyOn(window, 'addEventListener'); + detachers.push(() => added.mockRestore()); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + // A watcher installs the host's touch listeners, all passive (FR20). + detachers.push( + coarse.attach( + watched, + new PanGestureRecognizer({claimsTouches: false, direction: 'vertical'}), + ), + ); + added.mockClear(); + // A pull that takes only the wheel claims no touch. + detachers.push( + coarse.attach( + pull, + new PanGestureRecognizer({ + direction: 'vertical', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }), + ), + ); + const calls = added.mock.calls as unknown as [ + string, + unknown, + AddEventListenerOptions | undefined, + ][]; + expect( + calls.filter( + ([type, , options]) => + type === 'touchend' && options?.passive === false, + ), + ).toEqual([]); + expect(calls.filter(([type]) => type === 'contextmenu')).toEqual([]); + }); + + it('listens for the wheel only where a recognizer a wheel can drive allows it: a tap that allows scroll input adds no wheel listener', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const tap = new TapGestureRecognizer({allowedScrollTypes: ['continuous']}); + detachers.push(host.attach(box, tap)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + }); + + it("takes no scroll on a pan by default: a pan follows a trackpad's scroll only where its owner allows it, so its element gets no wheel listener (spec:AST-094 FR43, DEC-24)", () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect(pan.allowedScrollTypes.size).toBe(0); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + expect(wheel(box, {trackpad: true, dx: 30, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + }); + + it("never takes a mouse wheel's notches on a pan, even one a caller passes 'discrete': they scroll what is under the pointer (spec:AST-094 FR43, DEC-24)", () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + // @ts-expect-error a pan takes only a trackpad's scroll + allowedScrollTypes: ['continuous', 'discrete'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + // A notch in lines (Firefox's), a whole 100 px notch, and a 120 tick over + // 40 px (Chromium's). + expect(wheel(box, {dx: 1, mode: 1, time: 0}).defaultPrevented).toBe(false); + clock.runDue(300); + expect(wheel(box, {dx: 100, time: 400}).defaultPrevented).toBe(false); + clock.runDue(700); + expect( + wheel(box, {dx: 40, legacy: {x: -120, y: 0}, time: 800}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + }); + + it('leaves a wheel every taker declined to the page from its first step, as with no gesture system: a pan whose shouldReceiveTouch refuses it prevents nothing (spec:AST-094 FR43, FR19)', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + // A Toast declines a wheel a scroller under it can still take. + shouldReceiveTouch: () => false, + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const prevented = [0, 16, 32, 48, 64, 80].map( + time => wheel(box, {trackpad: true, dx: 16, time}).defaultPrevented, + ); + expect(prevented).toEqual([false, false, false, false, false, false]); + expect(states).toEqual([]); + clock.runDue(300); + // A notch, too. + expect(wheel(box, {dx: 1, mode: 1, time: 400}).defaultPrevented).toBe( + false, + ); + }); + + it('takes no wheel for a pan whose caller allows no scroll type: its element gets no wheel listener, and the page scrolls', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: [], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect(added.mock.calls.map(call => call[0])).not.toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('takes no wheel on a screen-edge pan, which FR43 keeps off under a mouse and a trackpad: a widened allowedScrollTypes is refused (spec:AST-094 FR43)', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['leading'], + // @ts-expect-error the screen-edge pan takes no allowedScrollTypes + allowedScrollTypes: ['continuous', 'discrete'], + }); + expect(edge.allowedScrollTypes.size).toBe(0); + detachers.push(host.attach(box, edge)); + expect(added.mock.calls.map(call => call[0])).not.toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + }); + + it('takes a trackpad scroll along its axis on a pan that allows continuous scrolling: it begins past 10 px, follows the content, and ends 220 ms after the last wheel, a slow swipe pausing between its wheels', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([['wheel', expect.any(Function), {passive: false}]]); + const events = [0, 16, 32, 48].map(time => + wheel(box, {trackpad: true, dx: 6.5, dy: 0.5, time}), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + // The content moves the way the fingers do: a scroll right drags it left. + expect(pan.translation().x).toBeLessThan(0); + expect(states).not.toContain('ended'); + clock.runDue(48 + 219); + expect(states).not.toContain('ended'); + clock.runDue(48 + 220); + expect(states).toContain('ended'); + }); + + it("releases a trackpad's scroll with the fingers' speed where they lift into momentum, and still where they stop (spec:AST-094 FR43, DEC-24)", () => { + const {smooth, stopped} = mount( + '
', + ); + const released: Record = {}; + for (const [key, element] of [ + ['smooth', smooth], + ['stopped', stopped], + ] as const) { + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((current, _from, to) => { + if (to === 'ended') { + released[key] = { + velocity: current.velocity().y, + scroll: current.scrollVelocity().y, + }; + } + }); + detachers.push(host.attach(element, pan)); + } + // A trackpad's fling: steady deltas, then shrinking ones as the fingers + // lift into momentum. It releases at the lift with the fingers' speed. + let time = 500; + for (const dy of [8, 8, 8, 8, 7.6, 7.2, 6.8, 6.4, 6, 5.6]) { + wheel(smooth, {trackpad: true, dy, time}); + time += 16; + } + clock.runDue(time + 200); + expect(Math.abs(released.smooth.velocity)).toBeGreaterThan(0); + expect(Math.abs(released.smooth.scroll)).toBeGreaterThan(0); + // Fingers that stop: the scroll ends 180 ms after its last wheel, still. + for (const at of [1500, 1516, 1532, 1548]) { + wheel(stopped, {trackpad: true, dy: 8, time: at}); + } + clock.runDue(1548 + 180); + expect(Math.abs(released.stopped.velocity)).toBe(0); + expect(Math.abs(released.stopped.scroll)).toBe(0); + }); + + it("releases a trackpad scroll whose fingers stop still whatever the host's clock reads: the release is stamped on the wheels' own times", () => { + // The host schedules a gesture's end on its clock, while the arena reads + // the wheels' own timestamps, which need not share the clock's base: a + // page whose clock a test fakes, as a sheet's tests do. + const {pad} = mount('
'); + const released: number[] = []; + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + pan.addTransitionListener((current, _from, to) => { + if (to === 'ended') { + released.push(current.velocity().y); + } + }); + detachers.push(host.attach(pad, pan)); + clock.time = 0; + for (const time of [1_000_000, 1_000_016, 1_000_032, 1_000_048]) { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + composed: true, + clientX: 20, + clientY: 20, + deltaY: 8, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + // A trackpad's signature: a legacy delta three times the delta. + Object.defineProperty(event, 'wheelDeltaX', {value: 0}); + Object.defineProperty(event, 'wheelDeltaY', {value: -24}); + pad.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } + clock.runDue(200); + expect(released.map(Math.abs)).toEqual([0]); + }); + + it('starts a new wheel gesture after a quiet gap even when the end timer has not run yet (a blocked main thread)', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + for (const time of [0, 16, 32]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + // The timer that would end it has not run: the next wheel, 250 ms later + // (past a swipe's 220 ms), ends it and begins another. + for (const time of [282, 298, 314]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + expect(states.filter(state => state === 'began')).toHaveLength(2); + expect(states).toContain('ended'); + }); + + it('leaves a wheel across its axis to the page', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const events = [0, 16, 32].map(time => + wheel(box, {trackpad: true, dy: 12, time}), + ); + expect(events.some(event => event.defaultPrevented)).toBe(false); + expect(states).toEqual([]); + }); + + it('leaves a wheel to a scroller under the pointer that can still scroll that way, for the whole wheel gesture, and takes the next one once it is at its edge', () => { + const {box, list} = mount( + '
row
', + ); + scrollableX(list, {scrollLeft: 0, scrollWidth: 600, clientWidth: 200}); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + const native = [0, 16, 32].map(time => + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time, + }), + ); + // Decided once, at the gesture's first wheel: the list scrolls. + Object.defineProperty(list, 'scrollLeft', {configurable: true, value: 400}); + native.push( + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time: 48, + }), + ); + expect(native.some(event => event.defaultPrevented)).toBe(false); + expect(states).toEqual([]); + clock.runDue(48 + 100); + const taken = [500, 516, 532].map(time => + wheel(document.getElementById('row') as HTMLElement, { + trackpad: true, + dx: 8, + time, + }), + ); + expect(taken.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + }); + + it("tells a mouse wheel's notches from a trackpad: a pan leaves lines and whole notches to the page and takes a trackpad's scroll (spec:AST-094 FR43, DEC-24)", () => { + const {smooth} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(smooth, pan)); + // Lines (Firefox's mouse wheel), and a whole notch in pixels (Chromium's). + expect(wheel(smooth, {dy: 3, mode: 1, time: 0}).defaultPrevented).toBe( + false, + ); + clock.runDue(200); + expect(wheel(smooth, {dy: 100, time: 300}).defaultPrevented).toBe(false); + clock.runDue(500); + expect(states).toEqual([]); + expect( + wheel(smooth, {trackpad: true, dy: 12, time: 600}).defaultPrevented, + ).toBe(true); + expect(states[0]).toBe('began'); + }); + + it('takes a ctrl-wheel on a pinch, a trackpad pinch or ctrl and a mouse wheel: the scale follows exp(-deltaY / 100), the page does not zoom, and it ends 100 ms after the last wheel', () => { + const {photo} = mount('
'); + const pinch = new PinchGestureRecognizer(); + const states = statesOf(pinch); + detachers.push(host.attach(photo, pinch)); + const events = [0, 16, 32, 48, 64].map(time => + wheel(photo, {dy: -10, ctrl: true, time}), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + expect(states[0]).toBe('began'); + expect(pinch.scale).toBeGreaterThan(1); + expect(pinch.scale).toBeLessThanOrEqual(Math.exp(0.5) + 1e-6); + clock.runDue(64 + 100); + expect(states).toContain('ended'); + }); + + it("keeps a trackpad stream a scroller took for FR43's pause bound, not 100 ms: a slow drag that rests 130 ms at the list's end stays the list's, and the pan takes only the next stream (spec:AST-094 FR43)", () => { + const {sheet, list, row} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(sheet, pan)); + scrollableY(list, {scrollTop: 0, scrollHeight: 400, clientHeight: 100}); + const at = (time: number) => { + clock.runDue(time); + return wheel(row, {trackpad: true, dy: 8, time}); + }; + // Three wheels the list scrolls. + expect([0, 16, 32].map(at).some(event => event.defaultPrevented)).toBe( + false, + ); + // The list reaches its end; the fingers rest 130 ms and carry on: still + // the list's stream, so the pan never takes it midway. + Object.defineProperty(list, 'scrollTop', {configurable: true, value: 300}); + expect([162, 178, 194].map(at).some(event => event.defaultPrevented)).toBe( + false, + ); + expect(states).toEqual([]); + // Past the 180 ms bound the stream has ended; the next one is the pan's. + expect([600, 616].map(at).every(event => event.defaultPrevented)).toBe( + true, + ); + expect(states[0]).toBe('began'); + }); + + it('keeps a ctrl-wheel pinch out of the trackpad splitter: a pinch that slows and reverses, or rests 60 ms, stays one gesture, and ends 100 ms after its last wheel (spec:AST-094 FR43)', () => { + const {photo} = mount('
'); + const pinch = new PinchGestureRecognizer(); + const states = statesOf(pinch); + detachers.push(host.attach(photo, pinch)); + const at = (time: number, dy: number) => { + clock.runDue(time); + return wheel(photo, {dy, ctrl: true, time}); + }; + const deltas = [-3, -4, -5, -4, -3, -2.5, -2, -1.5, 1, 2, 3, 4]; + for (const [i, dy] of deltas.entries()) { + expect(at(i * 16, dy).defaultPrevented).toBe(true); + } + expect(states.filter(state => state === 'began')).toHaveLength(1); + expect(states).not.toContain('ended'); + // One pinch throughout: still magnified after the reversal, where a + // second pinch starting from 1 would read below it. + expect(pinch.scale).toBeGreaterThan(1); + clock.runDue(11 * 16 + 99); + expect(states).not.toContain('ended'); + clock.runDue(11 * 16 + 100); + expect(states.filter(state => state === 'ended')).toHaveLength(1); + // Slowing, then resting 60 ms, is still one pinch. + states.length = 0; + for (const [i, dy] of [-5, -4, -3, -2.5, -2].entries()) { + at(1000 + i * 16, dy); + } + at(1000 + 4 * 16 + 60, -2); + expect(states.filter(state => state === 'began')).toHaveLength(1); + expect(states).not.toContain('ended'); + }); + + it("keeps the page's text selection when a wheel pan claims, as a scroll does", () => { + const {box} = mount('
Some selected text
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(box, pan)); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(box); + selection.addRange(range); + for (const time of [0, 16, 32, 48]) { + wheel(box, {trackpad: true, dx: 8, time}); + } + expect(pan.state).not.toBe('possible'); + expect(selection.rangeCount).toBe(1); + expect(box.style.getPropertyValue('user-select')).toBe(''); + }); + + it('keeps a watcher pan off the wheel: its element gets no wheel listener, and the page keeps the wheel (spec:AST-094 FR20, DEC-7)', () => { + const {box} = mount('
'); + const added = vi.spyOn(box, 'addEventListener'); + const watcher = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + claimsTouches: false, + }); + const states = statesOf(watcher); + detachers.push(host.attach(box, watcher)); + expect( + (added.mock.calls as unknown[][]).filter(call => call[0] === 'wheel'), + ).toEqual([]); + expect(wheel(box, {trackpad: true, dx: 30, time: 0}).defaultPrevented).toBe( + false, + ); + expect(states).toEqual([]); + }); + + it('leaves a first wheel already prevented, or one that cannot be cancelled, to the page for its whole gesture', () => { + const {box, inner} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + // An app's own handler below the pan prevented the first wheel. + inner.addEventListener('wheel', event => event.preventDefault(), { + once: true, + }); + wheel(inner, {trackpad: true, dx: 30, time: 0}); + expect( + wheel(inner, {trackpad: true, dx: 30, time: 16}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + clock.runDue(400); + // A wheel the browser lets no one cancel. + const uncancelable = new WheelEvent('wheel', { + bubbles: true, + cancelable: false, + composed: true, + clientX: 20, + clientY: 20, + deltaX: 30, + }); + Object.defineProperty(uncancelable, 'timeStamp', {value: 500}); + Object.defineProperty(uncancelable, 'wheelDeltaX', {value: -90}); + Object.defineProperty(uncancelable, 'wheelDeltaY', {value: 0}); + clock.time = 500; + inner.dispatchEvent(uncancelable); + expect( + wheel(inner, {trackpad: true, dx: 30, time: 516}).defaultPrevented, + ).toBe(false); + expect(states).toEqual([]); + }); + + it("stamps a trackpad gesture's release no later than the press that ends it: a long press right after keeps its own clock (spec:AST-094 FR16)", () => { + const {box, other} = mount('
'); + detachers.push( + host.attach( + box, + new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }), + ), + ); + const press = new LongPressGestureRecognizer({name: 'press'}); + const began: number[] = []; + press.addListener(recognizer => { + if (recognizer.state === 'began') { + began.push(clock.time); + } + }); + detachers.push(host.attach(other, press)); + // A trackpad's scroll past the pan's 10 px start. + expect(wheel(box, {trackpad: true, dy: 12, time: 0}).defaultPrevented).toBe( + true, + ); + // A pen comes down 20 ms later, ending the wheel gesture. + clock.time = 20; + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 5, + pointerType: 'pen', + clientX: 5, + clientY: 5, + buttons: 1, + }); + Object.defineProperty(down, 'timeStamp', {value: 20}); + other.dispatchEvent(down); + clock.runDue(519); + expect(began).toEqual([]); + clock.runDue(521); + // 500 ms after the pen landed, not after the scroll's release time. + expect(began).toHaveLength(1); + }); + + it.each(['detached', 'disabled'] as const)( + 'stamps the release of a trackpad pan %s mid-swipe at its last wheel: a long press right after keeps its own clock (spec:AST-094 FR16)', + how => { + const {box, other} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'vertical', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(box, pan); + const press = new LongPressGestureRecognizer({name: 'press'}); + const began: number[] = []; + press.addListener(recognizer => { + if (recognizer.state === 'began') { + began.push(clock.time); + } + }); + detachers.push(host.attach(other, press)); + expect( + wheel(box, {trackpad: true, dy: 12, time: 0}).defaultPrevented, + ).toBe(true); + // 10 ms later the pan leaves mid-swipe, and the fingers' next wheel + // lands at 16 ms, the page's now. + clock.runDue(10); + if (how === 'detached') { + detach(); + } else { + pan.setEnabled(false); + detachers.push(detach); + } + clock.runDue(16); + expect( + wheel(box, {trackpad: true, dy: 12, time: 16}).defaultPrevented, + ).toBe(false); + // A pen comes down at 20 ms. + clock.runDue(20); + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 6, + pointerType: 'pen', + clientX: 5, + clientY: 5, + buttons: 1, + }); + Object.defineProperty(down, 'timeStamp', {value: 20}); + other.dispatchEvent(down); + clock.runDue(519); + expect(began).toEqual([]); + clock.runDue(521); + // 500 ms after the pen landed, not after the scroll's end window. + expect(began).toHaveLength(1); + }, + ); + + it('removes the wheel listener when the pan is disabled or detached', () => { + const {box} = mount('
'); + const removed = vi.spyOn(box, 'removeEventListener'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(box, pan); + pan.isEnabled = false; + host.update(box); + expect(removed.mock.calls.map(call => call[0])).toContain('wheel'); + expect(wheel(box, {dx: 30, time: 0}).defaultPrevented).toBe(false); + detach(); + }); +}); + +describe("a mouse's wheel and a trackpad's lift (spec:AST-094 FR43)", () => { + /** A pan that takes only a trackpad's scroll, horizontally. */ + function trackpadPan(box: HTMLElement): PanGestureRecognizer { + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + detachers.push(host.attach(box, pan)); + return pan; + } + + for (const [name, init] of [ + [ + 'deltas a hair off whole pixels, as a mouse wheel on Windows sends', + {dx: 100.0000015}, + ], + [ + 'a legacy wheelDelta that is not three times the delta', + {dx: 10, legacy: {x: -120, y: 0}}, + ], + [ + 'a 120 legacy tick over a 40 px delta, a notch', + {dx: 40, legacy: {x: -120, y: 0}}, + ], + ] as const) { + it(`counts ${name} as a mouse's wheel: a pan that takes only a trackpad's scroll leaves it to the page`, () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, {...init, time: step * 16}); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + } + + it("counts a pixel scroll that carries no legacy wheelDelta, as Firefox sends, as a mouse's wheel: a pan that takes only a trackpad's scroll leaves it to the page", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, {dx: 7, time: step * 16}); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + + it("counts a scroll with Shift held, as Shift+wheel sends, as a mouse's wheel even with a trackpad's deltas: a pan that takes only a trackpad's scroll leaves it to the page (spec:AST-094 FR43)", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + const event = wheel(box, { + trackpad: true, + shift: true, + dx: 7, + time: step * 16, + }); + expect(event.defaultPrevented).toBe(false); + } + expect(states).toEqual([]); + }); + + it("takes a trackpad's precise deltas, whose legacy wheelDelta is three times each", () => { + const {box} = mount('
'); + const pan = trackpadPan(box); + const states = statesOf(pan); + for (let step = 0; step < 3; step++) { + wheel(box, {dx: 7, legacy: {x: -21, y: 0}, time: step * 16}); + } + expect(states[0]).toBe('began'); + }); +}); + +describe("a trackpad scroll through the splitter's undecided stretch (spec:AST-094 FR43)", () => { + /** A horizontal pan that takes only a trackpad's scroll, and what it did. */ + function followed() { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const seen: {state: string; x: number; velocity: number}[] = []; + pan.addTransitionListener((recognizer, _from, to) => { + seen.push({ + state: to, + x: recognizer.translation().x, + velocity: recognizer.velocity().x, + }); + }); + detachers.push(host.attach(box, pan)); + return {box, seen}; + } + + /** Each wheel 16 ms apart from 0; returns the time after the last. */ + function stream(box: HTMLElement, deltas: number[]): number { + let time = 0; + for (const dx of deltas) { + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + return time; + } + + it("keeps a trackpad pan moving at the fingers' speed while the splitter is undecided, and lets go at the lift still moving at that speed: no stall", () => { + const {box, seen} = followed(); + // Four steady deltas, then four shrinking ones (the lift is read at the + // fourth), then momentum. + const end = stream(box, [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6]); + clock.runDue(end + 500); + const changed = seen.filter(entry => entry.state === 'changed'); + // The fingers' travel at their last move is -30 (40 px past the 10 px + // hysteresis); through the shrinking deltas the pan moves on 10 px a + // wheel at their speed. + expect(changed.map(entry => entry.x).slice(-4)).toEqual([ + -40, -50, -60, -70, + ]); + const ended = seen.filter(entry => entry.state === 'ended'); + expect(ended).toHaveLength(1); + expect(ended[0].x).toBeCloseTo(-70, 6); + // Released at the fingers' speed, 10 px a 16 ms wheel. + expect(ended[0].velocity).toBeCloseTo(-625, -1); + // The momentum after the lift moves nothing. + expect( + seen.slice(seen.indexOf(ended[0]) + 1).map(entry => entry.state), + ).toEqual(['possible']); + }); + + it('cancels nothing when focus moves between elements mid-swipe: only the window’s own blur ends a trackpad pan', () => { + const {box, field} = mount( + '
', + ); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + for (const time of [0, 16, 32]) { + wheel(box, {trackpad: true, dx: 10, time}); + } + expect(states.at(-1)).toBe('changed'); + field.dispatchEvent(new FocusEvent('focus')); + field.dispatchEvent(new FocusEvent('blur')); + expect(states).not.toContain('cancelled'); + expect( + wheel(box, {trackpad: true, dx: 10, time: 48}).defaultPrevented, + ).toBe(true); + expect(states.at(-1)).toBe('changed'); + }); + + it('keeps the momentum of a pan its lift released latched through a blur: the blur cancels nothing, and the momentum still moves no surface (spec:AST-094 FR43)', () => { + const {box, seen} = followed(); + const deltas = [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6]; + deltas.forEach((dx, i) => wheel(box, {trackpad: true, dx, time: i * 16})); + expect(seen.some(entry => entry.state === 'ended')).toBe(true); + window.dispatchEvent(new Event('blur')); + expect(seen.map(entry => entry.state)).not.toContain('cancelled'); + expect( + wheel(box, {trackpad: true, dx: 5.5, time: 12 * 16}).defaultPrevented, + ).toBe(true); + }); + + it('keeps the momentum after the lift prevented, latched to the pan it released: momentum never moves a surface or continues a gesture (spec:AST-094 FR43)', () => { + const {box, seen} = followed(); + // Four steady wheels, then sixteen shrinking ones 16 ms apart: the lift + // is read at the fourth shrinking wheel (112 ms), and twelve momentum + // wheels follow it. + const deltas = [ + 10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6, 5.5, 5, 4.5, 4, 3.5, 3, + 2.5, 2, + ]; + const events = deltas.map((dx, i) => + wheel(box, {trackpad: true, dx, time: i * 16}), + ); + const lift = seen.findIndex(entry => entry.state === 'ended'); + expect(lift).toBeGreaterThan(-1); + // Every wheel was the pan's: none after the lift reached the page. + expect(events.slice(8).map(event => event.defaultPrevented)).toEqual( + Array.from({length: 12}, () => true), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + // The momentum moved nothing and began nothing. + expect(seen.slice(lift + 1).map(entry => entry.state)).toEqual([ + 'possible', + ]); + // The fingers' next swipe, past the momentum's 50 ms pause, is a new + // gesture the pan takes. + const next = [0, 16, 32].map(t => + wheel(box, {trackpad: true, dx: 10, time: 20 * 16 + 60 + t}), + ); + expect(next.every(event => event.defaultPrevented)).toBe(true); + expect(seen.filter(entry => entry.state === 'began')).toHaveLength(2); + }); + + it("lets the momentum's latch go with the stream's end window: a wheel after it is decided afresh (spec:AST-094 FR43)", () => { + const {box, seen} = followed(); + const deltas = [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6]; + for (const [i, dx] of deltas.entries()) { + expect( + wheel(box, {trackpad: true, dx, time: i * 16}).defaultPrevented, + ).toBe(true); + } + expect(seen.some(entry => entry.state === 'ended')).toBe(true); + const outside = document.createElement('div'); + document.body.append(outside); + try { + // Within the window the momentum is still the pan's. + clock.runDue(11 * 16 + 219); + expect( + wheel(outside, {trackpad: true, dx: 5.5, time: 11 * 16 + 30}) + .defaultPrevented, + ).toBe(true); + // 220 ms after its last wheel the stream has ended: the next wheel + // there is the page's. + clock.runDue(11 * 16 + 30 + 220); + expect( + wheel(outside, {trackpad: true, dx: 5, time: 11 * 16 + 260}) + .defaultPrevented, + ).toBe(false); + } finally { + outside.remove(); + } + }); + + it('lets the momentum’s latch go when the pan it released is detached: the next wheels are the page’s (spec:AST-094 FR43, FR24)', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + allowedScrollTypes: ['continuous'], + }); + const detach = host.attach(box, pan); + const deltas = [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6]; + const events = deltas.map((dx, i) => + wheel(box, {trackpad: true, dx, time: i * 16}), + ); + expect(events.every(event => event.defaultPrevented)).toBe(true); + detach(); + const outside = document.createElement('div'); + document.body.append(outside); + try { + expect( + wheel(outside, {trackpad: true, dx: 5.5, time: 12 * 16}) + .defaultPrevented, + ).toBe(false); + } finally { + outside.remove(); + } + }); + + it('absorbs what the undecided stretch showed when the fingers carry on: never a step back, and no extra travel', () => { + const {box, seen} = followed(); + const deltas = [10, 10, 10, 10, 9.5, 9, 10, 10, 10, 10, 10, 10]; + const end = stream(box, deltas); + clock.runDue(end + 500); + const moves = seen.filter( + entry => entry.state === 'changed' || entry.state === 'ended', + ); + for (const [i, entry] of moves.entries()) { + if (i > 0) { + expect(entry.x).toBeLessThanOrEqual(moves[i - 1].x); + } + } + // The fingers' whole travel, past the 10 px hysteresis. + const travel = deltas.reduce((sum, delta) => sum + delta, 0) - 10; + expect(moves.at(-1)?.x).toBeCloseTo(-travel, 6); + }); + + it("never moves the pan past what the fingers' last speed projects through a long undecided stretch", () => { + const {box, seen} = followed(); + // Deltas that shrink too slowly to read a lift for a while. + const shrinking = [9.9, 9.8, 9.7, 9.6, 9.5, 9.4]; + stream(box, [10, 10, 10, 10, ...shrinking]); + const changed = seen.filter(entry => entry.state === 'changed'); + expect(changed.map(entry => entry.x).slice(-shrinking.length)).toEqual( + shrinking.map((_, i) => -30 - 10 * (i + 1)), + ); + }); +}); + +describe("the splitter's gestures on timed trackpad streams (spec:AST-094 FR43, DEC-24)", () => { + /** A pan that takes a trackpad's scroll, and how many gestures it began. */ + function counted(direction: 'horizontal' | 'vertical') { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({ + direction, + allowedPointerTypes: [], + allowedScrollTypes: ['continuous'], + }); + const states = statesOf(pan); + detachers.push(host.attach(box, pan)); + return {box, began: () => states.filter(state => state === 'began').length}; + } + + /** Fingers, then a shrinking run whose fourth delta reads the lift. */ + const SWIPE = [12, 12, 12, 12, 11, 10, 9, 8]; + + it("counts ten swipes that each start in the last one's momentum as ten gestures", () => { + const {box, began} = counted('horizontal'); + let time = 0; + for (let swipe = 0; swipe < 10; swipe++) { + // The next swipe's first delta rises against the decay. + for (const dx of [...SWIPE, 7, 6, 5]) { + clock.runDue(time); + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + } + clock.runDue(time + 500); + expect(began()).toBe(10); + }); + + it('counts ten swipes whose momentum runs out as ten gestures', () => { + const {box, began} = counted('horizontal'); + let time = 0; + for (let swipe = 0; swipe < 10; swipe++) { + for (const dx of [...SWIPE, 7, 6, 5, 4, 3, 2, 1]) { + clock.runDue(time); + wheel(box, {trackpad: true, dx, time}); + time += 16; + } + time += 400; + } + clock.runDue(time + 500); + expect(began()).toBe(10); + }); + + it('counts a slow drag with 150 ms pauses as one gesture', () => { + const {box, began} = counted('vertical'); + let time = 0; + for (let stretch = 0; stretch < 6; stretch++) { + for (let step = 0; step < 5; step++) { + clock.runDue(time); + wheel(box, {trackpad: true, dy: 4, time}); + time += 16; + } + // A 150 ms pause from the stretch's last wheel to the next one's first. + time += 150 - 16; + } + clock.runDue(time + 500); + expect(began()).toBe(1); + }); +}); diff --git a/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts b/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts new file mode 100644 index 0000000000000..340381ec3269a --- /dev/null +++ b/packages/core/src/gestures/__tests__/wheelSegmenter.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheelSegmenter.test.ts + * @input A WheelSegmenter fed deltas along one axis with their times + * @output Tests for how a wheel stream splits into gestures and where a + * trackpad's fingers lift (spec:AST-094 FR43) + * @position Unit tests of wheelSegmenter.ts + */ + +import {describe, expect, it} from 'vitest'; +import {WheelSegmenter} from '../wheelSegmenter'; + +/** Push `deltas` 16 ms apart from `from`, with a 180 ms finger pause. */ +function feed(segmenter: WheelSegmenter, deltas: number[], from = 0) { + return deltas.map((delta, i) => segmenter.push(from + i * 16, delta, 180)); +} + +describe('WheelSegmenter (spec:AST-094 FR43)', () => { + it('starts a gesture at the first wheel, and again after a finger pause of idleMs', () => { + const segmenter = new WheelSegmenter(); + expect(segmenter.push(0, 10, 180).boundary).toBe('first'); + expect(segmenter.push(16, 10, 180).boundary).toBeNull(); + expect(segmenter.push(195, 10, 180).boundary).toBeNull(); + const after = segmenter.push(375, 10, 180); + expect(after.boundary).toBe('idle'); + expect(after.gesture).toBe(2); + }); + + it('reads the lift where a run of shrinking deltas has lost a fifth of its size, and marks the shrinking ones before it as held', () => { + const seen = feed( + new WheelSegmenter(), + [10, 10, 10, 10, 9.5, 9, 8.5, 8, 7.5], + ); + expect(seen.map(segment => segment.liftNow)).toEqual([ + false, + false, + false, + false, + false, + false, + false, + true, + false, + ]); + expect(seen.map(segment => segment.decaying)).toEqual([ + false, + false, + false, + false, + true, + true, + true, + false, + false, + ]); + expect(seen[7].phase).toBe('momentum'); + expect(seen[8].boundary).toBeNull(); + }); + + it('keeps a slow finger, whose deltas shrink and grow again, in its finger phase', () => { + const seen = feed(new WheelSegmenter(), [10, 10, 10, 9, 8, 9, 10, 9, 8, 9]); + expect(seen.some(segment => segment.liftNow)).toBe(false); + expect(seen.every(segment => segment.phase === 'finger')).toBe(true); + }); + + for (const [name, next, at] of [ + ['reversal', -5, 16], + ['gap', 7, 50], + ['jump', 20, 16], + ] as const) { + it(`starts a new gesture in momentum at a ${name}: momentum never ${name === 'gap' ? 'pauses' : name === 'jump' ? 'grows' : 'reverses'}`, () => { + const segmenter = new WheelSegmenter(); + const seen = feed(segmenter, [10, 10, 10, 10, 9.5, 9, 8.5, 8]); + expect(seen[7].liftNow).toBe(true); + const segment = segmenter.push(7 * 16 + at, next, 180); + expect(segment.boundary).toBe(name); + expect(segment.phase).toBe('finger'); + expect(segment.gesture).toBe(2); + }); + } + + it('starts a gesture at the next wheel once the last one ended on its own', () => { + const segmenter = new WheelSegmenter(); + feed(segmenter, [10, 10]); + segmenter.end(); + expect(segmenter.push(40, 10, 180).boundary).toBe('idle'); + }); +}); diff --git a/packages/core/src/gestures/nativeScroll.ts b/packages/core/src/gestures/nativeScroll.ts index 0ff1c5440bb45..bea018ef495e6 100644 --- a/packages/core/src/gestures/nativeScroll.ts +++ b/packages/core/src/gestures/nativeScroll.ts @@ -4,7 +4,8 @@ * @file nativeScroll.ts * @input An element a touch landed on * @output Exports createNativeScrollProbe (the NativeScrollProbe a host gives - * each touch) and parseTouchAction + * each touch, and each wheel gesture without the touch-action narrowing) + * and parseTouchAction * @position DOM half of the scroll coordination in packages/core/src/gestures * * Answers one question for a recognizer without letting it touch the DOM: @@ -82,7 +83,7 @@ interface Chain { readonly allowsPan: boolean; } -function buildChain(target: Element): Chain { +function buildChain(target: Element, readsTouchAction: boolean): Chain { const doc = target.ownerDocument; const view = doc.defaultView; if (view == null) { @@ -103,9 +104,11 @@ function buildChain(target: Element): Chain { continue; } const style = view.getComputedStyle(node); - const pans = parseTouchAction(style.touchAction || 'auto'); - allowX &&= pans.x; - allowY &&= pans.y; + if (readsTouchAction) { + const pans = parseTouchAction(style.touchAction || 'auto'); + allowX &&= pans.x; + allowY &&= pans.y; + } const isRoot = node === root || node === doc.documentElement; if (isRoot) { reachedRoot = true; @@ -196,7 +199,10 @@ function canMoveY(element: Element, dy: number): boolean { element.scrollHeight - element.clientHeight - EDGE_TOLERANCE + 0.5; } -function canMoveX(entry: ChainEntry, dx: number): boolean { +function canMoveX( + entry: Pick, + dx: number, +): boolean { const {element} = entry; const overflow = element.scrollWidth - element.clientWidth; if (overflow <= EDGE_TOLERANCE) { @@ -210,13 +216,47 @@ function canMoveX(entry: ChainEntry, dx: number): boolean { : element.scrollLeft < max - EDGE_TOLERANCE + 0.5; } -/** A probe of the native scrollers under `target`. */ -export function createNativeScrollProbe(target: unknown): NativeScrollProbe { +/** + * Whether `element` itself scrolls natively now for a finger moving by + * (dx, dy). It uses the probe's own test of each scroller: overflow that + * scrolls on that axis, and room past the edge tolerance. The position must + * also be short of the end it moves toward, a right-to-left range included. + * A wheel moves the content the other way, so pass its deltas negated. + */ +export function canElementScroll( + element: Element, + dx: number, + dy: number, +): boolean { + const view = element.ownerDocument.defaultView; + if (view == null) { + return false; + } + const style = view.getComputedStyle(element); + if (dy !== 0 && SCROLLABLE.has(style.overflowY) && canMoveY(element, dy)) { + return true; + } + return ( + dx !== 0 && + SCROLLABLE.has(style.overflowX) && + canMoveX({element, isRtl: isRtlElement(element as HTMLElement)}, dx) + ); +} + +/** + * A probe of the native scrollers under `target`. A wheel scrolls whatever + * `touch-action` says, which narrows only a finger's pan: a wheel's probe + * passes `readsTouchAction: false`. + */ +export function createNativeScrollProbe( + target: unknown, + {readsTouchAction = true}: {readsTouchAction?: boolean} = {}, +): NativeScrollProbe { let chain: Chain | null = null; const getChain = (): ChainEntry[] => { chain ??= target instanceof Element - ? buildChain(target) + ? buildChain(target, readsTouchAction) : {entries: [], allowsPan: false}; return chain.entries; }; diff --git a/packages/core/src/gestures/recognizerInternals.ts b/packages/core/src/gestures/recognizerInternals.ts index dd9a20547cf03..faa91898e0136 100644 --- a/packages/core/src/gestures/recognizerInternals.ts +++ b/packages/core/src/gestures/recognizerInternals.ts @@ -20,7 +20,12 @@ import type {GestureArena} from './GestureArena'; import type {GestureRecognizer} from './GestureRecognizer'; -import type {GestureEventContext, GestureState, GestureTouch} from './types'; +import type { + GestureEventContext, + GestureState, + GestureTouch, + Point, +} from './types'; /** * The kinds of recognizer the host and touch-action treat specially. Each of @@ -28,12 +33,14 @@ import type {GestureEventContext, GestureState, GestureTouch} from './types'; * never imports a recognizer class: a page ships only the recognizers it * constructs. */ -export type RecognizerFamily = 'tap' | 'longPress' | 'pan'; +export type RecognizerFamily = 'tap' | 'longPress' | 'pan' | 'pinch'; /** The members of a recognizer only the gesture system calls. */ export interface RecognizerInternals { /** The recognizer's family, when the host treats it specially. */ readonly family?: RecognizerFamily; + /** A pan's direction rule for a translation, `axisRatio` included. */ + classify?(translation: Point): 'begin' | 'fail' | 'wait'; arena: GestureArena | null; pending: 'began' | 'ended' | null; readonly seenTouchIds: Set; diff --git a/packages/core/src/gestures/types.ts b/packages/core/src/gestures/types.ts index d50f5c1391a9c..936707dd59b22 100644 --- a/packages/core/src/gestures/types.ts +++ b/packages/core/src/gestures/types.ts @@ -21,6 +21,14 @@ export type GestureState = /** What made the contact. A pen on an iPad is `pen`, not `touch`. */ export type PointerKind = 'touch' | 'mouse' | 'pen'; +/** + * Scroll input a recognizer can take, as UIKit's `UIScrollTypeMask`: a + * trackpad's two-finger scroll or pinch (`continuous`), or a mouse wheel's + * notches (`discrete`). A pan takes only a trackpad's; a pinch takes a + * ctrl-wheel of either. + */ +export type ScrollType = 'continuous' | 'discrete'; + export interface Point { readonly x: number; readonly y: number; @@ -85,6 +93,8 @@ export interface GestureTouch { readonly target: unknown; /** The native scrollers under the contact, when the host knows them. */ readonly scroll: NativeScrollProbe | null; + /** Set for a wheel's or a trackpad's scroll input, which is no contact. */ + readonly scrollType?: ScrollType; /** The browser took this contact for its own pan or zoom. */ takenByBrowser: boolean; } @@ -104,6 +114,8 @@ export interface TouchBeganInput extends TouchInput { readonly pointerType: PointerKind; readonly target: unknown; readonly scroll?: NativeScrollProbe | null; + /** Set for a wheel's or a trackpad's scroll input. */ + readonly scrollType?: ScrollType; } /** What a recognizer is told alongside the touches that changed. */ diff --git a/packages/core/src/gestures/wheelSegmenter.ts b/packages/core/src/gestures/wheelSegmenter.ts new file mode 100644 index 0000000000000..1fec9701d0c31 --- /dev/null +++ b/packages/core/src/gestures/wheelSegmenter.ts @@ -0,0 +1,114 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file wheelSegmenter.ts + * @input A wheel stream's deltas along one axis, with their event times + * @output Exports WheelSegmenter: splits the stream into gestures and reads + * a trackpad's lift (the onset of momentum) + * @position Read by GestureHost.ts's wheel input: the web reports no + * trackpad contact or phase, so both are inferred. + */ + +export type WheelBoundary = 'first' | 'idle' | 'reversal' | 'jump' | 'gap'; +export type WheelPhase = 'finger' | 'momentum'; + +export interface WheelSegment { + /** Why this event starts a new gesture, or null when it continues one. */ + readonly boundary: WheelBoundary | null; + readonly phase: WheelPhase; + /** The gesture's number, counting from 1. */ + readonly gesture: number; + /** This event is where the fingers lifted (momentum begins with it). */ + readonly liftNow: boolean; + /** This event shrank, so it may be momentum: hold it until known. */ + readonly decaying: boolean; +} + +/** Momentum arrives at frame rate: a pause this long ends it. */ +const MOMENTUM_GAP_MS = 50; +/** Shrinking deltas in a row before the lift is read. */ +const LIFT_RUN = 4; +/** And how much of its size the run has lost by then. */ +const LIFT_DECAY = 0.8; + +/** + * A trackpad gesture has a finger phase and, after a fling, a momentum phase. + * In the finger phase, deltas come while the fingers move, with pauses when + * they move slowly. In the momentum phase, deltas come at frame rate and + * shrink smoothly. The lift is the start of a run of shrinking deltas that + * has lost a fifth of its size. In the finger phase, a pause of `idleMs` + * ends the gesture. In the momentum phase, a reversal, a jump up against the + * decay, or a pause of 50 ms starts a new gesture. Momentum never pauses, + * reverses or grows. + */ +export class WheelSegmenter { + gesture = 0; + phase: WheelPhase = 'finger'; + private last = -Infinity; + private previous = 0; + private sign = 0; + private count = 0; + private run = 0; + private runStart = 0; + + push(time: number, delta: number, idleMs: number): WheelSegment { + const size = Math.abs(delta); + const sign = Math.sign(delta); + const gap = time - this.last; + let boundary: WheelBoundary | null = null; + if (this.count === 0) { + boundary = this.gesture === 0 ? 'first' : 'idle'; + } else if (this.phase === 'finger' && gap >= idleMs) { + boundary = 'idle'; + } else if (this.phase === 'momentum') { + if (sign !== 0 && this.sign !== 0 && sign !== this.sign) { + boundary = 'reversal'; + } else if (gap >= MOMENTUM_GAP_MS) { + boundary = 'gap'; + } else if (size >= 2 && size > this.previous * 1.3 + 0.5) { + boundary = 'jump'; + } + } + if (boundary != null) { + this.gesture += 1; + this.phase = 'finger'; + this.count = 0; + this.run = 0; + } + let liftNow = false; + let decaying = false; + if (this.phase === 'finger' && this.count >= 3 && size > 0) { + decaying = size <= this.previous * 0.995 && size >= this.previous * 0.5; + if (decaying) { + if (this.run === 0) { + this.runStart = this.previous; + } + this.run += 1; + } else { + this.run = 0; + } + if (this.run >= LIFT_RUN && size <= this.runStart * LIFT_DECAY) { + this.phase = 'momentum'; + liftNow = true; + } + } + this.count += 1; + this.previous = size; + if (sign !== 0) { + this.sign = sign; + } + this.last = time; + return { + boundary, + phase: this.phase, + gesture: this.gesture, + liftNow, + decaying: decaying && !liftNow, + }; + } + + /** The gesture ended on its own (its timer): the next event starts one. */ + end(): void { + this.count = 0; + } +} diff --git a/packages/core/src/hooks/__tests__/pagerBundle.ts b/packages/core/src/hooks/__tests__/pagerBundle.ts new file mode 100644 index 0000000000000..4c1c93b77877d --- /dev/null +++ b/packages/core/src/hooks/__tests__/pagerBundle.ts @@ -0,0 +1,19 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file pagerBundle.ts + * @input The pager and the paging math + * @output The surface the pager's browser proofs bundle onto their fixture + * page (esbuild, IIFE global `Pager`) + * @position Test-only entry of usePager.interaction.browser.spec.ts + */ + +export {createPager, pageForRelease} from '../usePager'; +export { + pagingSettleVelocity, + solveAnimatedPageScroll, + solvePaging, +} from '../../motion/paging'; +export {rubberBand} from '../../motion/rubberBand'; +export {paging} from '../../motion/uikit/paging'; +export {VelocityTracker} from '../../gestures/VelocityTracker'; diff --git a/packages/core/src/hooks/__tests__/useAttachmentPair.test.tsx b/packages/core/src/hooks/__tests__/useAttachmentPair.test.tsx new file mode 100644 index 0000000000000..5293c3fde52a4 --- /dev/null +++ b/packages/core/src/hooks/__tests__/useAttachmentPair.test.tsx @@ -0,0 +1,106 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useAttachmentPair.test.tsx + * @input useAttachmentPair + * @output Proof that an attachment of two nodes starts once both are there + * and follows either node and its key. A commit that changes neither does + * nothing, and the attachment is released when a node goes and at unmount + * @position Testing for /packages/core/src/hooks/useAttachment.ts + */ + +import {render} from '@testing-library/react'; +import {createElement, useRef} from 'react'; +import {describe, expect, it} from 'vitest'; +import {useAttachmentPair} from '../useAttachment'; + +interface PairProps { + log: string[]; + first?: string | null; + second?: string | null; + attachKey?: unknown; +} + +/** Two nodes, each shown under an id that names it (a new id is a new node). */ +function Pair({log, first = 'a', second = 'b', attachKey = 0}: PairProps) { + const firstRef = useRef(null); + const secondRef = useRef(null); + useAttachmentPair( + firstRef, + secondRef, + (firstNode, secondNode) => { + const pair = `${firstNode.id}+${secondNode.id}`; + log.push(`attach ${pair}`); + return () => log.push(`release ${pair}`); + }, + attachKey, + ); + return createElement( + 'div', + null, + first == null + ? null + : createElement('div', {key: first, id: first, ref: firstRef}), + second == null + ? null + : createElement('span', {key: second, id: second, ref: secondRef}), + ); +} + +describe('useAttachmentPair', () => { + it('attaches once both nodes are there, and not before', () => { + const log: string[] = []; + const view = render(createElement(Pair, {log, second: null})); + expect(log).toEqual([]); + view.rerender(createElement(Pair, {log})); + expect(log).toEqual(['attach a+b']); + view.unmount(); + }); + + it('does nothing on a commit that changes neither node nor the key', () => { + const log: string[] = []; + const view = render(createElement(Pair, {log})); + view.rerender(createElement(Pair, {log})); + view.rerender(createElement(Pair, {log})); + expect(log).toEqual(['attach a+b']); + view.unmount(); + }); + + it('releases the pair and attaches the new one when either node is replaced', () => { + const log: string[] = []; + const view = render(createElement(Pair, {log})); + view.rerender(createElement(Pair, {log, first: 'c'})); + view.rerender(createElement(Pair, {log, first: 'c', second: 'd'})); + expect(log).toEqual([ + 'attach a+b', + 'release a+b', + 'attach c+b', + 'release c+b', + 'attach c+d', + ]); + view.unmount(); + }); + + it('attaches again when the key changes', () => { + const log: string[] = []; + const view = render(createElement(Pair, {log, attachKey: 'mouse'})); + view.rerender(createElement(Pair, {log, attachKey: 'touch'})); + expect(log).toEqual(['attach a+b', 'release a+b', 'attach a+b']); + view.unmount(); + }); + + it('releases when a node goes, and the last attachment at unmount', () => { + const log: string[] = []; + const view = render(createElement(Pair, {log})); + view.rerender(createElement(Pair, {log, first: null})); + expect(log).toEqual(['attach a+b', 'release a+b']); + view.rerender(createElement(Pair, {log, first: 'e'})); + view.unmount(); + expect(log).toEqual([ + 'attach a+b', + 'release a+b', + 'attach e+b', + 'release e+b', + ]); + }); +}); diff --git a/packages/core/src/hooks/__tests__/usePager.interaction.browser.spec.ts b/packages/core/src/hooks/__tests__/usePager.interaction.browser.spec.ts new file mode 100644 index 0000000000000..9d507c638559f --- /dev/null +++ b/packages/core/src/hooks/__tests__/usePager.interaction.browser.spec.ts @@ -0,0 +1,1364 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePager.interaction.browser.spec.ts + * @input The pager on a fixture page (402 px over five pages), real touch and + * wheel input, and the interaction harness, in Chromium and WebKit + * @output Proof that the pager pages like a UIKit paging scroll view on the + * compositor. It covers drags, flicks, the rubber band, goTo, the catch, a + * busy main thread, a settle with no layout or paint, and storyboards + * @position Browser proof for hooks/usePager.ts, run by `pnpm test:interaction` + */ + +import {fileURLToPath} from 'node:url'; +import { + animationStoryboard, + armHold, + busyWindowProof, + compareCurve, + countBetween, + curveOf, + drag, + flick, + markElement, + merge, + path, + releaseAnimations, + sampleCurve, + startScreencast, + timeline, + toTouchEvents, + touchDriver, + traceRendering, + trackMarker, + waitForRest, + webInputDriver, + withFinger, + writeEvidence, + type ArmedHold, + type HeldAnimation, + type TouchDriver, + type TouchScript, +} from '@astryxdesign/interaction-harness'; +import {expect, test, type Locator, type Page} from '@playwright/test'; +import {build} from 'esbuild'; +import type * as PagerBundle from './pagerBundle'; + +declare global { + interface Window { + Pager: typeof PagerBundle; + pager: ReturnType; + motionStart?: number; + liftVelocities: number[]; + liftPositions: number[]; + frameTimes: number[]; + settleTimes: number[]; + armFrames: () => void; + touchTimes: [string, number][]; + wheelTimes: number[]; + pagerLog: string[]; + cancelledMoves: boolean[]; + cancelledWheels: boolean[]; + minTrackX: number; + /** Each move's position, and where the touch started (CSS px). */ + moves: [number, number][]; + touchStart: [number, number]; + } +} + +let script = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [fileURLToPath(new URL('./pagerBundle.ts', import.meta.url))], + bundle: true, + write: false, + format: 'iife', + globalName: 'Pager', + target: 'es2022', + }); + script = result.outputFiles[0].text; +}); + +/** The phone profile's page width: one page. */ +const PAGE = 402; +const COUNT = 5; +const TOP = 80; +const HEIGHT = 300; + +async function serve( + page: Page, + {rtl = false, startIndex = 1, tall = false} = {}, +): Promise { + const pages = Array.from( + {length: COUNT}, + (_, i) => + `
${i}
`, + ).join(''); + const html = ` + +
${pages}
+ +`; + await page.route('http://pager.test/', async route => + route.fulfill({contentType: 'text/html', body: html}), + ); + await page.goto('http://pager.test/'); +} + +async function open(page: Page, options: Parameters[1] = {}) { + await serve(page, options); + return { + track: page.locator('#track'), + driver: await recordingScripts(page, await touchDriver(page)), + }; +} + +/** + * The fixture under a fine primary pointer: a real mouse and real wheel + * input, no touch emulation (`spec:AST-094` FR43). + */ +async function openFine(page: Page, options: Parameters[1] = {}) { + await serve(page, options); + return {track: page.locator('#track'), input: await webInputDriver(page)}; +} + +const log = async (page: Page) => page.evaluate(() => window.pagerLog); + +/** + * 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(() => { + played = null; +}); + +/** The touch driver, noting each script it plays for the load report. */ +async function recordingScripts( + page: Page, + driver: Awaited>, +): Promise>> { + 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: + * - touch input against the script it played, from the events' own + * timestamps (in WebKit, real dispatch times). 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. An engine may not send a move, so the count can + * differ; + * - wheel input: a pause of 220 ms between steps ends a trackpad swipe; + * - the longest gap between the frames drawn from the input or goTo on: over + * 50 ms is frame starvation; + * - when each settle ended after the lift. + */ +async function loadReport(page: Page): Promise { + const drawn = await page.evaluate(() => ({ + frames: window.frameTimes, + settles: window.settleTimes, + touches: window.touchTimes, + wheels: window.wheelTimes, + origin: performance.timeOrigin, + })); + // Some engines stamp events in epoch milliseconds (GestureHost eventTime). + 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}`, + ); + } + const wheels = drawn.wheels.map(at); + if (wheels.length > 1) { + const longest = Math.max( + ...wheels.slice(1).map((time, i) => time - wheels[i]), + ); + causes.push( + longest >= 220 + ? `real-time wheel input with a ${ms(longest)} pause between steps, which ends a trackpad swipe (220 ms)` + : `wheel steps at most ${ms(longest)} apart`, + ); + } + 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)})`, + ); + } + const lift = drawn.touches.filter(([type]) => type === 'touchend').at(-1); + for (const settle of drawn.settles) { + if (lift != null && settle > at(lift[1])) { + causes.push(`a settle ended ${ms(settle - at(lift[1]))} after the lift`); + } + } + 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}`; + } + } +}); + +// A hold or a storyboard reads a motion a round trip after it starts. On +// loaded cores, a round trip can outlast a 0.2–0.3 s motion. So these arm the +// harness's hold before the input or goTo (armHold). The page then holds the +// motion at its first frame, and the read finds it there however late it +// comes. + +/** + * Plays `script` with the motion that its lift starts held (armHold), and + * gives that motion 5 s from the end of the input. The hold is armed before + * the input, so it holds the motion at its first frame. A long input can + * outlast the hold's own 5 s. The driver paces its events on the wall clock, + * and on a loaded runner they play much longer than scripted. Then the hold + * is armed again for what is left of 5 s from the input's end. + */ +async function playHeld( + page: Page, + track: Locator, + driver: TouchDriver, + script: TouchScript, +): Promise { + const first = await armHold(page, track); + const caught = first.held().then( + held => ({held}), + () => null, + ); + await driver.play(script); + const inputEnd = Date.now(); + return { + held: async () => { + const outcome = await caught; + if (outcome != null) { + return outcome.held; + } + const again = await armHold(page, track, { + timeout: Math.max(0, inputEnd + 5_000 - Date.now()), + }); + return again.held().catch(() => { + throw new Error( + 'no motion under the track started within 5 s of the input ending', + ); + }); + }, + }; +} + +/** Calls `goTo(index)` with the motion it starts held at its first frame. */ +const goToHeld = async (page: Page, track: Locator, index: number) => { + const hold = await armHold(page, track); + await page.evaluate(next => { + window.armFrames(); + window.pager.goTo(next); + }, index); + return hold.held(); +}; + +/** + * Whether a selection that starts on the track's text is refused now. Where + * iOS WebKit does not run, the shared selection lock cancels `selectstart` + * and writes no style. Where it runs, the lock is `user-select: none`, which + * WebKit reports only prefixed. `spec:AST-094` FR41. + */ +const refusesSelection = (el: Element) => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + (el.firstElementChild ?? el).dispatchEvent(event); + const style = getComputedStyle(el) as CSSStyleDeclaration & { + webkitUserSelect?: string; + }; + const userSelect = style.userSelect || (style.webkitUserSelect ?? ''); + return event.defaultPrevented || userSelect === 'none'; +}; + +/** The track's presented x (from the `translate` the pager drives). */ +const trackX = async (track: Locator) => + track.evaluate(el => { + const value = getComputedStyle(el).translate; + return value === 'none' ? 0 : parseFloat(value); + }); + +/** + * The pager's release velocity for a script: the shared tracker read as a + * scroll view reads it (a pan's `scrollVelocity()`), x only. + */ +async function releaseVelocities( + page: Page, + steps: ReturnType, +) { + return page.evaluate(events => { + const tracker = new window.Pager.VelocityTracker(); + let current = 0; + for (const event of events) { + if (event.type === 'touchEnd' || event.type === 'touchCancel') { + // Read at the lift's time, as the recognizer reads it (the + // stillness reset). + current = tracker.scrollVelocity(event.t).x; + break; + } + const finger = event.changed[0]; + tracker.add(finger.x, finger.y, event.t); + if (event.type === 'touchMove') { + current = tracker.scrollVelocity(event.t).x; + } + } + return {current}; + }, steps); +} + +/** + * UIKit's paging settle for a release, from the lift velocity that the + * pager's pan read. The page records that velocity as the pan reads it; in + * WebKit, it comes from the touches' real dispatch times. The paging solver + * runs from where the settle starts onto the page the pager chose. Returns + * the content offset at each of `times` (ms into the settle). + */ +async function releaseModel( + page: Page, + {from, to, times}: {from: number; to: number; times: ReadonlyArray}, +) { + return page.evaluate( + input => { + const lift = window.liftVelocities[window.liftVelocities.length - 1]; + // The pan's x velocity is the finger's; the content moves the other way. + const curve = window.Pager.solvePaging({ + from: input.from, + velocity: window.Pager.pagingSettleVelocity(-lift), + to: input.to, + }); + return {lift, offsets: input.times.map(t => curve.at(t / 1000))}; + }, + {from, to, times}, + ); +} + +/** + * A slow 300 px drag `degrees` up from leftward, at 300 px/s along it. At 35° + * and 45°, its horizontal part passes half a page, so the pager that takes it + * settles on the next page. + */ +function angled(degrees: number) { + const rad = (degrees * Math.PI) / 180; + const from = {x: 340, y: TOP + HEIGHT / 2}; + return drag( + from, + {x: from.x - 300 * Math.cos(rad), y: from.y - 300 * Math.sin(rad)}, + {durationMs: 1000}, + ); +} + +for (const degrees of [35, 45]) { + test(`in a scrolling page, a ${degrees}° drag is the pager's: it pages, its moves are cancelled from the pan's start at 10 pt, and the page stays at scrollY 0`, async ({ + page, + }) => { + const {track, driver} = await open(page, {tall: true}); + await driver.play(angled(degrees)); + await waitForRest(track); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + const {cancelled, moves, start} = await page.evaluate(() => ({ + cancelled: window.cancelledMoves, + moves: window.moves, + start: window.touchStart, + })); + expect(cancelled.length).toBeGreaterThan(3); + // Nothing is claimed before the pan begins at its 10 pt hysteresis, and + // every move from the first one past it is. Chromium holds back moves + // inside its own touch slop, so its first move may already be past 10 pt. + // Mobile Safari claims a slow drag for the page at about 10–11 px. So on + // the iOS 26.2 simulator, a drag steeper than about 27° scrolls the page: + // 25° pages, while 30° and 35° scroll. UIKit pages up to 45°. + const past = moves.findIndex( + ([x, y]) => + Math.max(Math.abs(x - start[0]), Math.abs(y - start[1])) >= 10, + ); + expect(past).toBeGreaterThanOrEqual(0); + expect(cancelled.slice(0, past).some(Boolean)).toBe(false); + expect(cancelled.slice(past).every(Boolean)).toBe(true); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); +} + +test("in a scrolling page, a 50° drag is the page's: the page scrolls, the pager stays and no move is cancelled", async ({ + page, + browserName, +}) => { + const {track, driver} = await open(page, {tall: true}); + await driver.play(angled(50)); + await page.waitForTimeout(400); + expect(await page.evaluate(() => window.pager.index)).toBe(1); + expect(await trackX(track)).toBeCloseTo(-PAGE, 1); + expect((await page.evaluate(() => window.cancelledMoves)).some(Boolean)).toBe( + false, + ); + if (browserName === 'chromium') { + // Injected touches scroll natively only in Chromium (harness README). + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(100); + } +}); + +/** + * The track's settle as a curve of translateX samples (ms, px), from a motion + * held at its first frame (an armHold armed before the input). + */ +async function heldSettle(hold: ArmedHold, track: Locator) { + const held = await hold.held(); + expect(held.length).toBeGreaterThan(0); + return curveOf( + await sampleCurve( + track, + timeline(Math.max(...held.map(a => a.curveEndMs))), + ), + 'translateX', + ); +} + +/** solvePaging from `start` at `velocity` onto page 2, at 60 Hz. */ +async function pagingOracle(page: Page, start: number, velocity: number) { + return page.evaluate( + ({from, v}) => { + const curve = window.Pager.solvePaging({from, velocity: v, to: 804}); + return Array.from({length: 60}, (_, i) => curve.at(i / 60)); + }, + {from: start, v: velocity}, + ); +} + +const at = (oracle: number[], t: number) => + oracle[Math.min(oracle.length - 1, Math.round((t * 60) / 1000))]; + +test("a slow drag past half a page, well under the 225 px/s floor, settles on the next page from rest on UIKit's paging curve", async ({ + page, +}) => { + const {track, driver} = await open(page); + // 60 % of a page at 100 px/s, lifted while moving: under the floor in + // either engine, so the settle starts at rest. + const script = drag( + {x: 320, y: 200}, + {x: 320 - 0.6 * PAGE, y: 200}, + {durationMs: 2400, restMs: 0}, + ); + const hold = await playHeld(page, track, driver, script); + const samples = await heldSettle(hold, track); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + const oracle = await pagingOracle(page, -samples[0].value, 0); + const result = compareCurve(samples, t => -at(oracle, t), 1); + await writeEvidence( + 'pager/slow-drag-settle', + {'curve.json': JSON.stringify({samples, result}, null, 2)}, + test.info(), + ); + expect(result.maxError).toBeLessThan(1); + await releaseAnimations(page); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); +}); + +// UIKit keeps the release velocity through a silent rest. Recorded on iOS +// 26.2: 600 pt/s, then up to 397 ms with no touch event, kept 0.55 to +// 0.61 pt/ms. The shared release filter reads 0 after 40 ms of silence +// instead (spec:AST-100 FR2), and the pager reads the filter. A held finger +// sends no events here either. +test('a drag held still for 200 ms before the lift settles from rest: the release filter reads 0 after 40 ms of silence (spec:AST-100 FR2)', async ({ + page, +}) => { + const {track, driver} = await open(page); + // 60 % of a page at 600 px/s (far above the floor in either engine), then + // held still for 200 ms. + const script = drag( + {x: 320, y: 200}, + {x: 320 - 0.6 * PAGE, y: 200}, + {durationMs: 402, restMs: 200}, + ); + const velocities = await releaseVelocities(page, toTouchEvents(script)); + expect(velocities.current).toBe(0); + const hold = await playHeld(page, track, driver, script); + const samples = await heldSettle(hold, track); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + const rest = await pagingOracle(page, -samples[0].value, 0); + const result = compareCurve(samples, t => -at(rest, t), 1); + await writeEvidence( + 'pager/held-drag-settle', + {'curve.json': JSON.stringify({samples, result}, null, 2)}, + test.info(), + ); + expect(result.maxError).toBeLessThan(1); + await releaseAnimations(page); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); +}); + +test('callbacks: the new page once the settle is moving, the settle when the track rests', async ({ + page, +}) => { + const {track, driver} = await open(page); + // 60 % of a page at 600 px/s, lifted while moving. + await driver.play( + drag( + {x: 320, y: 200}, + {x: 320 - 0.6 * PAGE, y: 200}, + {durationMs: 402, restMs: 0}, + ), + ); + // Reported a couple of frames into the settle, so the owner's render of + // the new page cannot hold up its hand-off to the compositor. + await expect + .poll(async () => (await log(page))[0]) + .toBe('index:2:drag:moving'); + await waitForRest(track); + await page.waitForTimeout(50); + expect(await log(page)).toEqual(['index:2:drag:moving', 'settle:2:1']); +}); + +test('a flick pages exactly one page, and the settle starts from the release velocity', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'WebKit sees real dispatch times, not the scripted ones the velocity oracle replays (harness README)', + ); + const {track, driver} = await open(page); + const throwScript = flick( + {x: 320, y: 200}, + {velocity: {x: -1500, y: 0}, durationMs: 80, hz: 120}, + ); + const velocities = await releaseVelocities(page, toTouchEvents(throwScript)); + const hold = await playHeld(page, track, driver, throwScript); + const held = await hold.held(); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + const samples = curveOf( + await sampleCurve( + track, + timeline(Math.max(...held.map(a => a.curveEndMs))), + ), + 'translateX', + ); + const from = -samples[0].value; + const oracle = await page.evaluate( + ({start, current}) => { + // The pan's x velocity is the finger's; the content moves the other way. + const settle = window.Pager.pagingSettleVelocity(-current); + const curve = window.Pager.solvePaging({ + from: start, + velocity: settle, + to: 804, + }); + return { + settle, + values: Array.from({length: 60}, (_, i) => curve.at(i / 60)), + }; + }, + {start: from, ...velocities}, + ); + expect(oracle.settle).toBeGreaterThan(900); + const result = compareCurve( + samples, + t => -oracle.values[Math.min(59, Math.round((t * 60) / 1000))], + 1.5, + ); + await writeEvidence( + 'pager/flick-settle', + { + 'curve.json': JSON.stringify( + {samples, settle: oracle.settle, result}, + null, + 2, + ), + }, + test.info(), + ); + expect(result.maxError).toBeLessThan(1.5); + await releaseAnimations(page); + await waitForRest(track); + // One page, however hard the throw. + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); +}); + +test('past the first page the track rubber-bands with d = the page width, then returns', async ({ + page, +}) => { + const {track, driver} = await open(page, {startIndex: 0}); + const finger = 290; + let shown = NaN; + const moves = toTouchEvents( + drag( + {x: 60, y: 200}, + {x: 60 + finger + 10, y: 200}, + {durationMs: 600, restMs: 120}, + ), + ).filter(e => e.type === 'touchMove').length; + let seen = 0; + await driver.play( + drag( + {x: 60, y: 200}, + {x: 60 + finger + 10, y: 200}, + {durationMs: 600, restMs: 120}, + ), + { + onEvent: async step => { + if (step.type === 'touchMove' && ++seen === moves) { + await page.evaluate( + async () => + new Promise(r => + requestAnimationFrame(() => requestAnimationFrame(r)), + ), + ); + shown = await trackX(track); + } + }, + }, + ); + const expected = await page.evaluate( + f => window.Pager.rubberBand(f, 402), + finger, + ); + // 10 px of the finger went to the pan's threshold. + expect(shown).toBeGreaterThan(expected - 3); + expect(shown).toBeLessThan(expected + 3); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(0, 0); + expect(await log(page)).toEqual(['settle:0:0']); +}); + +// spec:AST-094 FR37: a release whose velocity points back toward the start +// cancels, whatever the travel (UIKit takes the nearest page). +test('a drag 70 % of the way to the next page that turns back and lifts while moving back settles on its start page', async ({ + page, +}) => { + const {track, driver} = await open(page); + const x0 = 320; + // Forward 70 % of a page, then back 30 px in the last 180 ms, lifted + // while moving back. + await driver.play( + path( + p => + p <= 0.85 + ? {x: x0 - (0.7 * PAGE * p) / 0.85, y: 200} + : {x: x0 - 0.7 * PAGE + (30 * (p - 0.85)) / 0.15, y: 200}, + {durationMs: 1200, restMs: 0}, + ), + ); + await releaseAnimations(page); + await waitForRest(track); + expect(await page.evaluate(() => window.pager.index)).toBe(1); + expect(await trackX(track)).toBeCloseTo(-PAGE, 0); + expect(await log(page)).toEqual(['settle:1:1']); +}); + +// spec:AST-094 FR6: a new target keeps the current velocity. +test('a goTo that retargets a running goTo keeps its velocity: the new curve starts at the speed the old one had', async ({ + page, +}) => { + const {track} = await open(page, {startIndex: 0}); + // The first goTo starts in the task that reads it: a round trip between the + // two can outlast its 0.3 s on two loaded cores. + const {before, after, firedAt} = await page.evaluate(async () => { + const el = document.getElementById('track') as HTMLElement; + window.armFrames(); + window.pager.goTo(3); + // The keyframes' slope at `time` (ms into the animation), px/s: the + // animator plays a curve as linear segments between samples. + const slope = (animation: Animation, time: number) => { + const effect = animation.effect as KeyframeEffect; + const frames = effect.getKeyframes(); + const duration = Number(effect.getComputedTiming().duration); + const at = Math.min(Math.max(time / duration, 0), 1); + for (let i = 1; i < frames.length; i++) { + const a = frames[i - 1]; + const b = frames[i]; + if (at <= b.computedOffset || i === frames.length - 1) { + const span = (b.computedOffset - a.computedOffset) * duration; + return span > 0 + ? ((parseFloat(String(b.translate)) - + parseFloat(String(a.translate))) / + span) * + 1000 + : 0; + } + } + return 0; + }; + const running = el.getAnimations()[0]; + // Once the curve has started (its start resolves in its first frame), the + // retarget comes 100 ms into it on the page's clock. This task waits for + // it, so no other task, such as a timer's queue or a slow frame, can hold + // it back. A loaded runner held a 100 ms timer past the curve's end. The + // old curve's slope is read at the time the retarget comes, on the clock + // that the animator's model runs on. + await running.ready; + const start = Number(running.startTime); + while (performance.now() < start + 100) { + // The curve runs on the compositor meanwhile. + } + const firedAt = performance.now() - start; + const speed = slope(running, firedAt); + window.pager.goTo(4); + const next = el.getAnimations().find(a => a !== running); + return { + before: speed, + after: next ? slope(next, 0) : Number.NaN, + firedAt, + }; + }); + expect( + Math.abs(before), + `the retarget came ${firedAt.toFixed(1)} ms into the 300 ms curve`, + ).toBeGreaterThan(1000); + expect(after / before).toBeGreaterThan(0.85); + expect(after / before).toBeLessThan(1.15); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(-4 * PAGE, 0); +}); + +test('goTo plays setContentOffset(animated:): 0.3 s on a sinusoidal ease', async ({ + page, +}) => { + const {track} = await open(page, {startIndex: 0}); + const held = await goToHeld(page, track, 3); + // Reported at the call; held, the motion cannot settle yet. + expect(await log(page)).toEqual(['index:3:goTo']); + 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 => (-3 * PAGE * (1 - Math.cos(Math.PI * Math.min(1, t / 300)))) / 2, + 0.5, + ); + expect(result.maxError).toBeLessThan(0.5); + await releaseAnimations(page); +}); + +/** + * At most a jump: `property` holds its start value, then its end value, and + * never one in between. A cross-fade moves the track at the bottom of its dip + * (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 goTo 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 {track, driver} = await open(page); + expect( + await page.evaluate(() => ({ + reduce: matchMedia('(prefers-reduced-motion: reduce)').matches, + coarse: matchMedia('(pointer: coarse)').matches, + })), + ).toEqual({reduce: true, coarse: 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); + } + }; + // The drag follows the finger; after the lift the settle cross-fades. + const throwScript = flick( + {x: 320, y: 200}, + {velocity: {x: -1500, y: 0}, durationMs: 80}, + ); + const hold = await playHeld(page, track, driver, throwScript); + crossFadesOnly(await hold.held()); + await releaseAnimations(page); + await expect + .poll(async () => page.evaluate(() => window.pager.isSettling)) + .toBe(false); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + // goTo cross-fades too. + crossFadesOnly(await goToHeld(page, track, 4)); + await releaseAnimations(page); + await expect + .poll(async () => page.evaluate(() => window.pager.isSettling)) + .toBe(false); + expect(await page.evaluate(() => window.pager.index)).toBe(4); +}); + +test(`a still finger catches a settle ${85} ms after it lands, selects no text, and the lift settles on the nearest page`, async ({ + page, +}) => { + const {track, driver} = await open(page, {startIndex: 0}); + // One timeline the driver paces: a flick toward page 1. Then, 40 ms after + // its lift, a second finger lands on the settling track. It holds still for + // 600 ms, with a 0.6 px creep reported every 100 ms. The hold lands early in + // the settle, however busy the machine is. A settle that a separate goTo + // started raced the touch's dispatch, and under load the touch came later. + const throwScript = flick( + {x: 320, y: 200}, + {velocity: {x: -1500, y: 0}, durationMs: 80, hz: 120}, + ); + const liftAt = throwScript[throwScript.length - 1].t; + const hold = withFinger( + drag({x: 200, y: 200}, {x: 200.6, y: 200}, {durationMs: 600, hz: 10}), + 1, + liftAt + 40, + ); + const reads: { + x: number; + refused: boolean; + lift: {velocity: number; x: number; page: number}; + }[] = []; + let holdMoves = 0; + await driver.play(merge(throwScript, hold), { + onEvent: async event => { + if ( + event.type === 'touchMove' && + event.changed.some(finger => finger.id === 1) && + (++holdMoves === 3 || holdMoves === 6) + ) { + reads.push({ + x: await track.evaluate( + el => parseFloat(getComputedStyle(el).translate) || 0, + ), + refused: await track.evaluate(refusesSelection), + // The flick's lift, as the pan read it, where the track was, and + // the page the release chose (the catch keeps it until the lift). + lift: await page.evaluate(() => ({ + velocity: window.liftVelocities[window.liftVelocities.length - 1], + x: window.liftPositions[window.liftPositions.length - 1], + page: window.pager.index, + })), + }); + } + }, + }); + // Held: the same place at 300 and 600 ms, no selection. + expect(reads).toHaveLength(2); + expect(reads[1].x).toBeCloseTo(reads[0].x, 1); + expect(reads[0].refused).toBe(true); + // Caught mid-settle, the track sits on UIKit's settle for the lift that the + // pan read. That settle runs from the track's place at the lift onto the + // page the release chose. The hold stops it wherever it is on that curve + // (within 1.5 px), never at rest. In WebKit, touches carry their real + // dispatch times. So a busy page can read the lift faster than scripted, + // and that curve passes page 1 before it lands. Or a stall reads it as + // still, and the settle returns to page 0. + const settle = await page.evaluate( + ({velocity, from, to}) => { + const curve = window.Pager.solvePaging({ + from, + velocity: window.Pager.pagingSettleVelocity(-velocity), + to, + }); + const values: number[] = []; + for (let ms = 0; ms <= curve.duration * 1000; ms += 1) { + values.push(curve.at(ms / 1000)); + } + return {nearest: Math.min(...values), furthest: Math.max(...values)}; + }, + { + velocity: reads[0].lift.velocity, + from: -reads[0].lift.x, + to: reads[0].lift.page * PAGE, + }, + ); + const held = -reads[0].x; + expect(held, JSON.stringify({lift: reads[0].lift, settle})).toBeGreaterThan( + settle.nearest - 1.5, + ); + expect(held, JSON.stringify({lift: reads[0].lift, settle})).toBeLessThan( + settle.furthest + 1.5, + ); + expect(Math.abs(held - reads[0].lift.page * PAGE)).toBeGreaterThan(0.5); + // The lift settles on the page nearest to where it was held. + const nearest = Math.round(-reads[1].x / PAGE); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(-nearest * PAGE, 0); + expect(await track.evaluate(refusesSelection)).toBe(false); +}); + +// spec:AST-094 FR41: a hold that does nothing keeps the browser's native +// long-press selection. So a finger held on a resting pager's text can still +// select it. +test('a finger held on a resting track leaves its text selectable', async ({ + page, +}) => { + const {track, driver} = await open(page, {startIndex: 1}); + const reads: boolean[] = []; + let step = 0; + await driver.play( + drag({x: 200, y: 200}, {x: 200.6, y: 200}, {durationMs: 600, hz: 10}), + { + onEvent: async event => { + if (event.type === 'touchMove' && step++ === 3) { + reads.push(await track.evaluate(refusesSelection)); + } + }, + }, + ); + expect(reads).toEqual([false]); + await waitForRest(track); + expect(await trackX(track)).toBeCloseTo(-PAGE, 0); +}); + +test('a settle keeps advancing every frame through an 800 ms main-thread block', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + "WebKit's screencast clock is not the page's (frames arrive ~100 ms off), so a 0.3 s motion cannot be placed inside the block; the animator's own WebKit proof covers compositor playback, and Mobile Safari on the iOS simulator is this proof's WebKit evidence", + ); + await open(page, {startIndex: 0}); + const marker = page.locator('.page').nth(1); + await markElement(marker, {inset: 8}); + const cast = await startScreencast(page); + // Let the screencast deliver a few frames before anything moves. + await page.waitForTimeout(300); + // A goTo of one page (0.3 s at any distance), and an 800 ms block that the + // page schedules 90 ms in. WebKit hands a new animation to its compositor + // two or three rendering updates after it starts (animator.ts, known + // limits). A harness round trip would eat most of the motion. + const started = await page.evaluate(() => { + window.pager.goTo(1); + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 800; + let spins = 0; + while (performance.now() < end) { + spins += 1; + } + (window as unknown as {busy: unknown}).busy = { + from, + to: performance.timeOrigin + performance.now(), + spins, + }; + }, 90); + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(1200); + 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()); + // Judge the part of the block that the motion spans. The motion ends about + // 0.3 s after the call, or a frame later when it starts at the next commit. + const spanned = {...busy, to: Math.min(busy.to, started + 300)}; + test.info().annotations.push({ + type: 'marker x per frame (ms from the call)', + description: JSON.stringify( + tracked.map(f => [Math.round(f.t - started), f.box?.x ?? null]), + ), + }); + expect(spanned.to - spanned.from).toBeGreaterThan(150); + 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); + // And it lands while the thread is still blocked. The last frame that the + // page presented before the block ended shows the end state. There, page + // 1's marker sits at its inset from the viewport's left edge. A page at rest + // presents no new frame, so the landing is that last frame, whenever the + // screencast delivered it. + const blocked = tracked.filter(f => f.box != null && f.t < busy.to); + const last = blocked[blocked.length - 1]; + expect(last.t).toBeGreaterThan(spanned.from); + expect(Math.abs((last.box?.x ?? 0) - 8)).toBeLessThan(1.5); +}); + +test('no Layout or Paint while it settles', async ({page, browserName}) => { + test.skip( + browserName === 'webkit', + 'Playwright exposes no rendering trace for WebKit', + ); + const {track} = await open(page, {startIndex: 0}); + // Nothing reads the page while the motion runs: a read mid-motion draws a + // frame of its own, with paint. The motion notes when its curve starts, by + // the engine's clock, and that note is read afterwards. Under load, its + // first frame can come well after the call. + const trace = await traceRendering(page, async () => { + await page.evaluate(() => { + window.armFrames(); + window.pager.goTo(2); + const track = document.getElementById('track') as HTMLElement; + for (const animation of track.getAnimations()) { + void animation.ready.then(running => { + if (running.startTime != null) { + const start = performance.timeOrigin + Number(running.startTime); + window.motionStart = Math.min(window.motionStart ?? start, start); + } + }); + } + }); + // The 0.3 s motion, with room for a late start under load. + await page.waitForTimeout(800); + }); + const started = await page.evaluate(() => window.motionStart ?? NaN); + expect(Number.isFinite(started)).toBe(true); + // The trace holds the whole motion, and the motion moved the track to its + // page. So no layout or paint in it means the compositor moved it, not that + // the motion never ran. With nothing reading the page, the main thread draws + // no frame while it moves. + expect(trace.window.to).toBeGreaterThan(started + 300); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); + // The steady part of the 0.3 s motion: its first and last frames may commit. + const during = countBetween(trace, started + 60, started + 240); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + // And no long task while it settles. The task that starts the motion is the + // goTo call, which samples the curve into keyframes. It ends where the curve + // starts, and it is not part of the settle. On two loaded cores, it + // stretched to 51–85 ms, and ended 0.25–0.55 ms before the curve's start. + const whileSettling = trace.longTasks.filter( + task => + task.start + task.durationMs > started && task.start < started + 300, + ); + expect( + whileSettling, + JSON.stringify({started, longTasks: trace.longTasks}), + ).toEqual([]); +}); + +for (const [name, script] of [ + [ + 'release-at-rest', + drag( + {x: 320, y: 200}, + {x: 320 - 0.6 * PAGE, y: 200}, + {durationMs: 1000, restMs: 120}, + ), + ], + [ + 'flick', + flick( + {x: 320, y: 200}, + {velocity: {x: -1500, y: 0}, durationMs: 80, hz: 120}, + ), + ], + [ + // The lift a busy WebKit page read for the flick above, its moves bunched: + // 144 px in 8 ms, about 18,000 px/s. + 'flick-bunched', + flick( + {x: 320, y: 200}, + {velocity: {x: -18_000, y: 0}, durationMs: 8, hz: 1000}, + ), + ], + [ + 'return-to-page', + drag( + {x: 320, y: 200}, + {x: 320 - 0.3 * PAGE, y: 200}, + {durationMs: 1000, restMs: 120}, + ), + ], + ['goto', null], +] as const) { + test(`storyboard: ${name} @storyboard`, async ({page}) => { + const {track, driver} = await open(page); + let held: ReadonlyArray; + if (script == null) { + held = await goToHeld(page, track, 3); + } else { + const hold = await playHeld(page, track, driver, script); + held = await hold.held(); + } + const {storyboard} = await animationStoryboard(page, { + name: `pager-${name}`, + held, + clip: {x: 0, y: TOP, width: PAGE, height: HEIGHT}, + measure: async () => ({x: await trackX(track)}), + }); + await storyboard.write(test.info()); + const xs = storyboard.frames.map(f => (f.data as {x: number}).x); + // It lands on the page the pager chose (the last frame is half a + // millisecond inside the curve's end, within the last step). + const index = await page.evaluate(() => window.pager.index); + expect(Math.abs(xs[xs.length - 1] + index * PAGE)).toBeLessThan(1.5); + if (name === 'goto') { + // setContentOffset's ease runs toward the page and never past it. + const toward = Math.sign(xs[xs.length - 1] - xs[0]); + for (let i = 1; i < xs.length; i++) { + expect(Math.sign(xs[i] - xs[i - 1]) * toward).toBeGreaterThanOrEqual(0); + } + } else { + // A release: every frame, the landing included, lies on UIKit's paging + // curve for the lift velocity the pan read, within 1.5 px. A fast lift + // carries the curve past the page and back; the frames follow it. + const model = await releaseModel(page, { + from: -xs[0], + to: index * PAGE, + times: storyboard.frames.map(f => f.t), + }); + const off = xs.map((x, i) => Math.abs(x + model.offsets[i])); + expect( + Math.max(...off), + JSON.stringify({lift: model.lift, xs, model: model.offsets}), + ).toBeLessThan(1.5); + } + await releaseAnimations(page); + }); +} + +// spec:AST-094 FR43: gestures work on the web for every pointer. A desktop +// context: a fine primary pointer, a real mouse and real wheel input. +// Chromium's CDP input sends wheels without a trackpad's legacy `wheelDelta` +// (three times each delta). So the gesture engine reads them as a mouse's +// wheel, which a pan that takes only a trackpad's scroll leaves to the page. +// The trackpad cases run in WebKit, whose injected wheels carry it. +const TRACKPAD_IN_CHROMIUM = + "Chromium's CDP wheels read as a mouse's wheel; the trackpad cases run in WebKit"; + +test.describe('under a fine primary pointer (spec:AST-094 FR43)', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a mouse drag pages the pager like a finger', async ({page}) => { + const {track} = await openFine(page); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const y = TOP + HEIGHT / 2; + await page.mouse.move(340, y); + await page.mouse.down(); + // 250 px toward the start, past half a page whatever the release speed. + for (let i = 1; i <= 25; i++) { + await page.mouse.move(340 - 10 * i, y); + } + await page.mouse.up(); + await waitForRest(track); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); + }); + + test("a trackpad's horizontal scroll pages the pager, and the page keeps none of it", async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_IN_CHROMIUM); + const {track, input} = await openFine(page); + // 40 steps of 6.5 px (a trackpad's continuous scroll): 260 px toward the + // next page, past half of it whatever the release speed. + for (let i = 0; i < 40; i++) { + await input.wheel({x: 200, y: TOP + HEIGHT / 2, dx: 6.5}); + } + // A trackpad scroll ends 220 ms after its last wheel. + await page.waitForTimeout(300); + await waitForRest(track); + expect(await page.evaluate(() => window.pager.index)).toBe(2); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); + const cancelled = await page.evaluate(() => window.cancelledWheels); + expect(cancelled.length).toBeGreaterThan(0); + expect(cancelled.every(Boolean)).toBe(true); + }); + + test("a trackpad scroll of more than a page stays the pager's while the next page moves under the still cursor, stops at that page, and pages one page", async ({ + page, + browserName, + }) => { + test.skip(browserName === 'chromium', TRACKPAD_IN_CHROMIUM); + const {track, input} = await openFine(page); + // The furthest the track gets, sampled every frame. + await page.evaluate(() => { + window.minTrackX = 0; + const el = document.getElementById('track') as HTMLElement; + const sample = () => { + const value = getComputedStyle(el).translate; + const x = value === 'none' ? 0 : parseFloat(value); + window.minTrackX = Math.min(window.minTrackX, x); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + // 30 steps of 17.5 px: 525 px, more than a page. The cursor stays put + // while the next page slides under it; the track stops there, one page + // from where the scroll began. + for (let i = 0; i < 30; i++) { + await input.wheel({x: 200, y: TOP + HEIGHT / 2, dx: 17.5}); + } + // A trackpad scroll ends 220 ms after its last wheel. + await page.waitForTimeout(300); + await waitForRest(track); + const furthest = await page.evaluate(() => window.minTrackX); + expect(furthest).toBeGreaterThanOrEqual(-2 * PAGE - 0.5); + expect(furthest).toBeLessThanOrEqual(-2 * PAGE + 0.5); + const cancelled = await page.evaluate(() => window.cancelledWheels); + expect(cancelled.length).toBeGreaterThan(0); + expect(cancelled.every(Boolean)).toBe(true); + // One page per release, as UIKit pages. + expect(await page.evaluate(() => window.pager.index)).toBe(2); + expect(await trackX(track)).toBeCloseTo(-2 * PAGE, 0); + }); + + test("a vertical wheel over the pager is the page's: the pager stays and takes none of it", async ({ + page, + browserName, + }) => { + const {track, input} = await openFine(page, {tall: true}); + for (let i = 0; i < 10; i++) { + await input.wheel({x: 200, y: TOP + HEIGHT / 2, dy: 40.5}); + } + await page.waitForTimeout(200); + expect(await page.evaluate(() => window.pager.index)).toBe(1); + expect(await trackX(track)).toBeCloseTo(-PAGE, 0); + const cancelled = await page.evaluate(() => window.cancelledWheels); + expect(cancelled.length).toBeGreaterThan(0); + expect(cancelled.some(Boolean)).toBe(false); + if (browserName === 'chromium') { + expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/hooks/useAttachment.ts b/packages/core/src/hooks/useAttachment.ts index 9bfe67fa64c79..77a1d8b4be708 100644 --- a/packages/core/src/hooks/useAttachment.ts +++ b/packages/core/src/hooks/useAttachment.ts @@ -4,16 +4,16 @@ /** * @file useAttachment.ts - * @input A node ref (an element's, or the document's), an attach function + * @input Node refs (an element's, or the document's), an attach function * and a key - * @output Exports useAttachment: keeps the attach function bound to the node - * the ref holds across commits - * @position Internal React helper, not in hooks/index.ts: the one attach - * and release lifecycle of the gesture hooks (useGestureRecognizer, - * usePressTracker, useFingerScroll) + * @output Exports useAttachment and useAttachmentPair (two elements): they keep + * the attach function bound to the nodes the refs hold across commits + * @position Internal React helper, not in hooks/index.ts: the one attach and + * release lifecycle of the gesture hooks and the pager * - * SYNC: When modified, update gestures/__tests__/useGestureRecognizer.test.tsx - * and gestures/__tests__/useFingerScroll.test.tsx. + * SYNC: When modified, update gestures/__tests__/useGestureRecognizer.test.tsx, + * gestures/__tests__/useFingerScroll.test.tsx and + * __tests__/useAttachmentPair.test.tsx. */ import {useRef, type RefObject} from 'react'; @@ -29,23 +29,65 @@ export function useAttachment( ref: RefObject, attach: (element: T) => () => void, key: unknown, +): void { + useAttachedNodes([ref], ([node]) => attach(node as T), key); +} + +/** + * `useAttachment` for two elements. It keeps `attach` bound to the pair the + * refs hold after each commit, once both are there. Replacing either element + * releases the attachment and attaches the new pair. A changed `key` + * re-attaches, and the last attachment is released on unmount. A commit that + * changes neither does nothing. + */ +export function useAttachmentPair( + firstRef: RefObject, + secondRef: RefObject, + attach: (first: A, second: B) => () => void, + key: unknown, +): void { + useAttachedNodes( + [firstRef, secondRef], + ([first, second]) => attach(first as A, second as B), + key, + ); +} + +/** + * The lifecycle both share: one attachment of the nodes the refs hold. It is + * made once every node is there, and released when a node or `key` changes + * and at unmount. + */ +function useAttachedNodes( + refs: ReadonlyArray>, + attach: (nodes: ReadonlyArray) => () => void, + key: unknown, ): void { const attachmentRef = useRef<{ - element: T; + nodes: ReadonlyArray; key: unknown; release: () => void; } | null>(null); - // Syncs the attachment with the DOM node `ref` holds after this commit. + // Syncs the attachment with the DOM nodes the refs hold after this commit. useIsomorphicLayoutEffect(() => { - const element = ref.current; + const nodes = refs.map(ref => ref.current); const current = attachmentRef.current; - if (current?.element === element && current?.key === key) { + if ( + current != null && + current.key === key && + nodes.every((node, index) => node === current.nodes[index]) + ) { return; } current?.release(); - attachmentRef.current = - element == null ? null : {element, key, release: attach(element)}; + attachmentRef.current = nodes.every(node => node != null) + ? { + nodes: nodes, + key, + release: attach(nodes), + } + : null; }); // Releases the attachment at unmount. diff --git a/packages/core/src/hooks/usePager.test.ts b/packages/core/src/hooks/usePager.test.ts new file mode 100644 index 0000000000000..629c4ba845ad4 --- /dev/null +++ b/packages/core/src/hooks/usePager.test.ts @@ -0,0 +1,2665 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file usePager.test.ts + * @input vitest (jsdom), the gesture host on a manual clock, the animator + * with a recording Web Animations stub, and synthetic touches + * @output Proves the pager's behavior: tracking, the rubber band, UIKit's page + * choice and settle, the catch, goTo, loops, RTL, the axis rules and the + * callbacks + * @position Testing; the browser proofs are + * __tests__/usePager.interaction.browser.spec.ts + */ + +import {act, render} from '@testing-library/react'; +import {createElement, useRef, useState} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../gestures/GestureHost'; +import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; +import {ManualClock} from '../gestures/__tests__/harness'; +import {createAnimator, type Animator} from '../motion/animator'; +import {solveAnimatedPageScroll, solvePaging} from '../motion/paging'; +import type * as motionResolution from '../motion/resolveMotion'; +import {resolveMotion} from '../motion/resolveMotion'; +import {rubberBand} from '../motion/rubberBand'; +import {paging} from '../motion/uikit/paging'; +import { + createPager, + pageForRelease, + usePager, + type Pager, + type PagerOptions, +} from './usePager'; + +const WIDTH = 400; + +// resolveMotion as shipped, observable: a test can stand in other values for +// the roles a motion reads (spec:AST-094 FR4). +vi.mock('../motion/resolveMotion', async importOriginal => { + const actual = await importOriginal(); + return {...actual, resolveMotion: vi.fn(actual.resolveMotion)}; +}); + +// --- A recording Web Animations stub (pending until a frame commits). ------ + +interface Recorded { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + startTime: number | null; + cancelled: boolean; + playState: string; + ready: Promise; + cancel(): void; + commit(at: number): void; +} + +let now = 1000; +let recorded: Recorded[] = []; +let frameQueue: (() => void)[] = []; + +beforeEach(() => { + now = 1000; + recorded = []; + frameQueue = []; + vi.useFakeTimers(); + // A phone: a coarse primary pointer, which the gesture host reads when it + // first attaches. Gestures attach for every pointer (spec:AST-094 FR19, + // FR43). Under a fine pointer, an element keeps one passive `pointerdown` + // listener, and the page's listeners last only a press (FR24). + stubMedia(false, true); + HTMLElement.prototype.animate = function (keyframes, options) { + let resolveReady: (value: unknown) => void = () => {}; + let rejectReady: (error: Error) => void = () => {}; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + ready.catch(() => {}); + let start: number | null = null; + const animation: Recorded = { + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + get startTime() { + return start; + }, + set startTime(value) { + start = value; + resolveReady(animation); + }, + cancelled: false, + get playState() { + return this.cancelled ? 'idle' : 'running'; + }, + ready, + cancel() { + this.cancelled = true; + rejectReady(new Error('AbortError')); + }, + commit(at) { + if (!this.cancelled && start == null) { + start = at; + } + resolveReady(this); + }, + }; + recorded.push(animation); + return animation as unknown as Animation; + }; +}); + +afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +/** + * The person's settings, through `matchMedia` as the animator reads them: no + * option overrides them (design:reduced-motion DR4). + */ +function stubMedia(reduced: boolean, coarse: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('prefers-reduced-motion') + ? reduced + : query.includes('pointer: coarse') + ? coarse + : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +} + +/** Commits a frame, then moves both clocks forward by `ms`. */ +async function advance(ms: number, clock?: ManualClock) { + for (const animation of recorded) { + if (animation.startTime == null && !animation.cancelled) { + animation.commit(now); + } + } + const frames = frameQueue; + frameQueue = []; + for (const callback of frames) { + callback(); + } + await Promise.resolve(); + await Promise.resolve(); + now += ms; + if (clock != null) { + clock.time = now; + } + await vi.advanceTimersByTimeAsync(ms); +} + +/** + * A frame in WebKit's order. The browser starts new animations and resolves + * their start inside the frame's rendering update. That comes before this + * frame's animation-frame callbacks, with no task (timer) in between. + * `advance` runs the callbacks first. + */ +async function advanceInWebKitOrder(ms: number, clock?: ManualClock) { + for (const animation of recorded) { + if (animation.startTime == null && !animation.cancelled) { + animation.commit(now); + } + } + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } + const frames = frameQueue; + frameQueue = []; + for (const callback of frames) { + callback(); + } + await Promise.resolve(); + await Promise.resolve(); + now += ms; + if (clock != null) { + clock.time = now; + } + await vi.advanceTimersByTimeAsync(ms); +} + +// --- Touches ---------------------------------------------------------------- + +function touch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: EventTarget, + x: number, + y: number, + time: number, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const contact = {identifier: 1, clientX: x, clientY: y, target}; + const list = type === 'touchend' ? [] : [contact]; + Object.defineProperty(event, 'changedTouches', {value: [contact]}); + Object.defineProperty(event, 'touches', {value: list}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + return event; +} + +/** A mouse event for the gesture host (jsdom has no PointerEvent). */ +function mouse( + type: 'pointerdown' | 'pointermove' | 'pointerup', + target: EventTarget, + x: number, + y: number, + time: number, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const fields: Record = { + pointerType: 'mouse', + pointerId: 1, + isPrimary: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: y, + timeStamp: time, + }; + for (const [key, value] of Object.entries(fields)) { + Object.defineProperty(event, key, {value}); + } + target.dispatchEvent(event); + return event; +} + +/** + * A wheel for the gesture host: `dx`/`dy` px (or lines with `mode` 1). With + * no legacy `wheelDeltaX`/`wheelDeltaY` it reads as a mouse's wheel. + */ +function wheel( + target: EventTarget, + init: { + dx?: number; + dy?: number; + mode?: number; + time: number; + legacy?: {x: number; y: number}; + }, +): WheelEvent { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: 300, + clientY: 100, + deltaX: init.dx ?? 0, + deltaY: init.dy ?? 0, + deltaMode: init.mode ?? 0, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + if (init.legacy != null) { + Object.defineProperty(event, 'wheelDeltaX', {value: init.legacy.x}); + Object.defineProperty(event, 'wheelDeltaY', {value: init.legacy.y}); + } + target.dispatchEvent(event); + return event; +} + +/** + * A trackpad's scroll: precise deltas, whose legacy `wheelDelta` Chromium and + * WebKit report as three times each, against the content. + */ +function trackpad( + target: EventTarget, + init: {dx?: number; dy?: number; time: number}, +): WheelEvent { + const dx = init.dx ?? 0; + const dy = init.dy ?? 0; + return wheel(target, {...init, legacy: {x: -3 * dx, y: -3 * dy}}); +} + +interface Fixture { + pager: Pager; + viewport: HTMLElement; + track: HTMLElement; + page: HTMLElement; + animator: Animator; + clock: ManualClock; + host: GestureHost; + calls: string[]; + /** The track's x on screen now, from the animator. */ + x(): number; +} + +function setup(options: Partial = {}, rtl = false): Fixture { + const clock = new ManualClock(); + clock.time = now; + const host = new GestureHost(document, {clock}); + const viewport = document.createElement('div'); + const track = document.createElement('div'); + const page = document.createElement('div'); + if (rtl) { + viewport.style.direction = 'rtl'; + } + track.appendChild(page); + viewport.appendChild(track); + document.body.appendChild(viewport); + Object.defineProperty(viewport, 'clientWidth', { + value: WIDTH, + configurable: true, + }); + const animator = createAnimator(track, { + now: () => now, + requestFrame: callback => { + frameQueue.push(callback); + }, + }); + const calls: string[] = []; + const pager = createPager( + { + count: 5, + onIndexChange: (index, reason) => calls.push(`index:${index}:${reason}`), + onSettle: (index, previous) => calls.push(`settle:${index}:${previous}`), + ...options, + }, + { + host, + animator, + requestFrame: callback => { + frameQueue.push(callback); + }, + }, + ); + pager.attach(viewport, track); + return { + pager, + viewport, + track, + page, + animator, + clock, + host, + calls, + x: () => animator.get('x', now), + }; +} + +/** + * A horizontal drag on the page: lands at `from`, moves `dx` in steps of + * `step` px every `dt` ms, optionally holds still, then lifts. Returns the + * lift time. + */ +async function drag( + f: Fixture, + { + from = 200, + dx, + dy = 0, + step = 4, + dt = 8, + hold = 0, + still = 0, + }: { + from?: number; + dx: number; + dy?: number; + step?: number; + dt?: number; + /** A silent rest before the lift: no events, ms. */ + hold?: number; + /** Move samples that stand still at the end, `dt` apart, before the lift. */ + still?: number; + }, +): Promise { + const y = 100; + touch('touchstart', f.page, from, y, now); + const steps = Math.max(1, Math.round(Math.abs(dx) / step)); + for (let i = 1; i <= steps; i++) { + await advance(dt, f.clock); + touch( + 'touchmove', + f.page, + from + (dx * i) / steps, + y + (dy * i) / steps, + now, + ); + } + for (let i = 0; i < still; i++) { + await advance(dt, f.clock); + touch('touchmove', f.page, from + dx, y + dy, now); + } + if (hold > 0) { + await advance(hold, f.clock); + } + await advance(dt, f.clock); + touch('touchend', f.page, from + dx, y + dy, now); + return now; +} + +/** Three display frames: a release's report waits for its settle to run. */ +async function frames(f: Fixture, count = 3) { + for (let i = 0; i < count; i++) { + await advance(16, f.clock); + } +} + +async function settle(f: Fixture) { + for (let i = 0; i < 80 && f.pager.isSettling; i++) { + await advance(16, f.clock); + } + await advance(16, f.clock); +} + +/** + * A trackpad fling: `count` steady deltas of `px` every 8 ms, then four + * shrinking ones as the fingers lift into momentum. The gesture engine + * releases the pan at the lift, at the fingers' travel and speed; the + * shrinking deltas never move it. + */ +async function fling(f: Fixture, px: number, count: number) { + const events: WheelEvent[] = []; + for (const dx of [ + ...Array.from({length: count}, () => px), + ...[0.95, 0.9, 0.85, 0.78].map(k => k * px), + ]) { + events.push(trackpad(f.page, {dx, time: now})); + await advance(8, f.clock); + } + return events; +} + +describe('createPager', () => { + it('rests on the default page', () => { + const f = setup({defaultIndex: 2}); + expect(f.pager.index).toBe(2); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('tracks the finger 1:1 without reading layout while it moves', async () => { + const f = setup({defaultIndex: 1}); + const width = vi.spyOn(f.viewport, 'clientWidth', 'get'); + touch('touchstart', f.page, 300, 100, now); + for (let i = 1; i <= 10; i++) { + await advance(8, f.clock); + touch('touchmove', f.page, 300 - 6 * i, 100, now); + } + // The pan began at 10 px and dropped the threshold's travel (UIKit). + expect(f.pager.isDragging).toBe(true); + const shown = -f.x() - WIDTH; + expect(shown).toBeGreaterThan(40); + expect(shown).toBeLessThanOrEqual(60); + const readsWhileMoving = width.mock.calls.length; + for (let i = 11; i <= 20; i++) { + await advance(8, f.clock); + touch('touchmove', f.page, 300 - 6 * i, 100, now); + } + expect(width.mock.calls.length).toBe(readsWhileMoving); + expect(-f.x() - WIDTH - shown).toBeCloseTo(60, 6); + }); + + // A lift within 40 ms of the last move keeps the release velocity: the + // settle carries it. + it('a lift within 40 ms of the last move keeps the release velocity: the settle carries it', async () => { + const f = setup({defaultIndex: 1}); + // 250 px left in 8 ms steps of about 4 px (496 px/s), then 24 ms with no + // events. The lift comes 32 ms after the last move. + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 24}); + expect(f.pager.index).toBe(2); + const position = -f.x(); + const velocity = (250 / Math.round(250 / 4) / 8) * 1000; + const carried = solvePaging({from: position, velocity, to: 2 * WIDTH}); + const rest = solvePaging({from: position, velocity: 0, to: 2 * WIDTH}); + await advance(16, f.clock); + await advance(100, f.clock); + expect(carried.at(0.116) - rest.at(0.116)).toBeGreaterThan(5); + expect(-f.x()).toBeCloseTo(carried.at(0.116), 0); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + expect(f.calls).toEqual(['index:2:drag', 'settle:2:1']); + }); + + // UIKit keeps the release velocity through a silent rest. Recorded on + // iOS 26.2: 600 pt/s to 25 % of a page, then up to 397 ms with no touch + // event, kept 0.55 to 0.61 pt/ms. The shared release filter reads 0 after + // 40 ms of silence instead (spec:AST-100 FR2), and the pager reads the + // filter. + it('a silent hold of more than 40 ms before the lift starts the settle at rest (spec:AST-100 FR2), where UIKit keeps the speed', async () => { + const f = setup({defaultIndex: 1}); + // The same drag, then 100 ms with no events. + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + expect(f.pager.index).toBe(2); + const position = -f.x(); + const rest = solvePaging({from: position, velocity: 0, to: 2 * WIDTH}); + await advance(16, f.clock); + await advance(100, f.clock); + expect(-f.x()).toBeCloseTo(rest.at(0.116), 0); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + expect(f.calls).toEqual(['index:2:drag', 'settle:2:1']); + }); + + // A sample at the finger's last place is not motion (spec:AST-100's one + // filter). A finger that keeps reporting while it stands still keeps its + // speed until the stillness interval runs out. + it('move samples that stand still are not motion: a lift 32 ms after the last move keeps the release velocity', async () => { + const f = setup({defaultIndex: 1}); + // The same drag, then three samples at the same place, 8 ms apart; the + // lift comes 8 ms later, 32 ms after the last move. + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, still: 3}); + expect(f.pager.index).toBe(2); + const position = -f.x(); + const velocity = (250 / Math.round(250 / 4) / 8) * 1000; + const carried = solvePaging({from: position, velocity, to: 2 * WIDTH}); + const rest = solvePaging({from: position, velocity: 0, to: 2 * WIDTH}); + await advance(16, f.clock); + await advance(100, f.clock); + expect(carried.at(0.116) - rest.at(0.116)).toBeGreaterThan(5); + expect(-f.x()).toBeCloseTo(carried.at(0.116), 0); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('a finger that stands still, still reporting, for more than 40 ms before the lift starts the settle at rest (spec:AST-100 FR2)', async () => { + const f = setup({defaultIndex: 1}); + // The same drag, then six samples at the same place, 8 ms apart; the + // lift comes 56 ms after the last move. + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, still: 6}); + expect(f.pager.index).toBe(2); + const position = -f.x(); + const rest = solvePaging({from: position, velocity: 0, to: 2 * WIDTH}); + await advance(16, f.clock); + await advance(100, f.clock); + expect(-f.x()).toBeCloseTo(rest.at(0.116), 0); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('reports the new page once the settle runs on the compositor, two frames after its first', async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + // Chosen at the lift (index), reported once the settle is moving. + expect(f.pager.index).toBe(2); + expect(f.calls).toEqual([]); + await advance(16, f.clock); + await advance(16, f.clock); + expect(f.calls).toEqual([]); + expect(recorded.some(a => a.startTime != null && !a.cancelled)).toBe(true); + await advance(16, f.clock); + expect(f.calls).toEqual(['index:2:drag']); + }); + + // WebKit resolves an animation's start in the frame's rendering update, + // before that same frame's animation-frame callbacks. A count that included + // that frame reported the page one frame into the settle. + it("in WebKit's frame order the settle's start frame does not count: the page is still reported two frames after it", async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + expect(f.calls).toEqual([]); + // The frame that starts the settle, then one more: not yet. + await advanceInWebKitOrder(16, f.clock); + expect(recorded.some(a => a.startTime != null && !a.cancelled)).toBe(true); + await advanceInWebKitOrder(16, f.clock); + expect(f.calls).toEqual([]); + // The second frame after the start. + await advanceInWebKitOrder(16, f.clock); + expect(f.calls).toEqual(['index:2:drag']); + }); + + it('a drag that catches the settle before the report still reports the page', async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + touch('touchstart', f.page, 300, 100, now); + await advance(8, f.clock); + touch('touchmove', f.page, 288, 100, now); + expect(f.pager.isDragging).toBe(true); + expect(f.calls).toEqual(['index:2:drag']); + touch('touchend', f.page, 288, 100, now + 200); + }); + + it('a release whose report is still waiting reports nothing when the pager detaches: no callback from an unmount', async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + expect(f.pager.index).toBe(2); + expect(f.calls).toEqual([]); + f.pager.dispose(); + await frames(f, 4); + expect(f.calls).toEqual([]); + }); + + it("a controlled move during a release's waiting report drops it: the owner's own page wins, with no callback", async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + expect(f.pager.index).toBe(2); + f.pager.goTo(3, {reason: 'controlled'}); + expect(f.calls).toEqual([]); + await settle(f); + expect(f.pager.index).toBe(3); + expect(f.calls).toEqual(['settle:3:1']); + }); + + it('released slowly before half a page, returns to the page it started on', async () => { + const f = setup({defaultIndex: 1}); + // 2 px every 16 ms (125 px/s), then still. + await drag(f, {from: 300, dx: -150, step: 2, dt: 16, hold: 100}); + expect(f.calls).toEqual([]); + await settle(f); + expect(f.x()).toBe(-WIDTH); + expect(f.calls).toEqual(['settle:1:1']); + }); + + it('a flick at the advance speed pages from a few percent', async () => { + const f = setup({defaultIndex: 1}); + // 40 px in 4 events of 10 px every 16 ms: 625 px/s, no hold. + await drag(f, {from: 300, dx: -50, step: 10, dt: 16}); + await frames(f); + expect(f.calls).toEqual(['index:2:drag']); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('a flick lifted within 40 ms of its last move pages on its speed', async () => { + const f = setup({defaultIndex: 1}); + // 625 px/s to 12.5 %, then 16 ms with no events: the lift comes 32 ms + // after the last move. + await drag(f, {from: 300, dx: -50, step: 10, dt: 16, hold: 16}); + await frames(f); + expect(f.calls).toEqual(['index:2:drag']); + }); + + it('a hold of more than 40 ms before the lift cancels a flick (spec:AST-100 FR2): the page stays, where UIKit still pages', async () => { + const f = setup({defaultIndex: 1}); + // 625 px/s to 12.5 %, then still for 400 ms. + await drag(f, {from: 300, dx: -50, step: 10, dt: 16, hold: 400}); + expect(f.pager.index).toBe(1); + await settle(f); + expect(f.x()).toBe(-WIDTH); + expect(f.calls).toEqual(['settle:1:1']); + }); + + it('a slow drift does not page', async () => { + const f = setup({defaultIndex: 1}); + // 2 px every 16 ms: 125 px/s, under the advance speed. + await drag(f, {from: 300, dx: -60, step: 2, dt: 16}); + await frames(f); + expect(f.calls).toEqual([]); + }); + + it('never travels more than one page from the page the drag started on', async () => { + const f = setup({defaultIndex: 1}); + // 1.5 pages in one fast drag: still only the next page. + await drag(f, {from: 390, dx: -380, step: 12, dt: 8}); + expect(f.pager.index).toBe(2); + await frames(f); + expect(f.calls).toEqual(['index:2:drag']); + }); + + it('rubber-bands past the first page with d = the page width', async () => { + const f = setup({defaultIndex: 0}); + touch('touchstart', f.page, 100, 100, now); + for (let i = 1; i <= 30; i++) { + await advance(8, f.clock); + touch('touchmove', f.page, 100 + 10 * i, 100, now); + } + // 300 px of finger, 10 px of it spent on the pan's threshold. + const finger = 300 - 10; + expect(f.x()).toBeCloseTo(rubberBand(finger, WIDTH), 1); + await advance(8, f.clock); + touch('touchend', f.page, 400, 100, now); + await settle(f); + expect(f.x()).toBe(0); + expect(f.calls).toEqual(['settle:0:0']); + }); + + it('mirrors in RTL: a drag to the right goes forward', async () => { + const f = setup({defaultIndex: 0}, true); + expect(f.x()).toBe(0); + await drag(f, {from: 100, dx: 250, hold: 100}); + expect(f.pager.index).toBe(1); + await settle(f); + expect(f.x()).toBe(WIDTH); + expect(f.calls).toEqual(['index:1:drag', 'settle:1:0']); + }); + + it('leaves a vertical drag to the page: the pan fails', async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -20, dy: 60}); + expect(f.pager.isDragging).toBe(false); + expect(f.calls).toEqual([]); + expect(f.x()).toBe(-WIDTH); + }); + + it('asks shouldBeginPan with the initial direction', async () => { + const directions: string[] = []; + const f = setup({ + defaultIndex: 1, + shouldBeginPan: direction => { + directions.push(direction); + return direction === 'right'; + }, + }); + await drag(f, {from: 300, dx: -200, hold: 50}); + expect(directions).toEqual(['left']); + expect(f.x()).toBe(-WIDTH); + await drag(f, {from: 100, dx: 250, hold: 50}); + expect(directions).toEqual(['left', 'right']); + await frames(f); + expect(f.calls).toContain('index:0:drag'); + }); + + it("goTo animates setContentOffset's curve, and fires the callbacks", async () => { + const f = setup({defaultIndex: 0}); + f.pager.goTo(3); + expect(f.calls).toEqual(['index:3:goTo']); + await advance(0, f.clock); + const curve = solveAnimatedPageScroll({from: 0, to: 3 * WIDTH}); + await advance(150, f.clock); + expect(-f.x()).toBeCloseTo(curve.at(0.15), 0); + await settle(f); + expect(f.x()).toBe(-3 * WIDTH); + expect(f.calls).toEqual(['index:3:goTo', 'settle:3:0']); + }); + + it('goTo without animation lands at once', () => { + const f = setup({defaultIndex: 0}); + f.pager.goTo(4, {animated: false}); + expect(f.x()).toBe(-4 * WIDTH); + expect(f.calls).toEqual(['index:4:goTo', 'settle:4:0']); + }); + + // spec:AST-094 FR4: a component reads touch motion through resolveMotion and + // plays no literal. goTo reads setContentOffset's duration and curve there. + // What resolveMotion returns for them is what plays. Today those are the + // paging table's values, as no role names them. + it("goTo plays the duration and curve resolveMotion gives setContentOffset's", async () => { + const actual = await vi.importActual( + '../motion/resolveMotion', + ); + const LINEAR = [0, 0, 1, 1] as const; + function standIn( + element: Element | null | undefined, + leafOrGroup: object, + key?: string, + ): number | object | undefined { + if (leafOrGroup === paging.animatedScroll && key === 'durationMs') { + return 600; + } + if (leafOrGroup === paging.animatedScroll.curve) { + return LINEAR; + } + return ( + actual.resolveMotion as ( + ...args: unknown[] + ) => number | object | undefined + )(element, leafOrGroup, key); + } + vi.mocked(resolveMotion).mockImplementation( + standIn as typeof resolveMotion, + ); + try { + const f = setup({defaultIndex: 0}); + f.pager.goTo(2); + await advance(0, f.clock); + // A quarter of the way along a linear 600 ms curve at 150 ms (the + // table's 300 ms sinusoid would be at 50 %)… + await advance(150, f.clock); + expect(-f.x()).toBeGreaterThan(0.4 * WIDTH); + expect(-f.x()).toBeLessThan(0.6 * WIDTH); + // …halfway at 300 ms, where the table's 300 ms has landed… + await advance(150, f.clock); + expect(-f.x()).toBeGreaterThan(0.9 * WIDTH); + expect(-f.x()).toBeLessThan(1.1 * WIDTH); + // …and on the page at 600 ms. + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + } finally { + vi.mocked(resolveMotion).mockImplementation(actual.resolveMotion); + } + }); + + it('goTo during a drag cancels the drag and starts from what is on screen', async () => { + const f = setup({defaultIndex: 1}); + touch('touchstart', f.page, 300, 100, now); + for (let i = 1; i <= 10; i++) { + await advance(8, f.clock); + touch('touchmove', f.page, 300 - 8 * i, 100, now); + } + const onScreen = -f.x(); + f.pager.goTo(3); + expect(f.pager.isDragging).toBe(false); + expect(f.pager.pan.state).not.toBe('changed'); + await advance(0, f.clock); + expect(-f.x()).toBeCloseTo(onScreen, 0); + // The rest of the finger's travel no longer moves the track. + await advance(8, f.clock); + touch('touchmove', f.page, 100, 100, now); + await settle(f); + touch('touchend', f.page, 100, 100, now); + expect(f.x()).toBe(-3 * WIDTH); + expect(f.calls).toEqual(['index:3:goTo', 'settle:3:1']); + }); + + it('a drag catches a settling track at the value on screen', async () => { + const f = setup({defaultIndex: 1}); + f.pager.goTo(2); + await advance(0, f.clock); + await advance(100, f.clock); + const onScreen = -f.x(); + expect(onScreen).toBeGreaterThan(WIDTH); + expect(onScreen).toBeLessThan(2 * WIDTH); + touch('touchstart', f.page, 300, 100, now); + await advance(8, f.clock); + touch('touchmove', f.page, 289, 100, now); + expect(f.pager.isDragging).toBe(true); + expect(f.pager.isSettling).toBe(false); + // From the caught position, 1 px past the threshold. + expect(-f.x()).toBeCloseTo(-f.animator.get('x', now), 6); + touch('touchend', f.page, 289, 100, now + 40); + }); + + it(`a still finger catches a settle ${paging.catchStopMs} ms after it lands, and the lift settles on the nearest page`, async () => { + const f = setup({defaultIndex: 1}); + f.pager.goTo(2); + await advance(0, f.clock); + await advance(60, f.clock); + touch('touchstart', f.page, 300, 100, now); + await advance(40, f.clock); + // Still moving 40 ms after the touch. + expect(f.pager.isSettling).toBe(true); + await advance(paging.catchStopMs, f.clock); + expect(f.pager.isSettling).toBe(false); + const held = -f.x(); + await advance(100, f.clock); + expect(-f.x()).toBe(held); + touch('touchend', f.page, 300, 100, now); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('loops: past the last page goes forward to a virtual page, wrapped for callbacks', async () => { + const f = setup({defaultIndex: 4, loop: true}); + await drag(f, {from: 300, dx: -250, hold: 50}); + expect(f.pager.virtualIndex).toBe(5); + expect(f.pager.index).toBe(0); + await frames(f); + expect(f.calls).toEqual(['index:0:drag']); + await settle(f); + expect(f.x()).toBe(-5 * WIDTH); + // goTo takes the nearest copy: from virtual 5 (page 0), page 4 is back one. + f.pager.goTo(4, {animated: false}); + expect(f.pager.virtualIndex).toBe(4); + }); + + it("with two pages, a loop's step forward goes forward: of two copies equally near, the one asked for", () => { + const f = setup({count: 2, loop: true}); + f.pager.goTo(f.pager.virtualIndex + 1, {animated: false}); + expect(f.pager.virtualIndex).toBe(1); + f.pager.goTo(f.pager.virtualIndex + 1, {animated: false}); + expect(f.pager.virtualIndex).toBe(2); + expect(f.x()).toBe(-2 * WIDTH); + f.pager.goTo(f.pager.virtualIndex - 1, {animated: false}); + expect(f.pager.virtualIndex).toBe(1); + // A page index takes its nearest copy as before. + f.pager.goTo(0, {animated: false}); + expect(f.pager.virtualIndex).toBe(0); + }); + + it('clamps goTo to the pages when not looping', () => { + const f = setup({defaultIndex: 0}); + f.pager.goTo(9, {animated: false}); + expect(f.pager.index).toBe(4); + f.pager.goTo(-3, {animated: false}); + expect(f.pager.index).toBe(0); + }); + + it('a resize mid-settle lands on the target page at its new offset', async () => { + let onResize: ResizeObserverCallback | null = null; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + onResize = callback; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const f = setup({defaultIndex: 0}); + f.pager.goTo(2); + await advance(0, f.clock); + await advance(100, f.clock); + expect(f.pager.isSettling).toBe(true); + Object.defineProperty(f.viewport, 'clientWidth', { + value: 300, + configurable: true, + }); + expect(onResize).not.toBeNull(); + onResize!( + [{target: f.viewport} as unknown as ResizeObserverEntry], + {} as ResizeObserver, + ); + expect(f.pager.isSettling).toBe(false); + expect(f.x()).toBe(-600); + expect(f.calls).toEqual(['index:2:goTo', 'settle:2:0']); + vi.unstubAllGlobals(); + }); + + // The shared resize observer is one per module. So this case loads a fresh + // pager module after it installs its ResizeObserver, and another case's + // observer cannot stand in for it. + it("observes the track once pages becomes 'children' after attach, and stops when it goes back", async () => { + const observed: Element[] = []; + const unobserved: Element[] = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe(element: Element) { + observed.push(element); + } + unobserve(element: Element) { + unobserved.push(element); + } + disconnect() {} + }, + ); + vi.resetModules(); + const fresh = await import('./usePager'); + const f = setup({defaultIndex: 0}); + f.pager.dispose(); + const pager = fresh.createPager( + {count: 3, defaultIndex: 0}, + {host: f.host, animator: f.animator}, + ); + pager.attach(f.viewport, f.track); + expect(observed).toContain(f.viewport); + expect(observed).not.toContain(f.track); + pager.setOptions({pages: 'children'}); + expect(observed).toContain(f.track); + pager.setOptions({pages: undefined}); + expect(unobserved).toContain(f.track); + pager.dispose(); + vi.unstubAllGlobals(); + }); + + it('a smaller count clamps the target page', () => { + const f = setup({defaultIndex: 4}); + f.pager.setOptions({count: 2}); + expect(f.pager.index).toBe(1); + }); + + // Touch motion stays internal: the paging values are constants, so no + // `--motion-paging-…` variable changes a settle or a goTo. + it('a --motion-paging-… variable changes neither the settle nor goTo', async () => { + const plain = setup({defaultIndex: 1}); + await drag(plain, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + await advance(16, plain.clock); + await advance(150, plain.clock); + const integrator = plain.x(); + + const f = setup({defaultIndex: 1}); + const scope = document.createElement('div'); + scope.setAttribute('data-astryx-theme', 'paging-test'); + scope.style.setProperty( + '--motion-paging-settle', + 'spring(response 1.2s, damping-ratio 1)', + ); + scope.style.setProperty( + '--motion-paging-animated-scroll-duration-ms', + '600ms', + ); + scope.style.setProperty( + '--motion-paging-animated-scroll-curve', + 'cubic-bezier(0, 0, 1, 1)', + ); + document.body.appendChild(scope); + scope.appendChild(f.viewport); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + await advance(16, f.clock); + await advance(150, f.clock); + expect(f.x()).toBe(integrator); + await settle(f); + expect(f.x()).toBe(-2 * WIDTH); + + // goTo plays the table's 300 ms: landed at 300 ms, not halfway. + f.pager.goTo(3); + await advance(0, f.clock); + await advance(300, f.clock); + await advance(16, f.clock); + expect(f.x()).toBe(-3 * WIDTH); + scope.remove(); + }); + + // design:reduced-motion: after the finger lifts, nothing travels. A + // finger's release settles with a cross-fade of at most 200 ms on any + // pointer (DR2). A mouse's, a trackpad's or a pen's release follows DR2 on a + // coarse primary pointer, and DR3 otherwise (spec:AST-094 FR43). A goTo + // cross-fades on a coarse pointer (DR2) and lands at once on any other + // (DR3). The drag itself follows the pointer. + function reducedPager(f: Fixture, defaultIndex: number, coarse: boolean) { + stubMedia(true, coarse); + const reduced = createAnimator(f.track, { + now: () => now, + requestFrame: callback => { + frameQueue.push(callback); + }, + }); + f.pager.dispose(); + const pager = createPager( + {count: 3, defaultIndex}, + {host: f.host, animator: reduced}, + ); + pager.attach(f.viewport, f.track); + return {pager, reduced}; + } + + /** + * Animations started after `from` that move the track through values in + * between (a cross-fade's jump holds the start, then the end: two values). + */ + function travelsSince(from: number) { + return recorded.slice(from).filter(a => { + const xs = new Set(); + for (const k of a.keyframes as Record[]) { + const v = k.translate ?? k.transform; + if (typeof v === 'string') { + xs.add(v); + } + } + return xs.size > 2; + }); + } + + /** + * How long `property` keeps changing. The animator compiles a long animation + * that holds its last value. So this is the offset of the first keyframe + * that stays at the end value, times the duration. + */ + function activeMs(animation: Recorded, property: string): number { + const frames = animation.keyframes as Record[]; + const values = frames.map(k => { + const v = k[property]; + return typeof v === 'string' || typeof v === 'number' ? String(v) : ''; + }); + const end = values[values.length - 1]; + let i = values.length - 1; + while (i > 0 && values[i - 1] === end) { + i--; + } + const offset = Number( + frames[i]?.offset ?? i / Math.max(1, frames.length - 1), + ); + return offset * Number(animation.options.duration ?? 0); + } + + function fadesSince(from: number) { + return recorded + .slice(from) + .filter(a => + a.keyframes.some(k => (k as Record).opacity != null), + ); + } + + it('under Reduce Motion, a release settle does not travel: an opacity cross-fade of at most 200 ms on a coarse pointer (a touch release, DR2)', async () => { + const f = setup({defaultIndex: 1}); + const {pager, reduced} = reducedPager(f, 1, true); + // The pointer this case claims, as the animator sees it. + expect(reduced.reducedMotion()).toBe('crossfade'); + expect(reduced.reducedMotion({touchRelease: true})).toBe('crossfade'); + const before = recorded.length; + await drag( + {...f, pager, animator: reduced, x: () => reduced.get('x', now)}, + {from: 300, dx: -250, step: 4, dt: 8, hold: 100}, + ); + await frames(f, 40); + // The drag itself writes the track (no animation); every animation is + // the settle's. + expect(travelsSince(before)).toEqual([]); + const fades = fadesSince(before); + expect(fades.length).toBeGreaterThan(0); + for (const fade of fades) { + expect(activeMs(fade, 'opacity')).toBeLessThanOrEqual(200); + } + expect(-reduced.get('x', now)).toBeCloseTo(2 * WIDTH, 0); + }); + + // spec:AST-094 FR43: under reduced motion a release from a mouse, a + // trackpad, or a pen follows DR2 on a coarse primary pointer and DR3 + // otherwise. + for (const coarse of [false, true]) { + it(`under Reduce Motion, a mouse drag's release follows the primary pointer (spec:AST-094 FR43): ${coarse ? 'coarse, an opacity cross-fade of at most 200 ms (DR2)' : 'fine, at once with no cross-fade (DR3)'}`, async () => { + const f = setup({defaultIndex: 1}); + const {pager, reduced} = reducedPager(f, 1, coarse); + const before = recorded.length; + mouse('pointerdown', f.page, 300, 100, now); + for (let i = 1; i <= 62; i++) { + await advance(8, f.clock); + mouse('pointermove', f.page, 300 - 4 * i, 100, now); + } + await advance(8, f.clock); + mouse('pointerup', f.page, 52, 100, now); + await frames(f, 40); + expect(pager.index).toBe(2); + expect(travelsSince(before)).toEqual([]); + const fades = fadesSince(before); + if (coarse) { + expect(fades.length).toBeGreaterThan(0); + for (const fade of fades) { + expect(activeMs(fade, 'opacity')).toBeLessThanOrEqual(200); + } + } else { + expect(fades).toEqual([]); + } + expect(-reduced.get('x', now)).toBeCloseTo(2 * WIDTH, 0); + }); + } + + it("under Reduce Motion on a fine primary pointer, a trackpad scroll's release lands at once (spec:AST-094 FR43, DR3)", async () => { + const f = setup({defaultIndex: 1}); + const {pager, reduced} = reducedPager(f, 1, false); + const before = recorded.length; + // A fling: the fingers' lift releases it with their speed. + await fling(f, 6.5, 12); + await frames(f, 40); + expect(pager.index).toBe(2); + expect(travelsSince(before)).toEqual([]); + expect(fadesSince(before)).toEqual([]); + expect(-reduced.get('x', now)).toBeCloseTo(2 * WIDTH, 0); + // The momentum's stream ends 220 ms after its last wheel. + await advance(240, f.clock); + f.clock.runDue(now); + }); + + for (const coarse of [true, false]) { + it(`under Reduce Motion, goTo does not travel (${coarse ? 'coarse pointer: an opacity cross-fade of at most 200 ms, DR2' : 'fine pointer: at once, DR3'})`, async () => { + const f = setup({defaultIndex: 0}); + const {pager, reduced} = reducedPager(f, 0, coarse); + // The pointer this case claims, as the animator sees it. + expect(reduced.reducedMotion()).toBe(coarse ? 'crossfade' : 'instant'); + const before = recorded.length; + pager.goTo(1); + await frames(f, 30); + expect(travelsSince(before)).toEqual([]); + const fades = fadesSince(before); + if (coarse) { + expect(fades.length).toBeGreaterThan(0); + for (const fade of fades) { + expect(activeMs(fade, 'opacity')).toBeLessThanOrEqual(200); + } + } else { + expect(fades).toEqual([]); + } + expect(-reduced.get('x', now)).toBeCloseTo(WIDTH, 0); + }); + } + + // spec:AST-094 FR43: gestures work for every pointer. A mouse drags the + // pager as a finger does. + it('on a fine primary pointer a mouse drag pages the pager like a finger (spec:AST-094 FR43)', async () => { + const f = setup({defaultIndex: 1}); + stubMedia(false, false); + f.pager.dispose(); + const pager = createPager( + {count: 3, defaultIndex: 1}, + {host: f.host, animator: f.animator}, + ); + pager.attach(f.viewport, f.track); + mouse('pointerdown', f.page, 300, 100, now); + for (let i = 1; i <= 62; i++) { + await advance(8, f.clock); + mouse('pointermove', f.page, 300 - 4 * i, 100, now); + } + await advance(8, f.clock); + mouse('pointerup', f.page, 52, 100, now); + await frames(f, 2); + // 500 px/s at the lift, past the advance speed: the next page. + expect(pager.index).toBe(2); + for (let i = 0; i < 80 && pager.isSettling; i++) { + await advance(16, f.clock); + } + expect(f.x()).toBe(-2 * WIDTH); + pager.dispose(); + }); + + // spec:AST-094 FR43: on the web a pan also takes wheel and trackpad + // scrolling, along its own axis. + async function wheelPager( + defaultIndex: number, + width = WIDTH, + options: Partial = {}, + ) { + const f = setup({defaultIndex}); + stubMedia(false, false); + f.pager.dispose(); + Object.defineProperty(f.viewport, 'clientWidth', { + value: width, + configurable: true, + }); + const pager = createPager( + {count: 3, defaultIndex, ...options}, + {host: f.host, animator: f.animator}, + ); + pager.attach(f.viewport, f.track); + return {f, pager}; + } + + async function endWheel(f: Fixture) { + // A trackpad swipe ends 220 ms after its last wheel, a mouse wheel's + // notches 100 ms after theirs, both still: position alone decides. + await advance(240, f.clock); + f.clock.runDue(now); + await frames(f, 2); + } + + // Each wheel case disposes its pager even when an assertion fails, so a + // wheel gesture it left live can never take the next case's wheels. + it("takes a trackpad's horizontal scroll as a drag (spec:AST-094 FR43): the content follows it, and a fling releases it where the fingers lift, at their speed", async () => { + const {f, pager} = await wheelPager(1); + try { + const events: WheelEvent[] = []; + // 6.5 px every 8 ms (about 810 px/s), a trackpad's continuous scroll. + for (let i = 0; i < 12; i++) { + events.push(trackpad(f.page, {dx: 6.5, time: now})); + await advance(8, f.clock); + } + expect(events.every(event => event.defaultPrevented)).toBe(true); + expect(pager.isDragging).toBe(true); + // A scroll toward later content drags the track toward its start. + expect(f.x()).toBeLessThan(-WIDTH); + // The fingers lift: 78 px at about 810 px/s, a flick to the next page. + const lift = await fling(f, 6.5, 0); + expect(lift.every(event => event.defaultPrevented)).toBe(true); + await frames(f, 2); + expect(pager.index).toBe(2); + for (let i = 0; i < 80 && pager.isSettling; i++) { + await advance(16, f.clock); + } + expect(f.x()).toBe(-2 * WIDTH); + } finally { + pager.dispose(); + } + }); + + // spec:AST-094 FR43, DEC-24: a pan whose stream stops releases still. + it('a slow 60 px trackpad swipe that stops releases still and returns to its page', async () => { + const {f, pager} = await wheelPager(1); + try { + // 2 px every 20 ms, 60 px in all (50 past the pan's 10 px start), + // then the fingers rest. + let furthest = -WIDTH; + for (let i = 0; i < 30; i++) { + trackpad(f.page, {dx: 2, time: now}); + await advance(20, f.clock); + furthest = Math.min(furthest, f.x()); + } + expect(furthest).toBeCloseTo(-WIDTH - 50, 6); + await endWheel(f); + expect(pager.index).toBe(1); + for (let i = 0; i < 80 && pager.isSettling; i++) { + await advance(16, f.clock); + } + await advance(16, f.clock); + expect(f.x()).toBe(-WIDTH); + } finally { + pager.dispose(); + } + }); + + // spec:AST-094 FR43, DEC-24: a pan follows a trackpad's scroll and never a + // mouse wheel's. + it("leaves a mouse wheel's notches to the page: the pager follows a trackpad's scroll only", async () => { + const {f, pager} = await wheelPager(1); + try { + // Whole 100 px notches on the horizontal axis (a tilt wheel, or Shift + // with a wheel): discrete scrolling, which the pager does not take. + const notches: WheelEvent[] = []; + for (let i = 0; i < 3; i++) { + notches.push(wheel(f.page, {dx: 100, time: now})); + await advance(30, f.clock); + } + await endWheel(f); + expect(notches.some(event => event.defaultPrevented)).toBe(false); + expect(pager.isDragging).toBe(false); + expect(pager.index).toBe(1); + expect(f.x()).toBe(-WIDTH); + // A trackpad's continuous scroll over the same pager still drags it. + const scroll: WheelEvent[] = []; + for (let i = 0; i < 6; i++) { + scroll.push(trackpad(f.page, {dx: 6.5, time: now})); + await advance(8, f.clock); + } + expect(scroll.every(event => event.defaultPrevented)).toBe(true); + expect(pager.isDragging).toBe(true); + expect(f.x()).toBeLessThan(-WIDTH); + } finally { + pager.dispose(); + } + }); + + // A scroll without the trackpad's signature is a mouse wheel's (FR43): + // lines, a whole tick, and deltas with an accelerated mouse's remainder. + for (const [name, init] of [ + ['lines', {dx: 3, mode: 1}], + ['ticks: 40 px under a 120 legacy tick', {dx: 40, legacy: {x: -120, y: 0}}], + [ + "accelerated deltas: a hair off whole pixels, under a trackpad's legacy delta", + {dx: 12.004, legacy: {x: -36.012, y: 0}}, + ], + ] as const) { + it(`leaves a mouse wheel's ${name} to the page`, async () => { + const {f, pager} = await wheelPager(1); + try { + const events: WheelEvent[] = []; + for (let i = 0; i < 3; i++) { + events.push(wheel(f.page, {...init, time: now})); + await advance(30, f.clock); + } + await endWheel(f); + expect(events.some(event => event.defaultPrevented)).toBe(false); + expect(pager.index).toBe(1); + expect(f.x()).toBe(-WIDTH); + } finally { + pager.dispose(); + } + }); + } + + // A mouse drags as a finger does (FR43): a short fast drag pages, as a + // finger's flick does. + it("a mouse's short fast drag pages, as a finger's flick does", async () => { + const {f, pager} = await wheelPager(1); + try { + mouse('pointerdown', f.page, 300, 100, now); + // 10 px every 8 ms (1,250 px/s), 30 px in all. + for (let i = 1; i <= 3; i++) { + await advance(8, f.clock); + mouse('pointermove', f.page, 300 - 10 * i, 100, now); + } + await advance(8, f.clock); + mouse('pointerup', f.page, 270, 100, now); + await frames(f, 2); + expect(pager.index).toBe(2); + } finally { + pager.dispose(); + } + }); + + // spec:AST-094 FR43, DEC-24: a pager follows a trackpad swipe at most one + // page from where it began, rubber-banded at its first and last page. It + // commits past half a page or on a flick. A speed spike that moved it under + // a tenth of a page is not a flick. A swipe that lands during a settle + // starts from that settle's page. + it('a trackpad scroll never carries the track more than one page from the page it began on, and lands on that page', async () => { + const {f, pager} = await wheelPager(0); + try { + // 25 px every 8 ms, 1,000 px in all: two and a half pages. + let furthest = 0; + for (let i = 0; i < 40; i++) { + trackpad(f.page, {dx: 25, time: now}); + await advance(8, f.clock); + furthest = Math.min(furthest, f.x()); + } + // The track reaches the next page and stops there. + expect(furthest).toBeGreaterThanOrEqual(-WIDTH - 0.5); + expect(furthest).toBeLessThanOrEqual(-WIDTH + 0.5); + await endWheel(f); + expect(pager.index).toBe(1); + } finally { + pager.dispose(); + } + }); + + // A looping pager has no first or last page. The cap holds across the loop + // too, from the last page onto the copy of the first. + it('in a looping pager, a trackpad scroll never carries the track more than one page from the page it began on, and lands on that page', async () => { + const {f, pager} = await wheelPager(2, WIDTH, {loop: true}); + try { + // 25 px every 8 ms, 1,000 px in all, from the last page forward. + let furthest = 0; + for (let i = 0; i < 40; i++) { + trackpad(f.page, {dx: 25, time: now}); + await advance(8, f.clock); + furthest = Math.min(furthest, f.x()); + } + // The track reaches the copy of page 0 past the last page, and stops. + expect(furthest).toBeGreaterThanOrEqual(-3 * WIDTH - 0.5); + expect(furthest).toBeLessThanOrEqual(-3 * WIDTH + 0.5); + await endWheel(f); + expect(pager.virtualIndex).toBe(3); + expect(pager.index).toBe(0); + } finally { + pager.dispose(); + } + }); + + // A trackpad swipe that moved the pager under a tenth of a page releases + // still, at any speed: a speed spike is not a flick. A finger's or a + // mouse's flick still pages from a few percent (above). + it('a 42 px trackpad spike on a 480 px page releases still, whatever its speed', async () => { + const width = 480; + const {f, pager} = await wheelPager(1, width); + try { + // 7.5 px every 8 ms, then the fingers lift. The pan moves at their + // speed, about 940 px/s (past the advance speed), until the lift is + // read. It releases there with that speed. It moved the track 42 px: + // more than a tenth of a 400 px page, less than a tenth of this one. + let moved = 0; + for (const dx of [7.5, 7.5, 7.5, 7.125, 6.75, 6.375, 6]) { + trackpad(f.page, {dx, time: now}); + // Read before any frame runs: the last read is where it released. + moved = Math.max(moved, -width - f.x()); + await advance(8, f.clock); + } + expect(moved).toBeGreaterThan(40); + expect(moved).toBeLessThan(48); + await frames(f, 2); + expect(pager.index).toBe(1); + // As settle() waits: the rest lands on the frame after the settle. + for (let i = 0; i < 80 && pager.isSettling; i++) { + await advance(16, f.clock); + } + await advance(16, f.clock); + expect(f.x()).toBe(-width); + } finally { + pager.dispose(); + } + }); + + // The tenth is of the swipe's own pages. On 150 px children pages 10 px + // apart, a tenth of a page is 16 px, under a tenth of the 400 px viewport. + it('a trackpad swipe is measured in its own pages: a fast swipe past a tenth of a 150 px child pages on', async () => { + const f = setupChildren([150, 150, 150, 150, 150], 10); + stubMedia(false, false); + try { + // 6 px every 8 ms, then the fingers lift: about 750 px/s. It moved the + // track more than a tenth of a child's page, but less than a tenth of + // the viewport. So it keeps its speed and pages. + let moved = 0; + for (const dx of [6, 6, 6, 5.7, 5.4, 5.1, 4.8]) { + trackpad(f.page, {dx, time: now}); + // Read before any frame runs: the last read is where it released. + moved = Math.max(moved, -f.x()); + await advance(8, f.clock); + } + expect(moved).toBeGreaterThan(16); + expect(moved).toBeLessThan(40); + await frames(f, 2); + expect(f.pager.index).toBe(1); + // As settle() waits: the rest lands on the frame after the settle. + for (let i = 0; i < 80 && f.pager.isSettling; i++) { + await advance(16, f.clock); + } + await advance(16, f.clock); + expect(f.x()).toBe(-160); + } finally { + f.pager.dispose(); + } + }); + + it("a trackpad swipe that lands while the last swipe's settle runs starts from that settle's page: back-to-back swipes page on", async () => { + const {f, pager} = await wheelPager(0); + try { + // The first swipe: 78 px at about 810 px/s, then the fingers lift: a + // flick to page 1, settling from the lift. + await fling(f, 6.5, 12); + await frames(f, 2); + expect(pager.index).toBe(1); + expect(pager.isSettling).toBe(true); + // At once, early in that settle and nearer page 0 than page 1, a + // second swipe of 600 px. Its first delta grows against the lift's + // decay, which momentum never does, so it is a new swipe. From page 1 + // it may reach page 2, and lands there. + expect(f.x()).toBeGreaterThan(-WIDTH / 2); + for (let i = 0; i < 50; i++) { + trackpad(f.page, {dx: 12, time: now}); + await advance(8, f.clock); + } + await endWheel(f); + expect(pager.index).toBe(2); + for (let i = 0; i < 80 && pager.isSettling; i++) { + await advance(16, f.clock); + } + expect(f.x()).toBe(-2 * WIDTH); + } finally { + pager.dispose(); + } + }); + + it('leaves a vertical wheel over the pager to the page (spec:AST-094 FR43)', async () => { + const {f, pager} = await wheelPager(1); + try { + const events: WheelEvent[] = []; + for (let i = 0; i < 6; i++) { + events.push(wheel(f.page, {dy: 12.5, time: now})); + await advance(8, f.clock); + } + await endWheel(f); + expect(events.some(event => event.defaultPrevented)).toBe(false); + expect(pager.isDragging).toBe(false); + expect(pager.index).toBe(1); + expect(f.x()).toBe(-WIDTH); + } finally { + pager.dispose(); + } + }); +}); + +/** A rect for jsdom, which lays nothing out. */ +function rectOf(left: number, width: number): DOMRect { + return new DOMRect(left, 0, width, 100); +} + +/** + * A pager whose pages are the track's children (`pages: 'children'`). The + * children are `gap` px apart, laid out from the start edge of a WIDTH-wide + * viewport, the right edge in RTL. + */ +function setupChildren( + widths: number[], + gap: number, + options: Partial = {}, + rtl = false, +): Fixture { + const clock = new ManualClock(); + clock.time = now; + const host = new GestureHost(document, {clock}); + const viewport = document.createElement('div'); + const track = document.createElement('div'); + if (rtl) { + viewport.style.direction = 'rtl'; + } + let start = 0; + for (const width of widths) { + const child = document.createElement('div'); + const from = start; + child.getBoundingClientRect = () => + rtl ? rectOf(WIDTH - from - width, width) : rectOf(from, width); + track.appendChild(child); + start += width + gap; + } + const extent = start - gap; + track.getBoundingClientRect = () => + rtl ? rectOf(WIDTH - extent, extent) : rectOf(0, extent); + viewport.appendChild(track); + document.body.appendChild(viewport); + Object.defineProperty(viewport, 'clientWidth', { + value: WIDTH, + configurable: true, + }); + const animator = createAnimator(track, { + now: () => now, + requestFrame: callback => { + frameQueue.push(callback); + }, + }); + const calls: string[] = []; + const pager = createPager( + { + count: widths.length, + pages: 'children', + onIndexChange: (index, reason) => calls.push(`index:${index}:${reason}`), + onSettle: (index, previous) => calls.push(`settle:${index}:${previous}`), + ...options, + }, + { + host, + animator, + requestFrame: callback => { + frameQueue.push(callback); + }, + }, + ); + pager.attach(viewport, track); + return { + pager, + viewport, + track, + page: track.children[0] as HTMLElement, + animator, + clock, + host, + calls, + x: () => animator.get('x', now), + }; +} + +describe("children pages: each child's start edge, as a snap container places it", () => { + // Five 150 px children 10 px apart in a 400 px viewport start at 0, 160, + // 320, 480 and 640. The track ends at 790, so its end meets the viewport's + // end at 390, where the last two children rest. + const CARDS = [150, 150, 150, 150, 150]; + + it("measures one page per child start, the last children sharing the page where the track's end meets the viewport's", () => { + const f = setupChildren(CARDS, 10); + expect(f.pager.pageCount).toBe(4); + f.pager.goTo(2, {animated: false}); + expect(f.x()).toBe(-320); + f.pager.goTo(4, {animated: false}); + expect(f.pager.index).toBe(3); + expect(f.x()).toBe(-390); + }); + + it('a flick moves one child, on the paging settle', async () => { + const f = setupChildren(CARDS, 10); + // 625 px/s, lifted 32 ms after the last move. + await drag(f, {from: 300, dx: -50, step: 10, dt: 16, hold: 16}); + expect(f.pager.index).toBe(1); + await settle(f); + expect(f.x()).toBe(-160); + expect(f.calls).toEqual(['index:1:drag', 'settle:1:0']); + }); + + it('a slow drag past half the way to the next child settles there; short of half, back', async () => { + const f = setupChildren(CARDS, 10); + // The finger moves 100 px. Past the pan's 10 pt claim, the content + // travels 90 px, past half of the 160 px to the next child. + await drag(f, {from: 300, dx: -100, step: 2, dt: 16, hold: 100}); + await settle(f); + expect(f.pager.index).toBe(1); + expect(f.x()).toBe(-160); + // 70 px of the 160 px back toward the first child: short of half. + await drag(f, {from: 100, dx: 70, step: 2, dt: 16, hold: 100}); + await settle(f); + expect(f.pager.index).toBe(1); + expect(f.x()).toBe(-160); + }); + + it('past the last page the track rubber-bands and returns: children pages never loop', async () => { + const f = setupChildren(CARDS, 10, {loop: true}); + f.pager.goTo(3, {animated: false}); + await drag(f, {from: 300, dx: -100, step: 10, dt: 16, hold: 16}); + expect(f.pager.index).toBe(3); + await settle(f); + expect(f.x()).toBe(-390); + f.pager.goTo(-1, {animated: false}); + expect(f.pager.index).toBe(0); + }); + + it('content that fits is one page: nothing pages', async () => { + const f = setupChildren([150, 150], 10); + expect(f.pager.pageCount).toBe(1); + await drag(f, {from: 300, dx: -50, step: 10, dt: 16, hold: 16}); + await settle(f); + expect(f.pager.index).toBe(0); + expect(f.x()).toBe(0); + }); + + it('reads where each page rests and the page nearest an offset', () => { + const f = setupChildren(CARDS, 10); + expect([0, 1, 2, 3].map(i => f.pager.pageOffset(i))).toEqual([ + 0, 160, 320, 390, + ]); + expect(f.pager.nearestPage(70)).toBe(0); + expect(f.pager.nearestPage(90)).toBe(1); + expect(f.pager.nearestPage(370)).toBe(3); + expect(f.pager.nearestPage(5000)).toBe(3); + }); + + it('a child added to the track is measured on the next options change, and reported from the next frame', async () => { + const pages: number[] = []; + const f = setupChildren(CARDS, 10, { + onPagesChange: pageCount => pages.push(pageCount), + }); + const child = document.createElement('div'); + child.getBoundingClientRect = () => rectOf(800, 150); + f.track.appendChild(child); + f.track.getBoundingClientRect = () => rectOf(0, 950); + f.pager.setOptions({count: 6}); + expect(f.pager.pageCount).toBe(5); + expect(pages).toEqual([]); + await advance(16, f.clock); + expect(pages).toEqual([5]); + }); + + it("under a CSS zoom the rects are scaled; the pages are measured in the track's own pixels", () => { + const f = setupChildren(CARDS, 10); + // Rects at twice the size, as an ancestor's \`zoom: 2\` reports them; + // the track's own width stays 790 CSS px. + const children = Array.from(f.track.children) as HTMLElement[]; + children.forEach((child, i) => { + child.getBoundingClientRect = () => rectOf(2 * i * 160, 2 * 150); + }); + f.track.getBoundingClientRect = () => rectOf(0, 2 * 790); + f.track.style.width = '790px'; + f.pager.setOptions({count: 5}); + expect([0, 1, 2, 3].map(i => f.pager.pageOffset(i))).toEqual([ + 0, 160, 320, 390, + ]); + }); + + it('RTL: pages measure from the right edge, and a drag right goes forward', async () => { + const f = setupChildren(CARDS, 10, {}, true); + expect(f.pager.pageCount).toBe(4); + await drag(f, {from: 100, dx: 50, step: 10, dt: 16, hold: 16}); + expect(f.pager.index).toBe(1); + await settle(f); + expect(f.x()).toBe(160); + }); +}); + +describe("the drag's axis: UIKit's rule for a pager inside a scroll view", () => { + /** + * A drag `degrees` up from leftward, in 4 px steps every 8 ms, then a lift. + * Returns whether each move was cancelled (a cancelled move is Safari + * giving the page no scroll for the whole touch). + */ + async function angledDrag( + f: Fixture, + degrees: number, + length = 150, + ): Promise { + const rad = (degrees * Math.PI) / 180; + const x0 = 300; + const y0 = 200; + touch('touchstart', f.page, x0, y0, now); + const steps = Math.round(length / 4); + const cancelled: boolean[] = []; + for (let i = 1; i <= steps; i++) { + await advance(8, f.clock); + const d = (length * i) / steps; + const move = touch( + 'touchmove', + f.page, + x0 - d * Math.cos(rad), + y0 - d * Math.sin(rad), + now, + ); + cancelled.push(move.defaultPrevented); + } + await advance(8, f.clock); + touch( + 'touchend', + f.page, + x0 - length * Math.cos(rad), + y0 - length * Math.sin(rad), + now, + ); + return cancelled; + } + + for (const degrees of [35, 45]) { + it(`a ${degrees}° drag is the pager's (|dx| ≥ |dy|, the tie included): it pages, and its moves are cancelled from the pan's start at 10 pt, not before`, async () => { + const f = setup(); + const cancelled = await angledDrag(f, degrees); + // 4 px steps: the horizontal part reaches the pan's 10 pt hysteresis on + // the fourth move. Nothing claims the touch before the pan begins + // (no earlier committed claim); every move from then on is cancelled. + const start = 3; + expect(cancelled.slice(0, start).some(Boolean)).toBe(false); + expect(cancelled.slice(start).every(Boolean)).toBe(true); + await frames(f, 40); + expect(f.calls).toContain('index:1:drag'); + }); + } + + it("a 50° drag is the page's: the pager stays and cancels nothing", async () => { + const f = setup(); + const cancelled = await angledDrag(f, 50); + expect(cancelled.some(Boolean)).toBe(false); + await frames(f, 40); + expect(f.x()).toBe(0); + expect(f.calls).toEqual([]); + }); + + it('inside a sheet (an enclosing vertical pan takes part in the touch), a 35° drag moves neither, as UIKit does', async () => { + const f = setup(); + const sheetPan = new PanGestureRecognizer({direction: 'vertical'}); + const detach = f.host.attach(document.body, sheetPan); + try { + const cancelled = await angledDrag(f, 35); + expect(cancelled.some(Boolean)).toBe(false); + await frames(f, 40); + expect(f.x()).toBe(0); + expect(f.calls).toEqual([]); + expect(sheetPan.state === 'began' || sheetPan.state === 'changed').toBe( + false, + ); + } finally { + detach(); + } + }); +}); + +describe('review fixes', () => { + // spec:AST-094 FR41: a hold that does nothing keeps the browser's native + // long-press selection; stopping a settle does something. + it('a finger on a resting track leaves its text selectable; one that catches a settle locks it until the lift', async () => { + const f = setup({defaultIndex: 0}); + // Whether a selection that starts on the page's text is refused. Where + // iOS WebKit does not run (jsdom), the shared lock cancels `selectstart` + // and writes no style. + const refuses = () => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + f.page.dispatchEvent(event); + return event.defaultPrevented; + }; + touch('touchstart', f.page, 200, 100, now); + await advance(16, f.clock); + expect(refuses()).toBe(false); + touch('touchend', f.page, 200, 100, now); + await advance(16, f.clock); + f.pager.goTo(3); + await advance(16, f.clock); + await advance(16, f.clock); + expect(f.pager.isSettling).toBe(true); + touch('touchstart', f.page, 200, 100, now); + await advance(16, f.clock); + expect(refuses()).toBe(true); + await advance(120, f.clock); + touch('touchend', f.page, 200, 100, now); + await advance(16, f.clock); + expect(refuses()).toBe(false); + expect(f.track.style.getPropertyValue('user-select')).toBe(''); + }); + + // spec:AST-017 FR59: a mouse keeps its released behavior. Its press stops a + // settle as a finger's does (spec:AST-094 FR43) and locks no selection. + it('a mouse that catches a settle stops it and leaves the text selectable', async () => { + const f = setup({defaultIndex: 0}); + stubMedia(false, false); + const refuses = () => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + f.page.dispatchEvent(event); + return event.defaultPrevented; + }; + f.pager.goTo(3); + await advance(16, f.clock); + await advance(16, f.clock); + expect(f.pager.isSettling).toBe(true); + mouse('pointerdown', f.page, 200, 100, now); + await advance(16, f.clock); + expect(refuses()).toBe(false); + // The catch stops the settle after UIKit's catch delay, as a finger's. + await advance(120, f.clock); + expect(f.pager.isSettling).toBe(false); + expect(refuses()).toBe(false); + mouse('pointerup', f.page, 200, 100, now); + await advance(16, f.clock); + expect(refuses()).toBe(false); + expect(f.track.style.getPropertyValue('user-select')).toBe(''); + }); + + // spec:AST-094 FR6. + it('a goTo that retargets a running move keeps its velocity, read from the curve', async () => { + const f = setup({defaultIndex: 0}); + f.pager.goTo(2); + await advance(16, f.clock); + await advance(16, f.clock); + await advance(16, f.clock); + const before = f.animator.getVelocity('x', now); + expect(Math.abs(before)).toBeGreaterThan(1000); + f.pager.goTo(3); + expect(f.animator.getVelocity('x', now)).toBeCloseTo(before, -1); + await settle(f); + expect(f.x()).toBe(-3 * WIDTH); + }); + + // spec:AST-094 FR37. + it('a release moving back toward the start page settles there, whatever the travel', async () => { + const f = setup({defaultIndex: 1}); + // One touch: 70 % of the way to page 2, then back 20 px at 125 px/s, + // lifted while moving back. + let x = 350; + touch('touchstart', f.page, x, 100, now); + for (let i = 0; i < 70; i++) { + await advance(8, f.clock); + x -= 4; + touch('touchmove', f.page, x, 100, now); + } + for (let i = 0; i < 10; i++) { + await advance(16, f.clock); + x += 2; + touch('touchmove', f.page, x, 100, now); + } + touch('touchend', f.page, x, 100, now); + await settle(f); + expect(f.pager.index).toBe(1); + expect(f.x()).toBe(-WIDTH); + }); + + // react-component-runtime INV2: setOptions and a controlled goTo run from + // the owner's Effect, so nothing they cause may call back from inside it. + it('a gap change before a release reports keeps the report and delivers it, and the settle, from a frame callback', async () => { + const f = setup({defaultIndex: 1}); + await drag(f, {from: 300, dx: -250, step: 4, dt: 8, hold: 100}); + expect(f.pager.index).toBe(2); + expect(f.calls).toEqual([]); + f.pager.setOptions({gap: 10}); + // Nothing from inside the update. + expect(f.calls).toEqual([]); + expect(f.x()).toBe(-2 * (WIDTH + 10)); + await advance(16, f.clock); + expect(f.calls).toEqual(['index:2:drag', 'settle:2:1']); + }); + + it('a controlled move calls nothing from inside the update: its settle comes on the next frame', async () => { + const f = setup({defaultIndex: 1}); + f.pager.goTo(3, {animated: false, reason: 'controlled'}); + expect(f.calls).toEqual([]); + await advance(16, f.clock); + expect(f.calls).toEqual(['settle:3:1']); + }); + + it('a count that clamps the page calls nothing from inside the update', async () => { + const f = setup({defaultIndex: 4}); + f.pager.setOptions({count: 3}); + expect(f.pager.index).toBe(2); + expect(f.calls).toEqual([]); + await advance(16, f.clock); + expect(f.calls).toEqual(['settle:2:4']); + }); + + it('callbacks an update left for the next frame are dropped when the pager detaches first', async () => { + const f = setup({defaultIndex: 1}); + f.pager.goTo(3, {animated: false, reason: 'controlled'}); + f.pager.dispose(); + await advance(16, f.clock); + expect(f.calls).toEqual([]); + }); + + it('a vertical swipe that starts on a settling track still reaches the page: the catch never cancels its moves', async () => { + const f = setup(); + await drag(f, {dx: -250, step: 10, dt: 8}); + await frames(f, 2); + expect(f.pager.isSettling).toBe(true); + touch('touchstart', f.page, 200, 100, now); + const cancelled: boolean[] = []; + for (let i = 1; i <= 10; i++) { + await advance(8, f.clock); + cancelled.push( + touch('touchmove', f.page, 200, 100 - i * 6, now).defaultPrevented, + ); + } + await advance(8, f.clock); + touch('touchend', f.page, 200, 40, now); + expect(cancelled.some(Boolean)).toBe(false); + }); + + it('a drag that catches an overscrolled track starts from under the finger, with no jump', async () => { + const f = setup(); + // Pulled right past page 0 and released: the track springs back from x > 0. + await drag(f, {dx: 160, step: 10, dt: 8}); + await frames(f, 2); + const caught = f.x(); + expect(caught).toBeGreaterThan(10); + touch('touchstart', f.page, 200, 100, now); + const xs: number[] = []; + for (const dx of [4, 8, 12, 14, 15]) { + await advance(8, f.clock); + touch('touchmove', f.page, 200 + dx, 100, now); + xs.push(f.x()); + } + // Each move shifts the track by at most the finger's step (banding only + // shrinks it): no move jumps. + for (let i = 1; i < xs.length; i++) { + expect(Math.abs(xs[i] - xs[i - 1])).toBeLessThanOrEqual(4.01); + } + expect(Math.abs(xs[0] - caught)).toBeLessThan(30); + }); + + it('a gap change keeps the track on its page', () => { + const f = setup({gap: 0, defaultIndex: 3}); + expect(f.x()).toBe(-3 * WIDTH); + f.pager.setOptions({gap: 40}); + expect(f.x()).toBe(-3 * (WIDTH + 40)); + }); + + it('turning loop off on a virtual page lands on the page shown', () => { + const f = setup({loop: true}); + // Forward one page at a time, past the last page twice over: virtual 7. + for (const page of [1, 2, 3, 4, 0, 1, 2]) { + f.pager.goTo(page, {animated: false}); + } + expect(f.pager.virtualIndex).toBe(7); + expect(f.pager.index).toBe(2); + f.pager.setOptions({loop: false}); + expect(f.pager.index).toBe(2); + expect(f.x()).toBe(-2 * WIDTH); + }); + + it('one onSettle per settle when a finger lands as the settle ends and holds', async () => { + const f = setup(); + f.pager.goTo(1); + // 50 ms before setContentOffset's 300 ms ends, a finger lands and holds. + await advance(250, f.clock); + touch('touchstart', f.page, 200, 100, now); + await advance(400, f.clock); + touch('touchend', f.page, 200, 100, now); + await frames(f, 40); + expect(f.calls.filter(c => c.startsWith('settle:'))).toEqual([ + 'settle:1:0', + ]); + }); +}); + +/** The owner replaces the viewport node: the track moves into a new one. */ +function replaceViewport(f: Fixture): HTMLElement { + const next = document.createElement('div'); + Object.defineProperty(next, 'clientWidth', { + value: WIDTH, + configurable: true, + }); + next.appendChild(f.track); + f.viewport.replaceWith(next); + f.pager.attach(next, f.track); + return next; +} + +describe('pre-review fixes', () => { + it('replacing the viewport mid-drag ends the drag on its start page, with no silent page change', async () => { + const f = setup(); + touch('touchstart', f.page, 300, 100, now); + for (let i = 1; i <= 30; i++) { + await advance(8, f.clock); + touch('touchmove', f.page, 300 - i * 8, 100, now); + } + expect(f.pager.isDragging).toBe(true); + replaceViewport(f); + await frames(f); + await settle(f); + expect(f.pager.index).toBe(0); + expect(f.x()).toBeCloseTo(0, 5); + expect(f.calls.filter(call => call.startsWith('index:'))).toEqual([]); + }); + + it('a release whose report still waits when the viewport is replaced is reported, and its settle ends', async () => { + const f = setup(); + await drag(f, {dx: -240}); + expect(f.calls).toEqual([]); + replaceViewport(f); + await frames(f); + expect(f.pager.index).toBe(1); + expect(f.x()).toBeCloseTo(-WIDTH, 5); + expect(f.calls).toEqual(['index:1:drag', 'settle:1:0']); + }); + + it('with the pan off, a touch on the track does not stop a goTo', async () => { + const f = setup(); + f.pager.goTo(2); + await advance(16, f.clock); + expect(f.pager.isSettling).toBe(true); + f.pager.setPanEnabled(false); + touch('touchstart', f.page, 200, 100, now); + await advance(paging.catchStopMs + 30, f.clock); + expect(f.pager.isSettling).toBe(true); + touch('touchend', f.page, 200, 100, now); + await settle(f); + expect(f.pager.index).toBe(2); + expect(f.calls).toEqual(['index:2:goTo', 'settle:2:0']); + }); + + it('nearestPage before the first measure is the target page, never NaN', () => { + const pager = createPager({count: 5, defaultIndex: 2}); + expect(pager.nearestPage(0)).toBe(2); + expect(pager.nearestPage(800)).toBe(2); + }); + + it("the pan's published name is neutral, and an owner can name it", () => { + expect(createPager({count: 3}).pan.name).toBe('pager'); + expect( + createPager({count: 3, panOptions: {name: 'Gallery.pager'}}).pan.name, + ).toBe('Gallery.pager'); + }); + + it("frames come from the viewport's own window, such as an iframe's", () => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const view = frame.contentWindow as Window & typeof globalThis; + const doc = frame.contentDocument as Document; + const inner = vi.fn((_callback: FrameRequestCallback) => 1); + view.requestAnimationFrame = inner; + const outer = vi.fn((_callback: FrameRequestCallback) => 1); + vi.stubGlobal('requestAnimationFrame', outer); + const viewport = doc.createElement('div'); + const track = doc.createElement('div'); + viewport.appendChild(track); + doc.body.appendChild(viewport); + Object.defineProperty(viewport, 'clientWidth', { + value: WIDTH, + configurable: true, + }); + const host = {attach: () => () => {}} as unknown as GestureHost; + const animator = createAnimator(track, { + now: () => now, + requestFrame: callback => { + frameQueue.push(callback); + }, + }); + const pager = createPager({count: 5}, {host, animator}); + pager.attach(viewport, track); + // The owner's own move: its onSettle waits for a frame. + pager.goTo(2, {animated: false, reason: 'controlled'}); + expect(inner).toHaveBeenCalledTimes(1); + expect(outer).not.toHaveBeenCalled(); + }); +}); + +describe('usePager (React)', () => { + function Paged(props: { + index?: number; + defaultIndex?: number; + viewportKey?: string; + isPanEnabled?: boolean; + onIndexChange?: (index: number) => void; + onSettle?: (index: number, previous: number) => void; + onPager?: (pager: Pager) => void; + }) { + const viewportRef = useRef(null); + const trackRef = useRef(null); + const pager = usePager({ + viewportRef, + trackRef, + count: 5, + index: props.index, + defaultIndex: props.defaultIndex, + isPanEnabled: props.isPanEnabled, + onIndexChange: props.onIndexChange, + onSettle: props.onSettle, + }); + props.onPager?.(pager); + return createElement( + 'div', + { + ref: viewportRef, + key: props.viewportKey ?? 'a', + 'data-testid': 'viewport', + }, + createElement('div', {ref: trackRef}), + ); + } + + /** + * A finger's drag 40 px toward page 2 and back to where the pan began, 10 px + * from the landing. The pan has begun, and the track rests on the start + * page. + */ + function restingDrag(target: Element) { + touch('touchstart', target, 300, 100, now); + for (let i = 1; i <= 10; i++) { + now += 8; + vi.advanceTimersByTime(8); + touch('touchmove', target, 300 - 4 * i, 100, now); + } + for (let i = 1; i <= 10; i++) { + now += 8; + vi.advanceTimersByTime(8); + touch('touchmove', target, 260 + 3 * i, 100, now); + } + } + + /** Animation frames run by hand (the pager defers callbacks to a frame). */ + function handFrames() { + const queued: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queued.push(callback); + return queued.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); + return (count = 3) => { + for (let i = 0; i < count; i++) { + for (const callback of queued.splice(0)) { + callback(0); + } + } + }; + } + + // The pager's contract: no callback runs from an unmount. + it('an unmount that ends a drag resting on a page calls no onSettle, then or later', () => { + const runFrames = handFrames(); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue( + WIDTH, + ); + const onSettle = vi.fn(); + const box: {pager: Pager | null} = {pager: null}; + const view = render( + createElement(Paged, { + defaultIndex: 1, + onSettle, + onPager: p => { + box.pager = p; + }, + }), + ); + const track = view.getByTestId('viewport').firstElementChild as Element; + restingDrag(track); + expect(box.pager?.isDragging).toBe(true); + expect(box.pager?.presentation()).toBe(WIDTH); + view.unmount(); + runFrames(5); + expect(onSettle).not.toHaveBeenCalled(); + }); + + /** A drag 240 px toward page 1 on `track`, past half a page, still held. */ + function heldDrag(track: Element) { + touch('touchstart', track, 300, 100, now); + for (let i = 1; i <= 30; i++) { + now += 8; + vi.advanceTimersByTime(8); + touch('touchmove', track, 300 - 8 * i, 100, now); + } + } + + it('a viewport the hook replaces mid-drag ends the drag on its start page, with no silent page change', () => { + const runFrames = handFrames(); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue( + WIDTH, + ); + const onIndexChange = vi.fn(); + const box: {pager: Pager | null} = {pager: null}; + const capture = (p: Pager) => { + box.pager = p; + }; + const view = render( + createElement(Paged, {viewportKey: 'a', onIndexChange, onPager: capture}), + ); + heldDrag(view.getByTestId('viewport').firstElementChild as Element); + expect(box.pager?.isDragging).toBe(true); + act(() => { + view.rerender( + createElement(Paged, { + viewportKey: 'b', + onIndexChange, + onPager: capture, + }), + ); + }); + runFrames(5); + expect(box.pager?.index).toBe(0); + expect(box.pager?.presentation()).toBeCloseTo(0, 5); + expect(onIndexChange).not.toHaveBeenCalled(); + view.unmount(); + }); + + it('a release whose report still waits when the hook replaces the viewport is reported, and its settle ends', () => { + const runFrames = handFrames(); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue( + WIDTH, + ); + const onIndexChange = vi.fn(); + const onSettle = vi.fn(); + const box: {pager: Pager | null} = {pager: null}; + const capture = (p: Pager) => { + box.pager = p; + }; + const props = {onIndexChange, onSettle, onPager: capture}; + const view = render(createElement(Paged, {viewportKey: 'a', ...props})); + const track = view.getByTestId('viewport').firstElementChild as Element; + heldDrag(track); + now += 8; + vi.advanceTimersByTime(8); + touch('touchend', track, 60, 100, now); + expect(onIndexChange).not.toHaveBeenCalled(); + act(() => { + view.rerender(createElement(Paged, {viewportKey: 'b', ...props})); + }); + runFrames(5); + expect(box.pager?.index).toBe(1); + expect(box.pager?.presentation()).toBe(WIDTH); + expect(onIndexChange).toHaveBeenCalledWith(1, 'drag'); + expect(onSettle).toHaveBeenCalledWith(1, 0); + view.unmount(); + }); + + // ... and none from the owner's own update: the settle that turning the pan + // off causes is reported after the commit, not inside its Effect. + it('turning isPanEnabled off during a drag resting on a page calls onSettle after the commit, not inside its Effect', () => { + const runFrames = handFrames(); + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue( + WIDTH, + ); + const onSettle = vi.fn(); + const box: {pager: Pager | null} = {pager: null}; + const capture = (p: Pager) => { + box.pager = p; + }; + const view = render( + createElement(Paged, { + defaultIndex: 1, + onSettle, + isPanEnabled: true, + onPager: capture, + }), + ); + const track = view.getByTestId('viewport').firstElementChild as Element; + restingDrag(track); + expect(box.pager?.isDragging).toBe(true); + expect(box.pager?.presentation()).toBe(WIDTH); + act(() => { + view.rerender( + createElement(Paged, { + defaultIndex: 1, + onSettle, + isPanEnabled: false, + onPager: capture, + }), + ); + }); + expect(onSettle).not.toHaveBeenCalled(); + act(() => { + runFrames(3); + }); + expect(onSettle).toHaveBeenCalledTimes(1); + expect(onSettle).toHaveBeenCalledWith(1, 1); + view.unmount(); + }); + + it('a controlled index change does not echo onIndexChange: the owner caused it', () => { + const onIndexChange = vi.fn(); + const view = render(createElement(Paged, {index: 0, onIndexChange})); + act(() => { + view.rerender(createElement(Paged, {index: 2, onIndexChange})); + }); + expect(onIndexChange).not.toHaveBeenCalled(); + view.unmount(); + }); + + // A controlled index wins, as a controlled input's value does. A page that + // the pager reports and the owner does not take returns the pager to the + // index, and is not reported again. + it('with a controlled index, a page the owner does not take returns the pager to the index', () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const runFrames = () => { + for (const callback of frames.splice(0)) { + callback(0); + } + }; + const box: {pager: Pager | null} = {pager: null}; + // The owner keeps page 2 whatever the pager reports. + const onIndexChange = vi.fn(); + const view = render( + createElement(Paged, { + index: 2, + onIndexChange, + onPager: p => { + box.pager = p; + }, + }), + ); + act(() => { + box.pager?.goTo(3); + }); + for (let i = 0; i < 4; i++) { + act(() => runFrames()); + } + expect(onIndexChange).toHaveBeenCalledTimes(1); + expect(onIndexChange).toHaveBeenCalledWith(3, 'goTo'); + expect(box.pager?.index).toBe(2); + view.unmount(); + }); + + it('with a controlled index, a page the owner takes stays', () => { + const frames: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const runFrames = () => { + for (const callback of frames.splice(0)) { + callback(0); + } + }; + const box: {pager: Pager | null} = {pager: null}; + function Owner() { + const [index, setIndex] = useState(2); + return createElement(Paged, { + index, + onIndexChange: setIndex, + onPager: p => { + box.pager = p; + }, + }); + } + const view = render(createElement(Owner)); + act(() => { + box.pager?.goTo(3); + }); + for (let i = 0; i < 4; i++) { + act(() => runFrames()); + } + expect(box.pager?.index).toBe(3); + view.unmount(); + }); + + // spec:AST-094 FR19 and FR43: recognizers attach for every pointer. Under a + // fine pointer, the host keeps its listener on the element and adds none to + // the document at rest (FR24). A goTo moves the pager on either pointer. + it('attaches the pan and the catch on a fine primary pointer as under a finger (spec:AST-094 FR19, FR43), adds no input listener to the document at rest (FR24), and goTo moves the pager', () => { + stubMedia(false, false); + const attach = vi.spyOn(GestureHost.prototype, 'attach'); + const listen = vi.spyOn(document, 'addEventListener'); + const inputListeners = () => + listen.mock.calls.filter(([type]) => + /^(touch|pointer|mouse|wheel)/.test(type), + ); + const box: {pager: Pager | null} = {pager: null}; + const view = render( + createElement(Paged, { + onPager: p => { + box.pager = p; + }, + }), + ); + expect(attach).toHaveBeenCalledTimes(2); + expect(inputListeners()).toEqual([]); + act(() => { + box.pager?.goTo(2); + }); + expect(box.pager?.index).toBe(2); + view.unmount(); + stubMedia(false, true); + const touchView = render(createElement(Paged, {})); + expect(attach).toHaveBeenCalledTimes(4); + touchView.unmount(); + }); + + it('reads onPagesChange through the latest render, like the other callbacks', () => { + // The page change arrives on the next animation frame: run frames by hand. + const frames: FrameRequestCallback[] = []; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }); + const runFrames = () => { + for (const callback of frames.splice(0)) { + callback(0); + } + }; + const clientWidth = vi + .spyOn(HTMLElement.prototype, 'clientWidth', 'get') + .mockReturnValue(WIDTH); + // Children 150 px wide, 10 px apart, from the track's start. + const rects = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + if (this.dataset.child != null) { + const i = Array.prototype.indexOf.call( + this.parentElement?.children ?? [], + this, + ); + return new DOMRect(i * 160, 0, 150, 100); + } + if (this.dataset.track != null) { + const n = this.children.length; + return new DOMRect(0, 0, n * 160 - 10, 100); + } + return new DOMRect(0, 0, WIDTH, 100); + }); + try { + function Cards({ + count, + onPagesChange, + }: { + count: number; + onPagesChange: (pageCount: number) => void; + }) { + const viewportRef = useRef(null); + const trackRef = useRef(null); + usePager({ + viewportRef, + trackRef, + count, + pages: 'children', + onPagesChange, + }); + return createElement( + 'div', + {ref: viewportRef}, + createElement( + 'div', + {ref: trackRef, 'data-track': ''}, + Array.from({length: count}, (_, i) => + createElement('div', {key: i, 'data-child': ''}), + ), + ), + ); + } + const first = vi.fn(); + const second = vi.fn(); + const view = render( + createElement(Cards, {count: 5, onPagesChange: first}), + ); + act(() => { + view.rerender(createElement(Cards, {count: 6, onPagesChange: second})); + }); + expect(second).not.toHaveBeenCalled(); + act(() => runFrames()); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith(5); + view.unmount(); + } finally { + rects.mockRestore(); + clientWidth.mockRestore(); + } + }); + + it('attaches again when the viewport element is replaced', () => { + let pager: Pager | null = null; + const onPager = (p: Pager) => { + pager = p; + }; + const view = render(createElement(Paged, {viewportKey: 'a', onPager})); + const first = view.getByTestId('viewport'); + expect(pager!.pan.view).toBe(first); + act(() => { + view.rerender(createElement(Paged, {viewportKey: 'b', onPager})); + }); + const second = view.getByTestId('viewport'); + expect(second).not.toBe(first); + expect(pager!.pan.view).toBe(second); + view.unmount(); + }); +}); + +describe("pageForRelease: UIKit's measured page choices (402 pt pages)", () => { + const PAGE = 402; + const page = (fraction: number, velocity: number, startIndex = 1) => + pageForRelease({ + position: (startIndex + fraction) * PAGE, + velocity, + stride: PAGE, + count: 6, + startIndex, + }); + + it('released at rest, settles on the nearest page', () => { + expect(page(0.485, 0)).toBe(1); + expect(page(0.575, 0)).toBe(2); + expect(page(0.45, 0)).toBe(1); + expect(page(0.55, 0)).toBe(2); + expect(page(-0.485, 0)).toBe(1); + expect(page(-0.575, 0)).toBe(0); + }); + + it('pages with the velocity at the advance speed, from wherever the content is', () => { + // The pan's last velocity sample at the lift, measured at 10, 25 and + // 40 % of the page: the same flip everywhere. + for (const fraction of [0.1, 0.25, 0.4]) { + expect(page(fraction, 292.7)).toBe(1); + expect(page(fraction, 298.6)).toBe(1); + expect(page(fraction, 310.7)).toBe(2); + expect(page(fraction, 329.9)).toBe(2); + expect(page(-fraction, -298.6)).toBe(1); + expect(page(-fraction, -310.7)).toBe(0); + } + // 600 pt/s from 6 % advances; 150 pt/s at 46 % stays. + expect(page(0.06, 600)).toBe(2); + expect(page(0.46, 150)).toBe(1); + expect(page(-0.06, -600)).toBe(0); + }); + + it('a slow release past the midpoint still advances', () => { + expect(page(0.55, 150)).toBe(2); + }); + + it('never travels more than one page from the page the drag started on', () => { + // 1.5 pages from page 1 at 0, 600 and 3000 pt/s: page 2. + expect(page(1.5, 0)).toBe(2); + expect(page(1.5, 600)).toBe(2); + expect(page(1.5, 3000)).toBe(2); + // A flick of 1200 from 70 %: one page. + expect(page(0.7, 1200)).toBe(2); + }); + + it('a flick against the drag returns to the start page', () => { + expect(page(0.4, -600)).toBe(1); + expect(page(-0.4, 600)).toBe(1); + }); + + // spec:AST-094 FR37, where UIKit, slower than the advance speed, takes the + // nearest page. + it('a release whose velocity points back toward the start page cancels, whatever the travel', () => { + expect(page(0.7, -50)).toBe(1); + expect(page(0.95, -299)).toBe(1); + expect(page(-0.7, 50)).toBe(1); + expect(page(-0.95, 299)).toBe(1); + // At rest, or moving on, the travel decides as before. + expect(page(0.7, 0)).toBe(2); + expect(page(0.7, 50)).toBe(2); + expect(page(-0.7, -50)).toBe(0); + }); + + it('clamps to the pages, or leaves the range when looping', () => { + expect(page(0.3, 600, 5)).toBe(5); + expect(page(-0.3, -600, 0)).toBe(0); + expect( + pageForRelease({ + position: 5.3 * PAGE, + velocity: 600, + stride: PAGE, + count: 6, + startIndex: 5, + loop: true, + }), + ).toBe(6); + expect( + pageForRelease({ + position: -0.3 * PAGE, + velocity: -600, + stride: PAGE, + count: 6, + startIndex: 0, + loop: true, + }), + ).toBe(-1); + }); + + it('a stale start page past either end counts from the nearest real page, so a release still pages', () => { + // The count shrank under a drag that began on page 7 of 8. + expect( + pageForRelease({ + position: 400, + velocity: -1000, + stride: 100, + count: 5, + startIndex: 7, + }), + ).toBe(3); + expect( + pageForRelease({ + position: 0, + velocity: 1000, + stride: 100, + count: 5, + startIndex: -2, + }), + ).toBe(1); + // At rest it lands on a real page. + expect( + pageForRelease({ + position: 400, + velocity: 0, + stride: 100, + count: 3, + startIndex: 4, + }), + ).toBe(2); + }); + + it('reads an exact page boundary as on it', () => { + expect(page(0, 600)).toBe(2); + expect(page(0, -600)).toBe(0); + expect(page(0, 0)).toBe(1); + }); + + it('handles degenerate geometry', () => { + expect( + pageForRelease({ + position: 10, + velocity: 0, + stride: 0, + count: 3, + startIndex: 0, + }), + ).toBe(0); + expect( + pageForRelease({ + position: 10, + velocity: 0, + stride: 100, + count: 1, + startIndex: 0, + }), + ).toBe(0); + }); +}); diff --git a/packages/core/src/hooks/usePager.ts b/packages/core/src/hooks/usePager.ts new file mode 100644 index 0000000000000..ce7421aa956fd --- /dev/null +++ b/packages/core/src/hooks/usePager.ts @@ -0,0 +1,1390 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file usePager.ts + * @input A viewport and its track, a page count and gap (or the track's + * children), the gesture system and the animator + * @output Exports `createPager` (the framework-free pager), `usePager` (its + * React face) and their types + * @position Shared hook for core components that page like a UIKit paging + * scroll view, such as Carousel's paging presentation + * + * SYNC: When modified, update: + * - /packages/core/src/hooks/usePager.test.ts + * - /packages/core/src/hooks/__tests__/usePager.interaction.browser.spec.ts + */ + +import {useEffect, useRef, useState, type RefObject} from 'react'; +import {getGestureHost, type GestureHost} from '../gestures/GestureHost'; +import type {GestureRecognizer} from '../gestures/GestureRecognizer'; +import {LongPressGestureRecognizer} from '../gestures/LongPressGestureRecognizer'; +import {useAttachmentPair} from './useAttachment'; +import {lockSelection, locksSelectionFor} from '../gestures/selectionLock'; +import { + PanGestureRecognizer, + type PanGestureRecognizerOptions, +} from '../gestures/PanGestureRecognizer'; +import type { + GestureEventContext, + GestureTouch, + Point, + PointerKind, +} from '../gestures/types'; +import { + animatorFor, + type Animator, + type MotionCurve, + type MotionHandle, +} from '../motion/animator'; +import { + pagingSettleVelocity, + solveAnimatedPageScroll, + solvePaging, +} from '../motion/paging'; +import {resolveMotion} from '../motion/resolveMotion'; +import {resolveSnapPoint} from '../motion/resolveSnapPoint'; +import {rubberBandClamp, rubberBandUnclamp} from '../motion/rubberBand'; +import {paging, registerPagingTable} from '../motion/uikit/paging'; +import {observeResize} from '../utils/sharedResizeObserver'; +import {isRtlElement} from './isRtlElement'; +import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; + +/** A release the pager settles: where the content is, how fast, and the page it left. */ +export interface PageRelease { + /** Content offset at the lift, px, or pages for children pages (may be past either end). */ + position: number; + /** Content velocity at the release, px/s (the shared release velocity). */ + velocity: number; + /** Distance between consecutive pages: page width plus the gap, px (1 for children pages). */ + stride: number; + /** Number of pages (at least 1). */ + count: number; + /** The page the drag started on (its rest position when the finger landed). */ + startIndex: number; + /** Wraps around: the target may leave [0, count - 1] (the owner wraps it). */ + loop?: boolean; +} + +/** + * A pan or a screen-edge pan, told apart by its kind, never by its class. + */ +function isPanFamily( + recognizer: GestureRecognizer, +): recognizer is PanGestureRecognizer { + return recognizer.kind === 'pan' || recognizer.kind === 'screenEdgePan'; +} + +/** + * The page a release settles on. It applies UIKit's measured paging rule + * (`release` in uikit/paging.ts) through the release policy of + * resolveSnapPoint.ts, with the pager's own pages and thresholds. + * + * - A release at `advanceSpeed` or faster is a flick: it goes to the next page + * in the direction of the velocity, from any content position. + * - A slower release goes to the page nearest the content. UIKit's measured + * `commitFraction` is half a page, the policy's nearest snap point. + * - A release whose velocity points back toward the start page settles there, + * at any travel, through the policy's `start`. + * - The page is never more than `maxPagesFromStart` from the start page + * (`maxSteps`). It is clamped to the pages unless the pager loops. + * - The policy sees only the pages within that reach of the start page. So a + * looping target may leave the range, and the owner wraps it. + * + * With children pages, positions are in pages, but the velocity stays px/s. + * The policy reads the velocity only against `advanceSpeed` and for its + * direction. + * + * @example + * ``` + * // Released at rest 60 % of the way from page 1 to page 2: page 2. + * pageForRelease({position: 1.6 * 402, velocity: 0, stride: 402, count: 6, startIndex: 1}); + * // Flicked forward at 600 px/s from 6 % past page 1: page 2. + * pageForRelease({position: 1.06 * 402, velocity: 600, stride: 402, count: 6, startIndex: 1}); + * ``` + */ + +export function pageForRelease({ + position, + velocity, + stride, + count, + startIndex, + loop = false, +}: PageRelease): number { + if (!(stride > 0) || count < 1) { + return 0; + } + const reach = paging.release.maxPagesFromStart; + // A stale start page past either end (the count shrank, an overscrolled + // track) counts from the nearest real page. + const from = loop ? startIndex : Math.min(Math.max(startIndex, 0), count - 1); + const first = loop ? from - reach : Math.max(0, from - reach); + const last = loop ? from + reach : Math.min(count - 1, from + reach); + const points = Array.from( + {length: last - first + 1}, + (_, i) => (first + i) * stride, + ); + const {index} = resolveSnapPoint({ + points, + position, + velocity, + flickThreshold: paging.release.advanceSpeed, + // A hair of tolerance so a release exactly on a page reads as on it. + tolerance: 1e-6 * stride, + start: from * stride, + fromIndex: from - first, + maxSteps: reach, + }); + return first + index; +} + +/** Why the target page changed. */ +export type PagerChangeReason = 'drag' | 'goTo' | 'controlled'; + +export interface PagerOptions { + /** Number of pages (at least 1). */ + count: number; + /** Space between pages, px; it shows only while a page moves. @default 0 */ + gap?: number; + /** Pages wrap around; indexes become unbounded. @default false */ + loop?: boolean; + /** + * What a page is. With `'viewport'`, every page is the viewport's width, at + * index × (width + `gap`). With `'children'`, each child of the track is a + * page at its own start edge, as `scroll-snap-align: start` places it. + * + * With children pages, the viewport's padding stays an inset. The travel + * ends where the track's end meets the end of the viewport's content box. A + * child whose start lies past that point rests there, so the last children + * can share one page. The count is then the number of distinct pages + * (`pageCount`). `count` and `gap` serve only before the first measure, and + * children pages do not loop. The pager measures the children on attach, on + * a resize, on an options change and when a drag begins. It never measures + * while a finger moves. + * @default 'viewport' + */ + pages?: 'viewport' | 'children'; + /** Starting page. @default 0 */ + defaultIndex?: number; + /** + * The target page changed (index wrapped into [0, count)). A release + * reports once its settle is moving; `index` has the page from the lift. + * A controlled move (the `index` prop, or a `count` that clamps the page) + * is the owner's own and is not reported. + */ + onIndexChange?: (index: number, reason: PagerChangeReason) => void; + /** + * The track came to rest on `index` after moving on its own (a settle, a + * `goTo`), having last rested on `previous` (equal when it returned). + */ + onSettle?: (index: number, previous: number) => void; + /** + * With `pages: 'children'`: a measure found different pages (how many, or + * where they rest) after the viewport or the track resized or the options + * changed. Not called for the measure an attach makes: read `pageCount` + * after attaching. + */ + onPagesChange?: (pageCount: number) => void; + /** + * Called when the pan would begin, with the drag's first physical direction + * on screen, the same in RTL. Return false to leave the touch to someone + * else, such as a zoomed photo that can still pan that way. + */ + shouldBeginPan?: (direction: 'left' | 'right') => boolean; + /** Constructor options for the pan (e.g. `allowedPointerTypes`). */ + panOptions?: Partial; +} + +export interface GoToOptions { + /** Animate with `setContentOffset(_:animated:)`'s curve. @default true */ + animated?: boolean; + /** @default 'goTo' */ + reason?: PagerChangeReason; +} + +export interface Pager { + /** The page pan, for failure requirements and simultaneity. */ + readonly pan: PanGestureRecognizer; + /** The target page, wrapped into [0, count). */ + readonly index: number; + /** The target page, unbounded when looping. */ + readonly virtualIndex: number; + /** + * How many pages there are: `count`, or with `pages: 'children'` the + * distinct pages last measured. + */ + readonly pageCount: number; + /** Whether the track is moving on its own (a settle or a `goTo`). */ + readonly isSettling: boolean; + /** Whether a finger is dragging the track. */ + readonly isDragging: boolean; + /** The content offset on screen right now, from the curve (px). */ + presentation(): number; + /** Where page `index` rests (px). */ + pageOffset(index: number): number; + /** The page whose rest is nearest content offset `position` (px). */ + nearestPage(position: number): number; + /** Move to a page (wrapped to the nearest copy when looping). */ + goTo(index: number, options?: GoToOptions): void; + /** Update options; a smaller count clamps the target page. */ + setOptions(options: Partial): void; + /** + * Turns the page pan on or off (the hook's `isPanEnabled`). While it is off, + * a touch does not stop a settle or a `goTo`. A drag that it ends settles as + * a cancelled drag does. The callbacks of that settle wait for the next + * frame, so no callback runs inside the caller's update. + */ + setPanEnabled(enabled: boolean): void; + /** Attach to the elements; returns a detach function. */ + attach(viewport: HTMLElement, track: HTMLElement): () => void; + /** Detach and stop everything (attaching again restarts it). */ + dispose(): void; +} + +/** + * The copy of page `index` nearest virtual page `from`, in a loop of `count` + * pages. Two copies can be equally near: with two pages, or half an even + * count away. Then it is `index` itself when that is one of them, so a step + * to `from + 1` always goes forward. + */ +export function nearestLoopCopy( + index: number, + from: number, + count: number, +): number { + if (Math.abs(index - from) * 2 <= count) { + return index; + } + return Math.round( + from + + (((mod(index, count) - mod(from, count) + count + count / 2) % count) - + count / 2), + ); +} + +/** Whether two measured page lists are the same to within half a pixel. */ +function sameOffsets( + a: ReadonlyArray, + b: ReadonlyArray | null, +): boolean { + return ( + b != null && + a.length === b.length && + a.every((offset, i) => Math.abs(offset - b[i]) < 0.5) + ); +} + +function mod(value: number, count: number): number { + return ((value % count) + count) % count; +} + +/** A position curve seen as the track's x (mirrored for LTR). */ +function toTrackCurve(curve: MotionCurve, sign: number): MotionCurve { + // `+ 0` turns a mirrored zero (−0) into 0. + return { + duration: curve.duration, + at: t => sign * curve.at(t) + 0, + velocity: t => sign * curve.velocity(t) + 0, + }; +} + +/** What a pager runs on; tests pass their own clocked host and animator. */ +export interface PagerEnvironment { + /** The gesture host; the document's shared host by default. */ + host?: GestureHost; + /** The track's animator; `animatorFor(track)` by default. */ + animator?: Animator; + /** Timers for the catch delay; the viewport's window's by default. */ + setTimeout?: (callback: () => void, ms: number) => unknown; + clearTimeout?: (handle: unknown) => void; + /** The next animation frame; the viewport's window's by default. */ + requestFrame?: (callback: () => void) => void; +} + +/** + * The pager's pan. Its release velocity is the pan's `scrollVelocity()`, the + * one UIScrollView hands `scrollViewWillEndDragging`. Unlike UIKit's, it reads + * 0 after a still hold. + * + * Inside a scroll view, it takes UIKit's rule: a drag whose dominant axis is + * horizontal is the pager's (`paging.nestedPanAxisRatio`, the tie included). + * Any other drag is the page's. Like every pan, it cancels the touch's moves + * once it begins, at its 10 pt hysteresis. Nothing claims a touch earlier. + * + * Inside a sheet, it keeps the sheet's rule (`axisRatio`, + * `paging.panAxisRatio`). The arena shows the sheet as an enclosing vertical + * pan that takes part in the touch. Between atan(1 ÷ that ratio) and 45°, + * neither moves. + */ +class PagerPanGestureRecognizer extends PanGestureRecognizer { + /** + * The kind of pointer the pan follows. Under Reduce Motion a touch's + * release cross-fades; a mouse's or a pen's lands at once on a fine + * pointer. + */ + pointerType: PointerKind | null = null; + + protected override touchesBegan( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + if (this.state === 'possible' && touches.length > 0) { + this.pointerType = touches[0].pointerType; + } + super.touchesBegan(touches, event); + } + + private hasEnclosingVerticalPan(): boolean { + for (const other of this.arena?.involvedRecognizers ?? []) { + if ( + other !== this && + isPanFamily(other) && + (other.direction === 'vertical' || + other.direction === 'up' || + other.direction === 'down') + ) { + return true; + } + } + return false; + } + + protected override classify(translation: Point): 'begin' | 'fail' | 'wait' { + const decided = super.classify(translation); + if (decided !== 'wait' || this.hasEnclosingVerticalPan()) { + return decided; + } + const along = Math.abs(translation.x); + const across = Math.abs(translation.y); + if (along === 0 && across === 0) { + return 'wait'; + } + return along >= paging.nestedPanAxisRatio * across ? 'begin' : 'fail'; + } +} + +/** + * The settle catcher: a zero-duration long press that only stops the settle. + * It never claims the touch. So a vertical swipe or a pinch that starts on a + * settling track still reaches the page. UIKit also hands the outer scroll + * view a vertical drag after the paging view's content stops. + */ +class SettleCatcher extends LongPressGestureRecognizer { + /** The kind of pointer that caught the settle (see the pager's pan). */ + pointerType: PointerKind | null = null; + + override get claimsTouches(): boolean { + return false; + } + + protected override touchesBegan( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + if (this.state === 'possible' && touches.length > 0) { + this.pointerType = touches[0].pointerType; + } + super.touchesBegan(touches, event); + } +} + +/** + * A trackpad swipe that moved under this fraction of a page (in the pages' + * own spacing, children pages included) releases still, whatever its + * speed. + */ +const STILL_RELEASE_FRACTION = 0.1; + +/** Whether a pan's contact is a scroll (a trackpad's), not a pointer. */ +function isScrollContact(recognizer: PanGestureRecognizer): boolean { + return recognizer.trackedTouches[0]?.scrollType != null; +} + +/** + * Creates a pager: UIKit's paging scroll view, driven by the gesture system + * and played on the compositor. A native scroll-snap container does not run + * UIKit's paging integrator, and it cannot share a gesture with a pinch. + * Attach the pager to its viewport and track. It measures the page width on + * attach, on a resize and when a drag begins, never while a finger moves. + * + * - A horizontal pan moves the track 1:1, with one `translate` write per + * move, no layout read and no React render. Past the first or last page, + * the track follows UIKit's rubber band, with d = the page width. + * - At the lift, `pageForRelease` picks the page with UIKit's measured rule. + * The track then settles on UIKit's paging integrator (`solvePaging`), + * baked into keyframes. + * - A finger that lands on a settling pager catches it `paging.catchStopMs` + * after the touch, as in UIKit. A drag catches it at once, from the value + * on screen. While the pan is off, a touch does not stop the motion. + * - `goTo` runs the curve of `setContentOffset(_:animated:)` and cancels a + * drag in progress. It reads that duration and curve through + * `resolveMotion`. + * - Under Reduce Motion, the drag still follows the finger, but nothing + * travels after the lift. A touch's release cross-fades in 200 ms on any + * pointer. `goTo` and a mouse's or a pen's release cross-fade on a coarse + * pointer, and land at once on any other. + * - `onIndexChange` reports a release's page once its settle runs on the + * compositor, two frames after the frame it started in. It reports a + * `goTo` at once, and never a controlled change. A report still waiting at + * a detach or a controlled move is dropped. `onSettle` runs when the track + * rests. + * - A detach ends a drag on its start page, and an unmount calls nothing. + * When a replaced viewport or track attaches, a motion that the detach + * stopped ends on its page. Its report and `onSettle` go out from a frame. + * - A controlled `index` wins. A page that the pager reports and the owner + * does not take returns the pager to `index`, without a report. + * - The pan and the catch attach for every pointer. A mouse drags the track + * as a finger does. The pan follows a trackpad's scroll along its axis, but + * never a mouse wheel's. + * - A trackpad swipe moves the track at most one page from its start page. + * A swipe that moves under a tenth of a page releases still, at any speed. + * A swipe that lands during the last swipe's settle begins from that + * settle's page. + * + * Positions are content offsets in px, 0 at page 0 and growing toward later + * pages. The track is translated by −position, or by +position in a + * right-to-left viewport. The owner renders the pages: all of them, a window, + * or a wrapped copy for a loop. With `loop`, indexes are unbounded, and + * `index` wraps them into [0, count). + * + * Give the current page and its neighbors their own layers + * (`will-change: transform`). Through a busy main thread, Mobile Safari moves + * the track but cannot paint new content, so part of a page that slides in + * stays blank. A page on its own layer is painted whole before it moves. + */ +export function createPager( + initial: PagerOptions, + environment: PagerEnvironment = {}, +): Pager { + registerPagingTable(); + // Frames and timers come from the viewport's own window, known from the + // attach. In a Picture-in-Picture window, the opener's frames stop while + // its tab is hidden. + let view: (Window & typeof globalThis) | null = null; + const realm = () => view ?? globalThis; + const setTimer = + environment.setTimeout ?? + ((callback: () => void, ms: number) => realm().setTimeout(callback, ms)); + const clearTimer = + environment.clearTimeout ?? + ((handle: unknown) => + realm().clearTimeout(handle as ReturnType)); + const requestFrame = + environment.requestFrame ?? + ((callback: () => void) => { + const frames = realm(); + if (typeof frames.requestAnimationFrame === 'function') { + frames.requestAnimationFrame(() => callback()); + } else { + setTimer(callback, 16); + } + }); + let options: PagerOptions = {...initial}; + let viewport: HTMLElement | null = null; + let trackElement: HTMLElement | null = null; + let animator: Animator | null = null; + let pageWidth = 0; + let isRtl = false; + // With `pages: 'children'`, each page's offset (px, ascending), once + // measured. + let childOffsets: ReadonlyArray | null = null; + + const isChildPages = () => options.pages === 'children'; + const isLooping = () => options.loop === true && !isChildPages(); + const count = () => + isChildPages() && childOffsets != null + ? childOffsets.length + : Math.max(1, Math.floor(options.count)); + const stride = () => pageWidth + (options.gap ?? 0); + const sign = () => (isRtl ? 1 : -1); + const clampIndex = (index: number) => + isLooping() ? index : Math.max(0, Math.min(count() - 1, index)); + + /** Where page `index` rests (px). */ + function offsetOf(index: number): number { + if (isChildPages() && childOffsets != null) { + return childOffsets[ + Math.max(0, Math.min(childOffsets.length - 1, index)) + ]; + } + return index * stride(); + } + + /** + * Where `position` falls, in pages: an index plus the fraction of the way + * to the next page (past either end, in the end page's spacing). + */ + function pageAt(position: number): number { + if (!(isChildPages() && childOffsets != null)) { + return position / stride(); + } + const at = childOffsets; + const n = at.length; + if (n === 1) { + return (position - at[0]) / (pageWidth > 0 ? pageWidth : 1); + } + if (position < at[0]) { + return (position - at[0]) / (at[1] - at[0]); + } + for (let i = 0; i < n - 1; i++) { + if (position < at[i + 1]) { + return i + (position - at[i]) / (at[i + 1] - at[i]); + } + } + return n - 1 + (position - at[n - 1]) / (at[n - 1] - at[n - 2]); + } + + /** The page nearest `position`, or the target before anything is measured. */ + function nearestPage(position: number): number { + if (!isChildPages() && !(stride() > 0)) { + return target; + } + return Math.round(pageAt(position)); + } + + /** + * The page a release from `position` at `velocity` settles on (UIKit's + * rule, pageForRelease), in children pages' own spacing. + */ + function releasePage( + position: number, + velocity: number, + startIndex: number, + ): number { + const pages = isChildPages() && childOffsets != null; + return pageForRelease({ + position: pages ? pageAt(position) : position, + velocity, + stride: pages ? 1 : stride(), + count: count(), + startIndex, + loop: isLooping(), + }); + } + + /** + * Children pages: each child's start edge, from the track's start. The + * track's own translate moves both alike, so it cancels out. Each offset is + * clamped to the travel that keeps the track's end at or past the end of + * the viewport's content box. Children clamped onto one offset share its + * page. + * + * Rects are read in the track's own CSS pixels, which its `translate` moves + * in. An ancestor's `zoom` scales the rects, so the pager divides out that + * scale: the rect's width over the track's used width. + */ + function measureChildOffsets(): ReadonlyArray { + const track = trackElement; + if (viewport == null || track == null || track.children.length === 0) { + return [0]; + } + const box = track.getBoundingClientRect(); + const used = Number.parseFloat(getComputedStyle(track).width); + const scale = used > 0 && box.width > 0 ? box.width / used : 1; + const style = getComputedStyle(viewport); + const inner = + viewport.clientWidth - + (Number.parseFloat(style.paddingLeft) || 0) - + (Number.parseFloat(style.paddingRight) || 0); + const starts: number[] = []; + let extent = 0; + for (const child of Array.from(track.children)) { + const rect = child.getBoundingClientRect(); + starts.push( + (isRtl ? box.right - rect.right : rect.left - box.left) / scale, + ); + extent = Math.max( + extent, + (isRtl ? box.right - rect.left : rect.right - box.left) / scale, + ); + } + const end = Math.max(0, extent - inner); + const offsets: number[] = []; + for (const start of starts) { + const offset = Math.min(Math.max(start, 0), end); + if (offsets.length === 0 || offset > offsets[offsets.length - 1] + 0.5) { + offsets.push(offset); + } + } + return offsets.length > 0 ? offsets : [0]; + } + + // The page the track rests on or is heading for (unbounded when looping), + // and the one it last rested on. + let target = clampIndex(Math.round(options.defaultIndex ?? 0)); + let settled = target; + let settleHandle: MotionHandle | null = null; + let settleToken = 0; + let isDragging = false; + + // Drag state, captured when the pan begins. + let dragStart = 0; + let dragStartIndex = 0; + let dragPosition = 0; + let suppressCancelSettle = false; + let catchTimer: unknown = null; + // Whether the finger now holding the track stopped a settle (not one that + // ended on its own inside the catch window). + let caughtSettle = false; + + const pan = new PagerPanGestureRecognizer({ + ...options.panOptions, + // Published, so app code can relate its own recognizers to the paging + // pan (gesture delegates). Instances are told apart by their element. + name: options.panOptions?.name ?? 'pager', + direction: 'horizontal', + axisRatio: options.panOptions?.axisRatio ?? paging.panAxisRatio, + // On the web the pan follows a trackpad's scroll along its axis and never + // a mouse wheel's. + allowedScrollTypes: options.panOptions?.allowedScrollTypes ?? [ + 'continuous', + ], + shouldBegin: recognizer => { + if (options.panOptions?.shouldBegin?.(recognizer) === false) { + return false; + } + const dx = recognizer.totalTranslation().x; + const direction = dx < 0 ? 'left' : 'right'; + return options.shouldBeginPan?.(direction) ?? true; + }, + }); + + // Catches a settling track when a finger lands on it. It begins on the + // touch itself, alongside the pan, only while the track moves on its own + // and the pan is on. + const catcher = new SettleCatcher({ + minimumPressDuration: 0, + allowableMovement: Number.POSITIVE_INFINITY, + // Its own hold does nothing, so it brings no selection lock: a finger on + // a resting track keeps the browser's native long press. Stopping a + // settle does something; `holdSelection` locks then. + selectionLock: null, + shouldBegin: () => settleHandle != null && !isDragging && pan.isEnabled, + }); + catcher.recognizeSimultaneouslyWith(pan); + + function positionToX(position: number): number { + const x = sign() * position; + return x === 0 ? 0 : x; + } + + function presentation(): number { + if (animator == null) { + return offsetOf(target); + } + return sign() * animator.get('x'); + } + + function clearCatchTimer(): void { + if (catchTimer != null) { + clearTimer(catchTimer); + catchTimer = null; + } + } + + function stopSettle(): void { + clearCatchTimer(); + flushReport(); + if (settleHandle != null) { + settleToken += 1; + settleHandle = null; + animator?.stop(['x']); + } + } + + function wrapped(index: number): number { + return isLooping() ? mod(index, count()) : index; + } + + // The owner passes its own updates (setOptions, a controlled goTo) from an + // Effect. The callbacks they cause run from a frame callback, never inside + // that Effect. There, a callback that sets state would update the owner + // during its own commit. A detach drops the callbacks still waiting. + let deferDepth = 0; + let deferred: (() => void)[] = []; + let deferredToken = 0; + + function deliver(callback: () => void): void { + if (deferDepth > 0) { + deferred.push(callback); + } else { + callback(); + } + } + + function deferringCallbacks(work: () => void): void { + deferDepth += 1; + try { + work(); + } finally { + deferDepth -= 1; + if (deferDepth === 0 && deferred.length > 0) { + const calls = deferred; + deferred = []; + const token = deferredToken; + requestFrame(() => { + if (token === deferredToken) { + for (const call of calls) { + call(); + } + } + }); + } + } + } + + function dropDeferred(): void { + deferred = []; + deferredToken += 1; + } + + // The page last reported through onIndexChange, and a report that waits + // for a release's settle to reach the compositor. + let reported = wrapped(target); + let pendingReport: PagerChangeReason | null = null; + let reportToken = 0; + + function report(reason: PagerChangeReason): void { + pendingReport = null; + reportToken += 1; + const index = wrapped(target); + if (index !== reported) { + reported = index; + // A controlled move (the owner's `index`, or a `count` that clamps the + // page) is the owner's own change: it is not reported back. + if (reason !== 'controlled') { + deliver(() => options.onIndexChange?.(index, reason)); + } + } + } + + function flushReport(): void { + if (pendingReport != null) { + report(pendingReport); + } + } + + /** + * Forgets a release's report still waiting: the pager is detaching, or the + * owner's own (controlled) move replaces the page it chose. The page last + * reported stays as it was. + */ + function dropReport(): void { + pendingReport = null; + reportToken += 1; + } + + /** + * A release reports its page once the settle reaches the compositor, two + * frames after the frame it started in. In Mobile Safari, a main-thread + * block that starts within about two frames of the touch freezes a motion + * that the touch's handler started. The owner's render of the new page is + * such a block. UIKit tells its delegate at the lift + * (`scrollViewWillEndDragging`), and the page is known then too (`index`). + */ + function setTarget( + next: number, + reason: PagerChangeReason, + whenMoving = false, + ): void { + target = next; + if (whenMoving) { + pendingReport = reason; + } else { + report(reason); + } + } + + /** + * Runs `callback` in the `frames`-th animation frame after the frame that a + * motion started in. Call it when the motion's start resolves. + * + * WebKit resolves an animation's start inside that frame's rendering + * update, before the frame's animation-frame callbacks. No task runs inside + * a rendering update. So a first frame that runs before a task queued at + * the resolution is the start's own frame, and it does not count. + * Timestamps cannot tell: WebKit stamps that frame's callbacks 0–4 ms after + * the animation's start time, as far apart as two frames at 240 Hz. Where + * the start resolves after its frame, as in Chromium, the task runs before + * the next frame. The report then comes a frame later, never earlier. + */ + function afterFramesPastStart(frames: number, callback: () => void): void { + let isPastStartFrame = false; + setTimer(() => { + isPastStartFrame = true; + }, 0); + const onFrame = (left: number) => { + const remaining = isPastStartFrame ? left - 1 : left; + isPastStartFrame = true; + if (remaining <= 0) { + callback(); + return; + } + requestFrame(() => onFrame(remaining)); + }; + requestFrame(() => onFrame(frames)); + } + + function rest(): void { + flushReport(); + const previous = wrapped(settled); + settled = target; + const index = wrapped(settled); + deliver(() => options.onSettle?.(index, previous)); + } + + /** + * Plays `curve` on the track. Under Reduce Motion, the animator replaces the + * travel. A settle that a touch's release starts (`touchRelease`) + * cross-fades on any pointer. Any other motion, such as `goTo` or a mouse's + * or a pen's release, cross-fades on a coarse pointer. It lands at once on + * any other pointer. + */ + function play(curve: MotionCurve, touchRelease = false): void { + if (animator == null) { + return; + } + follow(animator.play({x: toTrackCurve(curve, sign())}, {touchRelease})); + } + + function follow(handle: MotionHandle): void { + const token = ++settleToken; + settleHandle = handle; + if (pendingReport != null) { + const reportAt = reportToken; + void handle.started.then(() => + afterFramesPastStart(2, () => { + if (reportAt === reportToken) { + flushReport(); + } + }), + ); + } + void handle.finished.then(finished => { + if (token !== settleToken) { + return; + } + settleHandle = null; + if (finished) { + rest(); + } + }); + } + + /** + * The settle after a pointer lifts (a release, or a hold let go); + * `touchRelease` when that pointer was a touch. + */ + function settleFrom( + position: number, + velocity: number, + touchRelease: boolean, + ): void { + const to = offsetOf(target); + // UIKit's integrator, as a playable curve (touch motion stays internal). + const curve = solvePaging({from: position, velocity, to}); + if (curve.steps === 0) { + animator?.set({x: positionToX(to)}); + rest(); + return; + } + play(curve, touchRelease); + } + + function measure(): void { + if (viewport == null) { + return; + } + const width = viewport.clientWidth; + isRtl = isRtlElement(viewport); + let changed = width !== pageWidth; + pageWidth = width; + if (isChildPages()) { + const offsets = measureChildOffsets(); + if (!sameOffsets(offsets, childOffsets)) { + childOffsets = offsets; + changed = true; + const pageCount = offsets.length; + deliver(() => options.onPagesChange?.(pageCount)); + } + } + if (!changed) { + return; + } + if (!isDragging && target > count() - 1) { + // Fewer pages (children clamped onto one): the last one, as a smaller + // count clamps. + goTo(count() - 1, {animated: false, reason: 'controlled'}); + return; + } + // A resize lands on the target page at its new offset (a settle in + // flight ends there), as a paging scroll view does on rotation. + if (!isDragging) { + stopSettle(); + animator?.set({x: positionToX(offsetOf(target))}); + if (settled !== target) { + rest(); + } + } + } + + function onPan(recognizer: PanGestureRecognizer): void { + const state = recognizer.state; + if (state === 'began') { + measure(); + clearCatchTimer(); + const current = presentation(); + const wasSettling = settleHandle != null; + stopSettle(); + isDragging = true; + // A track caught past an end is shown rubber-banded: the drag starts + // from its unbanded position, so the first move keeps it under the + // finger. + dragStart = isLooping() + ? current + : rubberBandUnclamp( + current, + offsetOf(0), + offsetOf(count() - 1), + pageWidth > 0 ? pageWidth : 1, + ); + dragPosition = current; + // A trackpad swipe that lands while the last swipe's settle runs + // begins from that settle's page, so back-to-back swipes page on. + dragStartIndex = + wasSettling && isScrollContact(recognizer) + ? target + : nearestPage(current); + return; + } + if (state === 'changed') { + const dx = recognizer.translation().x; + // Finger right (dx > 0) moves content toward earlier pages in LTR. + let raw = dragStart + sign() * dx; + if (isScrollContact(recognizer) && pageWidth > 0) { + // A trackpad swipe moves the track at most one page from its start + // page, across a loop too. A pager that does not loop keeps the + // rubber band of its first and last pages below. + const looping = isLooping(); + const before = + looping || dragStartIndex > 0 + ? offsetOf(dragStartIndex - 1) + : -Infinity; + const after = + looping || dragStartIndex < count() - 1 + ? offsetOf(dragStartIndex + 1) + : Infinity; + raw = Math.min( + Math.max(raw, Math.min(before, after)), + Math.max(before, after), + ); + } + dragPosition = isLooping() + ? raw + : rubberBandClamp( + raw, + offsetOf(0), + offsetOf(count() - 1), + pageWidth > 0 ? pageWidth : 1, + ); + animator?.set({x: positionToX(dragPosition)}); + return; + } + if (state === 'ended' || state === 'cancelled') { + isDragging = false; + if (state === 'cancelled' && suppressCancelSettle) { + suppressCancelSettle = false; + return; + } + // The pan's `scrollVelocity()`: the shared release filter, read as + // UIScrollView reads it. A finger still for more than 40 ms before the + // lift releases at 0, where UIScrollView keeps the speed. The content + // moves against the finger in LTR (sign()). + let velocity = + state === 'ended' ? sign() * recognizer.scrollVelocity().x : 0; + // A trackpad swipe that moved under a tenth of a page, in the pages' + // own spacing, releases still at any speed. Such a speed spike is not a + // flick. A finger's and a mouse's flick keep their speed. + if ( + isScrollContact(recognizer) && + Math.abs(pageAt(dragPosition) - dragStartIndex) < STILL_RELEASE_FRACTION + ) { + velocity = 0; + } + const next = releasePage(dragPosition, velocity, dragStartIndex); + setTarget(next, 'drag', true); + settleFrom( + dragPosition, + pagingSettleVelocity(velocity), + pan.pointerType === 'touch', + ); + } + } + + // While a finger holds a caught track, the browser's long-press text + // selection and callout stay off. In UIKit, the touch that stops a scroll + // view does not reach its content. The gesture system's shared lock is + // counted per element. So this lock and the gesture host's own lock for the + // same contact release in either order. + let releaseSelection: (() => void) | null = null; + + function holdSelection(on: boolean): void { + if (on && releaseSelection == null && trackElement != null) { + if (locksSelectionFor(catcher.pointerType ?? 'touch')) { + releaseSelection = lockSelection([trackElement], null); + } + } else if (!on && releaseSelection != null) { + releaseSelection(); + releaseSelection = null; + } + } + + function onCatch(recognizer: LongPressGestureRecognizer): void { + if (recognizer.state === 'began') { + holdSelection(true); + clearCatchTimer(); + caughtSettle = false; + catchTimer = setTimer(() => { + catchTimer = null; + if (settleHandle != null && !isDragging) { + stopSettle(); + caughtSettle = true; + } + }, paging.catchStopMs); + return; + } + if (recognizer.state === 'ended' || recognizer.state === 'cancelled') { + holdSelection(false); + if (catchTimer != null) { + // Lifted before the catch took hold: the settle runs on. + clearCatchTimer(); + return; + } + const wasCaught = caughtSettle; + caughtSettle = false; + if ( + wasCaught && + !isDragging && + settleHandle == null && + animator != null + ) { + // Held, then lifted without dragging: settle on the nearest page. + const position = presentation(); + const next = releasePage(position, 0, nearestPage(position)); + setTarget(next, 'drag', true); + settleFrom(position, 0, catcher.pointerType === 'touch'); + } + } + } + + function goTo(index: number, goToOptions: GoToOptions = {}): void { + if (goToOptions.reason === 'controlled' && deferDepth === 0) { + // The owner's own move: what it calls back waits for a frame. + deferringCallbacks(() => goTo(index, goToOptions)); + return; + } + const {animated = true, reason = 'goTo'} = goToOptions; + let next = Math.round(index); + if (isLooping()) { + next = nearestLoopCopy(next, target, count()); + } else { + next = clampIndex(next); + } + if (isDragging) { + suppressCancelSettle = true; + isDragging = false; + pan.cancel(); + } + const from = presentation(); + // A move this one retargets hands on its velocity, from its curve. + const velocity = + settleHandle != null && animator != null + ? sign() * animator.getVelocity('x') + : undefined; + if (reason === 'controlled') { + dropReport(); + } + stopSettle(); + setTarget(next, reason); + const to = offsetOf(next); + if (!animated || animator == null || from === to) { + animator?.set({x: positionToX(to)}); + rest(); + return; + } + play( + solveAnimatedPageScroll({ + from, + to, + durationMs: resolveMotion( + trackElement, + paging.animatedScroll, + 'durationMs', + ), + curve: resolveMotion(trackElement, paging.animatedScroll.curve), + velocity, + }), + ); + } + + function setOptions(update: Partial): void { + deferringCallbacks(() => applyOptions(update)); + } + + function applyOptions(update: Partial): void { + const wasLooping = isLooping(); + const wasChildPages = isChildPages(); + const previousOffset = offsetOf(target); + options = {...options, ...update}; + if (isChildPages() !== wasChildPages) { + syncTrackObserver(); + } + if (isChildPages() && viewport != null && !isDragging) { + // The children may have changed with the options (a count, a gap). + const offsets = measureChildOffsets(); + if (!sameOffsets(offsets, childOffsets)) { + childOffsets = offsets; + const pageCount = offsets.length; + deliver(() => options.onPagesChange?.(pageCount)); + } + } + if (!isLooping() && (target < 0 || target > count() - 1)) { + // Turning `loop` off keeps the page shown; a smaller `count` clamps. + const shown = wasLooping ? mod(target, count()) : target; + goTo(clampIndex(shown), {animated: false, reason: 'controlled'}); + return; + } + if (offsetOf(target) !== previousOffset && !isDragging) { + // A new gap (or children): the target page at its new offset (a settle + // ends there). + const wasSettling = settleHandle != null; + stopSettle(); + animator?.set({x: positionToX(offsetOf(target))}); + if (wasSettling || settled !== target) { + rest(); + } + } + } + + let detachAll: (() => void) | null = null; + // What a detach carries to the next attach: a release's report that still + // waits, and whether the detach stopped a motion before the track rested. + let carriedReport: PagerChangeReason | null = null; + let carriedEnd = false; + + // Children pages also move when the track's own size changes, as when a + // child is added, removed or resized. So the track is observed while the + // pages are its children, from the attach or the options change that makes + // them so. + let unobserveTrack: () => void = () => {}; + function syncTrackObserver(): void { + unobserveTrack(); + unobserveTrack = + isChildPages() && trackElement != null && detachAll != null + ? observeResize(trackElement, measure) + : () => {}; + } + + function attach(nextViewport: HTMLElement, nextTrack: HTMLElement) { + detachAll?.(); + view = nextViewport.ownerDocument.defaultView; + viewport = nextViewport; + trackElement = nextTrack; + animator = environment.animator ?? animatorFor(nextTrack); + pageWidth = 0; + isRtl = isRtlElement(nextViewport); + pageWidth = nextViewport.clientWidth; + if (isChildPages()) { + childOffsets = measureChildOffsets(); + target = clampIndex(target); + settled = target; + } + animator.set({x: positionToX(offsetOf(target))}); + // The recognizers, for every pointer. + const removePanListener = pan.addListener(onPan); + const removeCatchListener = catcher.addListener(onCatch); + const host = environment.host ?? getGestureHost(nextViewport.ownerDocument); + const detachPan = host.attach(nextViewport, pan); + // The catcher sits on the track, so its long-press styles (no callout, no + // selection) stay off the pages' text. The viewport's touch-action + // already covers the touches that land on the track. + const detachCatch = host.attach(nextTrack, catcher, { + manageStyle: false, + }); + const detachGestures = () => { + detachCatch(); + detachPan(); + removeCatchListener(); + removePanListener(); + }; + const unobserve = observeResize(nextViewport, measure); + detachAll = () => { + unobserve(); + unobserveTrack(); + unobserveTrack = () => {}; + // A drag that the detach ends stays on its start page. The report and + // the end of a motion that the detach stops wait for the next attach, + // so an unmount calls nothing. + if (isDragging) { + suppressCancelSettle = true; + } + deferringCallbacks(() => detachGestures()); + suppressCancelSettle = false; + carriedReport = pendingReport; + carriedEnd = target !== settled; + dropReport(); + stopSettle(); + dropDeferred(); + holdSelection(false); + isDragging = false; + detachAll = null; + }; + syncTrackObserver(); + // A motion that the last detach stopped ends here: its report, if it + // still waited, and its onSettle go out from a frame. + if (carriedEnd && target !== settled) { + const report = carriedReport; + deferringCallbacks(() => { + pendingReport = report; + rest(); + }); + } + carriedReport = null; + carriedEnd = false; + return () => detachAll?.(); + } + + return { + pan, + get index() { + return wrapped(target); + }, + get virtualIndex() { + return target; + }, + get pageCount() { + return count(); + }, + get isSettling() { + return settleHandle != null; + }, + get isDragging() { + return isDragging; + }, + presentation, + pageOffset: offsetOf, + nearestPage: (position: number) => clampIndex(nearestPage(position)), + goTo, + setOptions, + setPanEnabled(enabled: boolean) { + deferringCallbacks(() => pan.setEnabled(enabled)); + }, + attach, + dispose() { + detachAll?.(); + }, + }; +} + +export interface UsePagerOptions extends PagerOptions { + /** The gesture surface: the clipped box the pages show through. */ + viewportRef: RefObject; + /** The row of pages the pager translates. */ + trackRef: RefObject; + /** + * Controlled target page; a change animates to it (reason 'controlled'), + * and a page the pager reports that the owner does not take returns it + * here. + */ + index?: number; + /** Live toggle for the pan; turning it off mid-drag cancels the drag. */ + isPanEnabled?: boolean; +} + +/** + * A pager on two elements. Callbacks are read through a ref, so passing new + * functions each render costs nothing; a gesture never renders. + * + * @example + * ``` + * const pager = usePager({viewportRef, trackRef, count: photos.length, gap: 40, + * onIndexChange: setCounter}); + *
+ *
…pages…
+ *
+ * ``` + */ +export function usePager({ + viewportRef, + trackRef, + index, + isPanEnabled = true, + ...options +}: UsePagerOptions): Pager { + const latestRef = useRef(options); + // The controlled `index` of the last commit, read when the pager reports. + const indexRef = useRef(index); + useIsomorphicLayoutEffect(() => { + latestRef.current = options; + indexRef.current = index; + }); + // Bumped when the pager reports a page while `index` is controlled. The + // render it causes is batched with the owner's own update. If the owner kept + // `index`, that render returns the pager to it (the controlled sync below). + const [reported, setReported] = useState(0); + const [pager] = useState(() => + createPager({ + ...options, + defaultIndex: index ?? options.defaultIndex, + onIndexChange: (i, reason) => { + latestRef.current.onIndexChange?.(i, reason); + if (indexRef.current != null) { + setReported(n => n + 1); + } + }, + onSettle: (i, previous) => latestRef.current.onSettle?.(i, previous), + onPagesChange: pageCount => latestRef.current.onPagesChange?.(pageCount), + shouldBeginPan: direction => + latestRef.current.shouldBeginPan?.(direction) ?? true, + }), + ); + + // Synchronizes the pager (the track's animation and the gesture host) with + // these props. A clamp it causes is not reported back (reason 'controlled'). + const {count, gap, loop, pages} = options; + useIsomorphicLayoutEffect(() => { + pager.setOptions({count, gap, loop, pages}); + }, [pager, count, gap, loop, pages]); + + // Attaches the pager to the viewport and track nodes, and again whenever + // either node is replaced; detaches at unmount. + useAttachmentPair( + viewportRef, + trackRef, + (viewport, track) => pager.attach(viewport, track), + pager, + ); + + // Synchronizes the pan recognizer (the gesture host) with isPanEnabled; what + // a drag it ends calls back waits for the next frame, after this Effect. + useEffect(() => { + pager.setPanEnabled(isPanEnabled); + }, [pager, isPanEnabled]); + + // Synchronizes the pager, an imperative system outside React (the track's + // animation and its gesture recognizers), with a controlled `index`. A new + // `index` moves the pager there. So does the render after a page that it + // reported and the owner did not take, such as a refused swipe. A + // controlled input returns to its value the same way. The move is the + // owner's own, so it is not reported back. It moves only while the two + // differ. + useEffect(() => { + if (index != null && index !== pager.index) { + pager.goTo(index, {reason: 'controlled'}); + } + }, [pager, index, reported]); + + return pager; +} diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index fe236bf310866..043ad0502c0fd 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -43,7 +43,7 @@ installed); rows marked † also on iOS 18 for the pre-glass delta. iPhone | M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` | | M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` | | M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` | -| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `scroll.paging` | +| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `scroll.paging`, `paging` | | M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` | | M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` | | M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` | @@ -72,6 +72,13 @@ initial velocity's sign flipped, a curve that overshoots. Push and pop are not Core Animation animations on iOS 26.2: UIKit writes presentation modifiers every frame from the main thread. +## Paging (UIScrollView `isPagingEnabled`, UIPageViewController) + +| ID | What | App and gesture | Extract | Method | Feeds | +| ------ | --------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------ | ------------------------------ | +| M-PG12 | End of a settle | Same pager: releases at rest from 10 %, 49 %, 60 % and 90 % of a page, logged at 120 fps | remaining distance and step velocity at the frame the offset lands exactly on the page | C | `paging.integrator.restSpeed` | +| M-PG13 | Settle velocity floor | Same pager: steady releases from 200 to 250 pt/s in 5 pt/s steps, at 25 % of a page | the slowest release whose `scrollViewWillEndDragging` velocity is not 0 | C | `paging.release.velocityFloor` | + ## Gestures (UIKit gesture recognizers) Thresholds the gesture system reads (`uikit/gestures.ts`). Safari on iOS runs diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 6a0f6b68ac379..f080e2bffe236 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -17,6 +17,7 @@ so each keeps its measured value; no shipped component reads the roles yet. | `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. | | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, paging, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | +| `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | | `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). | | `roles.ts` | The internal motion roles: the springs, durations and curves `spec:AST-094` FR4 names, registered by the shared tables, and a role's CSS form read back. | @@ -28,6 +29,7 @@ so each keeps its measured value; no shipped component reads the roles yet. | `coast.ts` | A release's momentum curves for `animator.decay`: the coast, the edge spring, a release past an edge. | | `rubberBand.ts` | UIScrollView's resistance past an edge, and its inverse. | | `projection.ts` | Release targets: the rest point nearest the projected coast. | +| `paging.ts` | Paging: UIKit's paging settle as a playable curve, its velocity floor, the animated page scroll. | | `resolveSnapPoint.ts` | Release policy: the snap point a release lands on (flick, back-out cancel, step limit), on `selectSnapPoint`. Internal. | | `selectPage.ts` | Paging: at most one page per release, on `resolveSnapPoint`. | | `bezier.ts` | Cubic-bézier timing functions (Core Animation's named curves). | diff --git a/packages/core/src/motion/paging.test.ts b/packages/core/src/motion/paging.test.ts new file mode 100644 index 0000000000000..144a472f92fb6 --- /dev/null +++ b/packages/core/src/motion/paging.test.ts @@ -0,0 +1,431 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paging.test.ts + * @input vitest, the paging math, and UIKit reference curves recorded on the + * iOS 26.2 simulator (a 402 pt paging UIScrollView, contentOffset per 60 Hz + * frame) + * @output Holds solvePaging and solveAnimatedPageScroll to the recorded + * motion + * @position Testing; validates paging.ts without a browser + */ + +import {describe, expect, it} from 'vitest'; +import { + pagingSettleVelocity, + solveAnimatedPageScroll, + solvePaging, +} from './paging'; +import {paging} from './uikit/paging'; + +const PAGE = 402; + +/** + * contentOffset per frame after a release at rest, from the frame the settle + * starts (ms, px). UIKit lab, iOS 26.2 simulator, 402 pt pages. + */ +const SETTLES: Record< + string, + {from: number; velocity?: number; to: number; frames: number[][]} +> = { + // 60 % of the way from page 1 to page 2, released at rest: page 2. + forwardFrom60: { + from: 633.33, + to: 804, + frames: [ + [0.7, 633.33], + [17.4, 639], + [34.0, 652], + [50.7, 668.67], + [67.4, 686.67], + [84.0, 704.67], + [100.7, 721.33], + [117.4, 736.33], + [134.0, 749.67], + [150.7, 761], + [167.4, 770.67], + [184.0, 778.33], + [200.7, 784.67], + [217.4, 789.67], + [234.0, 793.67], + [250.7, 796.67], + [267.4, 799], + [284.0, 800.67], + [300.7, 802], + [317.4, 803], + [334.0, 803.67], + [350.7, 804], + ], + }, + // 46 % (content) of the way to page 2, released at rest: back to page 1. + backFrom46: { + from: 589, + to: 402, + frames: [ + [0.6, 589], + [17.3, 583], + [33.9, 569], + [50.6, 550.67], + [67.3, 531], + [83.9, 511.33], + [100.6, 493], + [117.3, 476.33], + [133.9, 461.67], + [150.6, 449.33], + [167.3, 439], + [183.9, 430.33], + [200.6, 423.33], + [217.3, 417.67], + [233.9, 413.33], + [250.6, 410], + [267.3, 407.33], + [283.9, 405.33], + [300.6, 404], + [317.3, 403], + [333.9, 402.33], + [350.6, 402], + ], + }, + // Dragged back 23 % toward page 0, released at rest: page 1. + forwardFromMinus23: { + from: 311.33, + to: 402, + frames: [ + [0.6, 311.33], + [17.2, 314.33], + [33.9, 321], + [50.6, 330], + [67.2, 339.67], + [83.9, 349.33], + [100.6, 358.33], + [117.2, 366.33], + [133.9, 373.33], + [150.6, 379.33], + [167.2, 384.33], + [183.9, 388.33], + [200.6, 391.67], + [217.2, 394.33], + [233.9, 396.33], + [250.6, 398], + [267.2, 399.33], + [283.9, 400.33], + [300.6, 401], + [317.2, 401.33], + [333.9, 401.67], + [350.6, 402], + ], + }, + // Released moving forward at 25 % with a settle velocity of 0.314 pt/ms + // (steady 320 pt/s drag): the next page. + flick314From24: { + from: 499.67, + velocity: 314, + to: 804, + frames: [ + [15.0, 499.67], + [31.7, 513.33], + [48.4, 538.33], + [65.0, 569], + [81.7, 601.67], + [98.4, 633.33], + [115.0, 662.67], + [131.7, 689], + [148.4, 712], + [165.0, 731.67], + [181.7, 748], + [198.4, 761.33], + [215.0, 772], + [231.7, 780.33], + [248.4, 787], + [265.0, 792], + [281.7, 796], + [298.4, 799], + [315.0, 801], + [331.7, 802.33], + [348.4, 803.33], + [365.0, 804], + ], + }, + // 600 pt/s drag released with no hold: settle velocity 0.565 pt/ms. + flick565From24: { + from: 497.33, + velocity: 565, + to: 804, + frames: [ + [-3.2, 497.33], + [13.5, 514.33], + [30.2, 541.33], + [46.8, 573], + [63.5, 606], + [80.2, 637.67], + [96.8, 667], + [113.5, 693], + [130.2, 715.33], + [146.8, 734.33], + [163.5, 750], + [180.2, 763], + [196.8, 773.33], + [213.5, 781.67], + [230.2, 788], + [246.8, 793], + [263.5, 796.67], + [280.2, 799.33], + [296.8, 801.33], + [313.5, 802.67], + [330.2, 803.67], + [346.8, 804], + ], + }, + // 290 pt of pan past page 0 (114.33 pt shown), released at rest. + overscrollReturn: { + from: -114.33, + to: 0, + frames: [ + [0, -114.33], + [10.6, -113], + [27.3, -105.67], + [43.9, -95.33], + [60.6, -83.33], + [77.3, -71], + [93.9, -59.33], + [110.6, -48.67], + [127.3, -39.33], + [143.9, -31.33], + [160.6, -24.67], + [177.3, -19], + [193.9, -14.33], + [210.6, -10.67], + [227.3, -7.67], + [243.9, -5.33], + [260.6, -3.67], + [277.3, -2.33], + [293.9, -1.33], + [310.6, -0.67], + [327.3, -0.33], + [343.9, 0], + ], + }, +}; + +/** `setContentOffset(_:animated:)` per frame from its first frame (ms, px). */ +const ANIMATED: Record = + { + twoPages: { + from: 402, + to: 1206, + frames: [ + [0, 402], + [16.7, 408], + [33.3, 426.33], + [50, 456], + [66.7, 496], + [83.3, 545.67], + [100, 603], + [116.7, 666.67], + [133.3, 734.33], + [150, 804], + [166.7, 873.67], + [183.3, 941.33], + [200, 1005], + [216.7, 1062.33], + [233.3, 1112], + [250, 1152], + [266.7, 1181.67], + [283.3, 1200], + [300, 1206], + ], + }, + fivePages: { + from: 0, + to: 2010, + frames: [ + [0, 0], + [16.7, 15.33], + [33.3, 60.67], + [50, 134.67], + [66.7, 235], + [83.3, 359], + [100, 502.67], + [116.7, 661.33], + [133.3, 830.33], + [150, 1005], + [166.7, 1179.67], + [183.3, 1348.67], + [200, 1507.67], + [216.7, 1651], + [233.3, 1775], + [250, 1875.33], + [266.7, 1949.33], + [283.3, 1994.67], + [300, 2010], + ], + }, + }; + +/** + * Residuals of a curve against recorded frames. The paging integrator runs + * floor(elapsed ms) steps on each frame. So a frame shows the curve after the + * floored steps since the start (`steps`). A timed curve, such as the animated + * scroll, is read at the frame times (`time`). + */ +function errors( + curve: {at(t: number): number}, + frames: number[][], + clock: 'steps' | 'time', +): {rms: number; max: number} { + let steps = 0; + const residuals = frames.map(([ms, x], i) => { + if (clock === 'steps' && i > 0) { + steps += Math.floor(ms - frames[i - 1][0] + 1e-6); + } + const t = + clock === 'steps' ? steps / paging.integrator.stepsPerSecond : ms / 1000; + return curve.at(t) - x; + }); + const rms = Math.sqrt( + residuals.reduce((sum, r) => sum + r * r, 0) / residuals.length, + ); + return {rms, max: Math.max(...residuals.map(Math.abs))}; +} + +describe('pagingSettleVelocity: the floor on the one release velocity (spec:AST-100 FR3)', () => { + it('zeroes a release velocity below the floor, either way', () => { + expect(pagingSettleVelocity(200)).toBe(0); + expect(pagingSettleVelocity(-200)).toBe(0); + }); + + it('keeps a velocity at or above the floor: UIKit keeps a steady 249 pt/s release (0.249 pt/ms)', () => { + expect(pagingSettleVelocity(249)).toBe(249); + expect(pagingSettleVelocity(-260)).toBe(-260); + }); +}); + +describe("solvePaging: UIKit's paging settle, frame by frame", () => { + it.each(Object.entries(SETTLES))( + 'reproduces the recorded %s within a pixel', + (_, {from, velocity = 0, to, frames}) => { + const curve = solvePaging({from, velocity, to}); + const {rms, max} = errors(curve, frames, 'steps'); + expect(rms).toBeLessThan(0.35); + expect(max).toBeLessThan(0.8); + }, + ); + + it('ends exactly on the page about 0.33 s after a release at rest', () => { + const curve = solvePaging({from: 633.33, velocity: 0, to: 804}); + expect(curve.at(curve.duration)).toBe(804); + expect(curve.velocity(curve.duration)).toBe(0); + expect(curve.duration).toBeGreaterThan(0.3); + expect(curve.duration).toBeLessThan(0.36); + }); + + it("starts at the release position with UIKit's step velocity", () => { + const curve = solvePaging({from: 500, velocity: 1000, to: 804}); + expect(curve.at(0)).toBe(500); + // 1000 px/s enters as 1 px per step; 960 steps a second. + expect(curve.velocity(0)).toBeCloseTo(960, 6); + }); + + it('is continuous and monotonic for a release at rest', () => { + const curve = solvePaging({from: 589, velocity: 0, to: 402}); + let previous = curve.at(0); + for (let t = 0.001; t <= curve.duration; t += 0.001) { + const value = curve.at(t); + expect(value).toBeLessThanOrEqual(previous + 1e-9); + expect(Math.abs(value - previous)).toBeLessThan(3); + previous = value; + } + }); + + it('carries a forward flick on with a small overshoot-free approach', () => { + const curve = solvePaging({from: 450, velocity: 600, to: 804}); + // The pull is underdamped (ζ ≈ 0.88): it may pass the page by a hair. + let maxValue = -Infinity; + for (let t = 0; t <= curve.duration; t += 0.004) { + maxValue = Math.max(maxValue, curve.at(t)); + } + expect(maxValue).toBeLessThan(804 + 2); + expect(curve.at(curve.duration)).toBe(804); + }); + + it('settles at once when already on the page and at rest', () => { + const curve = solvePaging({from: 804, velocity: 0, to: 804}); + expect(curve.steps).toBe(0); + expect(curve.duration).toBe(0); + expect(curve.at(0.1)).toBe(804); + }); + + it('runs the documented integrator', () => { + const {springPull, friction} = paging.integrator; + const curve = solvePaging({from: 0, velocity: 0, to: 100}); + // One step from rest: v = (0 + 100·pull)·friction. + const oneStep = 100 * springPull * friction; + expect(curve.at(1 / paging.integrator.stepsPerSecond)).toBeCloseTo( + oneStep, + 9, + ); + }); +}); + +describe('solveAnimatedPageScroll: setContentOffset(_:animated:)', () => { + it.each(Object.entries(ANIMATED))( + 'reproduces the recorded %s', + (_, {from, to, frames}) => { + const curve = solveAnimatedPageScroll({from, to}); + const {rms, max} = errors(curve, frames, 'time'); + expect(rms).toBeLessThan(0.5); + expect(max).toBeLessThan(1.2); + }, + ); + + it('lasts 0.3 s whatever the distance and ends at rest', () => { + for (const distance of [402, 804, 2010]) { + const curve = solveAnimatedPageScroll({from: 0, to: distance}); + expect(curve.duration).toBe(0.3); + expect(curve.at(0.3)).toBe(distance); + expect(curve.velocity(0.3)).toBe(0); + expect(curve.velocity(0)).toBe(0); + } + }); + + // spec:AST-094 FR6: a new target keeps the current velocity (UIKit's + // setContentOffset drops it). + it('a retarget starts at the velocity it was handed and still ends on the target, at rest, on time', () => { + for (const velocity of [900, -900, 0]) { + const curve = solveAnimatedPageScroll({from: 100, to: 804, velocity}); + expect(curve.at(0)).toBe(100); + expect(curve.velocity(0)).toBe(velocity); + expect(curve.velocity(1e-4)).toBeCloseTo(velocity, -1); + expect(curve.at(0.3)).toBe(804); + expect(curve.velocity(0.3)).toBe(0); + expect(curve.velocity(0.2999)).toBeCloseTo(0, -1); + } + }); + + it('peaks at π/2 times the average speed halfway (the sinusoid, to 0.2 %)', () => { + const curve = solveAnimatedPageScroll({from: 0, to: 402}); + expect(curve.velocity(0.15) / ((Math.PI / 2) * (402 / 0.3))).toBeCloseTo( + 1, + 2, + ); + }); + + it('follows the sinusoid to 0.02 % of the distance', () => { + const curve = solveAnimatedPageScroll({from: 0, to: 402}); + for (let t = 0; t <= 0.3; t += 0.01) { + const sine = (402 * (1 - Math.cos((Math.PI * t) / 0.3))) / 2; + expect(Math.abs(curve.at(t) - sine)).toBeLessThan(0.1); + } + }); + + it("takes a theme's duration and curve", () => { + const curve = solveAnimatedPageScroll({ + from: 0, + to: 100, + durationMs: 500, + curve: [0, 0, 1, 1], + }); + expect(curve.duration).toBe(0.5); + expect(curve.at(0.25)).toBeCloseTo(50, 1); + }); +}); diff --git a/packages/core/src/motion/paging.ts b/packages/core/src/motion/paging.ts new file mode 100644 index 0000000000000..2cd978c7c42af --- /dev/null +++ b/packages/core/src/motion/paging.ts @@ -0,0 +1,161 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paging.ts + * @input A release velocity, a settle's start, end and velocity, and the UIKit + * paging constants + * @output Exports `pagingSettleVelocity`, `solvePaging` (UIKit's paging settle + * as a curve the animator plays) and `solveAnimatedPageScroll` + * @position Pure math, with no DOM and no React; hooks/usePager.ts reads it + * + * SYNC: When modified, update: + * - /packages/core/src/motion/paging.test.ts + * - /packages/core/src/motion/README.md + */ + +import type {MotionCurve} from './animator'; +import {cubicBezier, type BezierPoints} from './bezier'; +import {paging} from './uikit/paging'; + +/** + * The velocity a paging settle starts at. It is the release velocity that every + * gesture reads, zeroed below `velocityFloor`. The page choice reads the + * release velocity itself. + */ +export function pagingSettleVelocity(velocity: number): number { + return Math.abs(velocity) < paging.release.velocityFloor ? 0 : velocity; +} + +/** A paging settle: the curve and where it ends. */ +export interface PagingCurve extends MotionCurve { + readonly from: number; + readonly to: number; + /** Integration steps the settle took. */ + readonly steps: number; +} + +/** + * UIKit's paging settle from `from` (px), moving at `velocity` (px/s), onto + * `to`. It steps `paging.integrator` `stepsPerSecond` times a second and ends + * exactly on `to`. The same motion returns an overscrolled pager to its first + * or last page. + * + * Positions are content offsets along the paging axis, 0 at page 0 and growing + * toward later pages. Velocities are px/s of that offset. + * + * No closed form reproduces the per-frame stepping, so the settle integrates + * once into a table, and the curve reads the table. A release needs at most + * about 400 steps, well under a millisecond of work. + * + * The release velocity enters in px per millisecond per step, as UIKit hands it + * to the integrator. At 960 steps a second, the content starts at 96 % of the + * finger's speed, as in UIKit. + */ +export function solvePaging({ + from, + velocity, + to, +}: { + from: number; + velocity: number; + to: number; +}): PagingCurve { + const {springPull, friction, stepsPerSecond, restDistance, restSpeed} = + paging.integrator; + const xs: number[] = [from]; + const vs: number[] = [velocity / 1000]; + let x = from; + let v = velocity / 1000; + // ~10 s of steps: far beyond any settle (≈ 0.35 s from rest). + const maxSteps = stepsPerSecond * 10; + let steps = 0; + while (steps < maxSteps) { + if (Math.abs(to - x) < restDistance && Math.abs(v) < restSpeed) { + break; + } + v = (v + (to - x) * springPull) * friction; + x += v; + steps += 1; + xs.push(x); + vs.push(v); + } + xs[xs.length - 1] = to; + vs[vs.length - 1] = 0; + const duration = steps / stepsPerSecond; + const last = xs.length - 1; + const read = (table: number[], t: number): number => { + if (!(t > 0)) { + return table[0]; + } + const s = t * stepsPerSecond; + if (s >= last) { + return table[last]; + } + const i = Math.floor(s); + const f = s - i; + return table[i] + (table[i + 1] - table[i]) * f; + }; + return { + from, + to, + steps, + duration, + at: t => read(xs, t), + // px per step × steps per second + velocity: t => read(vs, t) * stepsPerSecond, + }; +} + +/** + * `setContentOffset(_:animated:)`: `paging.animatedScroll.durationMs` on its + * curve, from the current offset, at any distance. The pager passes the + * duration and curve that `resolveMotion` gives, which are the measured values + * of the table. + * + * `velocity` (px/s) is the velocity of a move that this one retargets. The new + * curve starts at it and still ends on `to`, at rest and on time. A new target + * keeps the current velocity, where UIKit drops it. The carry is a Hermite + * term, v × duration × u(1 − u)², less the curve's own starting slope. So a + * move from rest is the curve itself. + */ +export function solveAnimatedPageScroll({ + from, + to, + durationMs = paging.animatedScroll.durationMs, + curve = paging.animatedScroll.curve, + velocity, +}: { + from: number; + to: number; + durationMs?: number; + curve?: BezierPoints; + velocity?: number; +}): MotionCurve { + const duration = durationMs / 1000; + const distance = to - from; + const ease = cubicBezier(curve); + const progress = (u: number) => ease(Math.min(1, Math.max(0, u))); + // The carry, in px: the retargeted velocity over the whole duration, less + // what the curve's own starting slope already gives. + const startSlope = progress(1e-4) / 1e-4; + const carry = + velocity == null ? 0 : velocity * duration - distance * startSlope; + const at = (t: number) => { + const u = Math.min(1, Math.max(0, t / duration)); + return from + distance * progress(u) + carry * u * (1 - u) * (1 - u); + }; + return { + duration, + at, + velocity: t => { + if (t <= 0) { + return velocity ?? 0; + } + if (t >= duration) { + return 0; + } + const h = Math.min(1e-4, t / 2, (duration - t) / 2); + return (at(t + h) - at(t - h)) / (2 * h); + }, + }; +} diff --git a/packages/core/src/motion/uikit/paging.ts b/packages/core/src/motion/uikit/paging.ts new file mode 100644 index 0000000000000..e4109c88f8940 --- /dev/null +++ b/packages/core/src/motion/uikit/paging.ts @@ -0,0 +1,193 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paging.ts + * @input Nothing; data only + * @output Exports `paging`, the measured constants of a paging UIScrollView, + * and `registerPagingTable`, which registers them with the motion registry + * @position The paging domain of the UIKit constants table (motion/uikit). Only + * core reads it, so the `uikit` namespace does not re-export it + * + * SYNC: When modified, update: + * - /packages/core/src/motion/MEASUREMENTS.md (measurement tasks) + * - /packages/core/src/motion/paging.ts and paging.test.ts + */ + +import {registerMotionTable} from '../roles'; + +export const paging = { + /** + * After the lift, one model moves the content: the settle onto the chosen + * page and the return from an overscroll. Each display frame runs floor(frame + * interval in ms) steps of 1 ms: + * + * v = (v + (target − x) · springPull) · friction; x += v (v: pt per step) + * + * A 60 Hz frame runs 16 steps, not 16.67. With these values, the model + * replays eight captured releases to 0.20 pt RMS. It replays five overscroll + * returns to 0.16–0.33 pt RMS (402 pt pages, iOS 26.2, 3× screen). Without + * the floor, the error is 1.0 pt RMS, with a systematic shape. + */ + integrator: { + /** + * Velocity added per 1 ms step, per point of distance to the target page + * (pt per step²). + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. + * `_pagingSpringPull` reads the same on a 402 pt paging UIScrollView and + * on UIPageViewController's scroll view. It replays 8 captured settles to + * 0.20 pt RMS. + */ + springPull: 0.000284858, + /** + * Velocity kept per step after the pull (`_pagingFriction`, equal to + * `_defaultPagingFriction`). + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. Both read + * the same on the two scroll views of `springPull`, and they replay with + * it. + */ + friction: 0.970815, + /** + * Integration steps per second: floor(16.67 ms) = 16 steps per 60 Hz frame, + * and floor(8.33 ms) = 8 per 120 Hz frame, so 960. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. In a 60 fps + * log of 8 releases at rest, floored stepping fits to 0.20 pt RMS. + * Unfloored stepping fits to 1.0 pt RMS. + */ + stepsPerSecond: 960, + /** + * A settle ends exactly on the page once the remaining distance is below + * this many points and the step velocity is below `restSpeed`. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. The + * captured settles jump the last 0.33 pt onto the page about 0.35 s after + * a release at rest. + */ + restDistance: 0.5, + /** + * Step velocity (pt per step) below which a settle may end. + * @source GUESS — the captured settles end at about 0.04 pt per step; + * the threshold is not observable more finely at 60 fps + * @measure M-PG12 + */ + restSpeed: 0.05, + }, + release: { + /** + * Released slower than `advanceSpeed`, the scroll view settles on the page + * nearest the content position: past half a page, the next one. The pager + * keeps the start page instead for a release that moves back toward it. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. In releases + * at rest from page 1, content at 48.5 % of the page stays, and 57.5 % + * goes to the next page. On a second 402 pt pager, 45 % stays and 55 % + * advances. + */ + commitFraction: 0.5, + /** + * Released at least this fast (pt/s), the scroll view pages in the + * direction of the velocity, from any content position. A flick from 6 % of + * the page advances. The speed is UIPanGestureRecognizer's velocity at the + * last movement, and it never decays. The scroll view's + * `_horizontalVelocity` equals it at every sample. A drag held still for + * 397 ms before the lift still pages at the speed it had. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. Steady + * releases at 10, 25 and 40 % of a 402 pt page: 292.7, 295.3 and 298.6 + * pt/s stay; 310.7, 316.4 and 329.9 advance. The result is the same at + * every position: a threshold, not a projection. + */ + advanceSpeed: 300, + /** + * A settle velocity slower than this (pt/s) is zeroed, so the settle starts + * at rest. It is paging's own tuning on the one release velocity that every + * gesture reads. + * @source GUESS — the middle of the measured (200, 249] bracket. In the + * UIKit lab (iOS 26.2 simulator, Oct 23 2026), + * `scrollViewWillEndDragging` reads 0 for steady releases at 175 and 200 + * pt/s. It reads 0.249 pt/ms for a release at 249 pt/s. + * @measure M-PG13 + */ + velocityFloor: 225, + /** + * Pages a release may travel from the page the drag started on, at any + * speed and drag distance. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A drag of + * 1.5 pages from page 1 lands on page 2 at release speeds of 0, 600 and + * 3000 pt/s. + */ + maxPagesFromStart: 1, + }, + /** + * A finger that lands on a settling pager does not stop it at once. The + * settle runs on for about this long, then the content holds under the + * finger. Lifted without a move, the content settles again on the nearest + * page. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. After + * touch-downs 60 and 160 ms into a settle, the content moved on for 82.6 + * and 87.5 ms (5 frames), then held. + */ + catchStopMs: 85, + /** + * In a sheet, a horizontal pager's pan begins only for a drag within atan(1 ÷ + * this) of horizontal (26.6° for 2). A steeper drag goes to the sheet. + * Between 26.6° and 45°, neither moves. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A scroll + * view's directional lock holds the major axis up to 25° and frees it from + * 30°. An inline calendar pager in a sheet pages at 20° from horizontal. At + * 35° and 45°, it moves neither itself nor the sheet, and it leaves 55° to + * the sheet. + */ + panAxisRatio: 2, + /** + * Inside a vertical scroll view, exactly one of the pager and the scroll view + * takes a drag, decided once by its dominant axis. The pager takes it when + * |dx| ≥ this × |dy|, 45° and the tie included. The scroll view takes any + * other drag. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 24 2026. A 402 × 300 + * paging view sits in a full-screen vertical scroll view, with 150 pt drags + * at 0–90°. The pager takes 0–45°, and the page takes 50–90°. At 35°, the + * outer view stays at 0 through 86 pt of vertical travel. The choice + * happens at 10–15 pt of travel. + */ + nestedPanAxisRatio: 1, + animatedScroll: { + /** + * `setContentOffset(_:animated:)` duration in milliseconds. It is the same + * for one page and for five, as for a page control's tap. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. + * `_contentOffsetAnimationDuration` reads 0.3, and five animated moves of + * 1 to 5 pages each last 0.30 s. + */ + durationMs: 300, + /** + * Its curve is a sinusoidal ease-in-out: x = from + (to − from) · (1 − cos + * πu) ÷ 2, for u = t ÷ duration. This cubic bézier matches it to 0.02 % of + * the distance (0.09 pt over a 402 pt page). + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. The five + * moves fit the sinusoid to 0.14–0.51 pt RMS, and a 2010 pt move fits to + * 0.20 pt. The curve easeInEaseOut (0.42, 0, 0.58, 1) misses by 7.7 pt. + */ + curve: [0.364212, 0, 0.635788, 1], + }, + /** + * Past the first or last page, the content follows the finger through the + * scroll view's rubber band (scroll.rubberBand.coefficient), with d = the + * page width. It returns on the integrator above. + * @source measured: UIKit lab, iOS 26.2 simulator, Oct 23 2026. A pan of 90 + * pt past page 0 shows 42.7–45 pt, and 290 pt shows 114.3 pt. The formula + * gives 44.1 and 114.2, with c 0.55 and d 402. The return replays on the + * integrator to 0.16–0.33 pt RMS. + */ + rubberBandDimension: 'pageWidth', +} as const; + +/** + * Registers this table with the motion registry (motion/roles.ts). The pager + * then reads `setContentOffset(_:animated:)`'s duration and curve through + * `resolveMotion`, as it reads every touch motion. The table lists no role, + * because no motion role names any of its values, so each plays as measured. + * Nothing registers on import, and a second call does nothing. + */ +export function registerPagingTable(): void { + registerMotionTable('paging', paging, { + // Who gets the touch, not how anything moves. + exclude: ['catchStopMs'], + }); +} diff --git a/packages/core/src/motion/uikit/tableImports.test.ts b/packages/core/src/motion/uikit/tableImports.test.ts index 97715084abc23..4896356b06bde 100644 --- a/packages/core/src/motion/uikit/tableImports.test.ts +++ b/packages/core/src/motion/uikit/tableImports.test.ts @@ -16,12 +16,16 @@ import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs'; import {dirname, join, relative, resolve} from 'node:path'; import {describe, expect, it} from 'vitest'; -/** The tables every touch module may share: the animator and gestures read them. */ +/** + * The tables shared code may read. The animator and gestures read these. The + * paging math and the pager, one hook for every paging component, read paging. + */ const SHARED_TABLES = new Set([ 'motion/uikit/gestures.ts', // The long-press values: useLongPress and the menus' press model read them. 'motion/uikit/longPress.ts', 'motion/uikit/navigation.ts', + 'motion/uikit/paging.ts', 'motion/uikit/scroll.ts', 'motion/uikit/springs.ts', 'motion/uikit/timing.ts', diff --git a/scripts/touch-support-bundle.test.mjs b/scripts/touch-support-bundle.test.mjs index 696e106705ccd..25ad2ba4f5e9d 100644 --- a/scripts/touch-support-bundle.test.mjs +++ b/scripts/touch-support-bundle.test.mjs @@ -118,7 +118,11 @@ function filesUnder(dir, pattern) { * gives a component its gesture adds that component's import, and each import * that reaches it, with a comment naming the gesture. */ -const GESTURE_USERS = new Set([]); +const GESTURE_USERS = new Set([ + // Under a finger, a snapping Carousel with its buttons pages through the + // shared pager's pan (useCarouselPaging, hooks/usePager.ts). + 'Carousel', +]); /** Public imports that carry no gesture code, whatever the list above says. */ const NEVER_GESTURE_USERS = ['Button', 'TextInput'];