diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..95611b3f4619c --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. The `{stylexOptions}` form now passes StyleX `treeshakeCompensation: true` by default (before, its babel pass ran with StyleX's default, `false`), as the plugin form already does; pass `treeshakeCompensation: false` in `stylexOptions` to restore the old behavior. + +@vjeux diff --git a/.changeset/click-guard.md b/.changeset/click-guard.md new file mode 100644 index 0000000000000..7376b26fef796 --- /dev/null +++ b/.changeset/click-guard.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted, swallowing it for 400ms or until the next press. Keyboard and assistive-technology clicks (`detail` 0) always pass. + +@vjeux diff --git a/.changeset/gesture-arena-moving-cancel.md b/.changeset/gesture-arena-moving-cancel.md new file mode 100644 index 0000000000000..5b7e52cad131a --- /dev/null +++ b/.changeset/gesture-arena-moving-cancel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a gesture that cancels itself on a finger's move (a long press dragged past its tolerance) keeps that finger claimed until it lifts, so the page never takes the drag over midway; a resting finger is still given back when its gesture is cancelled. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..1930e34496d7e --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-hook-fixes.md b/.changeset/gesture-hook-fixes.md new file mode 100644 index 0000000000000..87f08a0948e31 --- /dev/null +++ b/.changeset/gesture-hook-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture hooks call nothing back after unmount; `usePressTracker` brakes with the host by default and keeps one tracker, so an option change or a new ref never drops a press in progress; the recognizer's arena plumbing is `protected`. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-fixes.md b/.changeset/gesture-host-fixes.md new file mode 100644 index 0000000000000..2fc0915c974a6 --- /dev/null +++ b/.changeset/gesture-host-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture host fixes from review. A page scroll under a still finger keeps the press and its click; watchers and touches nothing can claim get passive listeners only; a claimed pen holds only its own stylus touches; an element's own `touch-action` is never widened; slotted content scrolls with its shadow-tree scroller. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-owner.md b/.changeset/gesture-host-owner.md new file mode 100644 index 0000000000000..9ff3bb2d7aded --- /dev/null +++ b/.changeset/gesture-host-owner.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host becomes the one document-level touch owner: the finger count, touch gates, and a kept press for continuous-tracking controls, whose element gets `touch-action: manipulation` where the pointer is coarse. A scroll lock's page hold and the finger-scroll watch keep their own listeners and ask the host only about the touches it owns. A second finger fails a one-finger pan that has not claimed, so the page can pinch. A scroll-edge pan holds a lone finger from the first move that pulls away from an edge every scroller under it is at, so those moves are cancelled before it begins; with a second finger anywhere on the screen the page keeps its pinch. Where the primary pointer is fine, the host attaches nothing and registers no listener, following the pointer when it changes; a recognizer that reads each event's pointer itself opts out with `perEventPointer`, and a press tracker with it paints a finger's press feedback only, with no control event and no mouse or pen press. A press tracker whose hold acts (a stepper's hold-repeat) can bring a selection lock for its presses, as a recognizer can. Internal, for the touch components that adopt them; not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..eb9f2fec42db2 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md index 12eb02fc42391..908da1b367575 100644 --- a/.changeset/gesture-pan.md +++ b/.changeset/gesture-pan.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-press-tracking.md b/.changeset/gesture-press-tracking.md new file mode 100644 index 0000000000000..7a6fb875d74c1 --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks. `paintPress` in `utils/pressFeedback.ts` writes `data-astryx-press` for every press it tracks; the action-group tracker writes its groups' presses itself. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-readme.md b/.changeset/gesture-readme.md new file mode 100644 index 0000000000000..9a6293b811b68 --- /dev/null +++ b/.changeset/gesture-readme.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's README for component authors, with its browser proof in Chromium and WebKit. + +@vjeux diff --git a/.changeset/gesture-recognizer-core.md b/.changeset/gesture-recognizer-core.md index 0b0bfc389235f..28caef0d5579c 100644 --- a/.changeset/gesture-recognizer-core.md +++ b/.changeset/gesture-recognizer-core.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-recognizer-fixes.md b/.changeset/gesture-recognizer-fixes.md new file mode 100644 index 0000000000000..9e6754a78d733 --- /dev/null +++ b/.changeset/gesture-recognizer-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture recognizer and arena fixes from review; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (`spec:AST-100`: UIKit's measured blend, a scrolling surface's release through the pan's `scrollVelocity()`, and FR2's release rules). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..ffeadde679ef5 --- /dev/null +++ b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: swipe, pinch, rotation, screen-edge pan and hover gesture recognizers. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-tap-long-press.md b/.changeset/gesture-tap-long-press.md index 9ef786da65842..f1efef8d29014 100644 --- a/.changeset/gesture-tap-long-press.md +++ b/.changeset/gesture-tap-long-press.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point; no component uses them yet. +[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-watcher.md b/.changeset/gesture-watcher.md new file mode 100644 index 0000000000000..bec7cd286e49d --- /dev/null +++ b/.changeset/gesture-watcher.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a recognizer created with `claimsTouches: false` is a watcher: passive listeners only, simultaneous with every recognizer, and it follows a touch the browser takes for a scroll to the lift. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/input-stream.md b/.changeset/input-stream.md new file mode 100644 index 0000000000000..d34b681ddf710 --- /dev/null +++ b/.changeset/input-stream.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The touch press feedback and the gesture recognizers now hear page-wide input through one shared stream on the window's capture phase, so an app's window-capture `stopPropagation`, or a document-capture `stopImmediatePropagation` registered before Core, no longer hides a touch from them. A recognizer that must claim a touch mid-gesture still needs the touch to reach its element. Each event type's listener is passive unless a subscriber cancels it (the gesture host's `touchend` and `contextmenu` while a claiming recognizer or press tracker is attached, the page hold's `touchmove` while it holds, and the click guard's `click` once it first arms), so a page of pressables registers no listener that can hold a scroll. + +@vjeux diff --git a/.changeset/item-swipe-gesture-system.md b/.changeset/item-swipe-gesture-system.md new file mode 100644 index 0000000000000..c36ef7086cfd0 --- /dev/null +++ b/.changeset/item-swipe-gesture-system.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Swipe actions on `Item` and `ListItem` run on the gesture system. A row claims a sideways touch at 8 px, before a browser commits to scrolling it. Its swipe begins at UIKit's 25 px, from 0, so a short wobble no longer moves the row. The swipe is a touch behaviour, the same on every device: a finger swipes the row whatever the primary pointer, a touchscreen laptop's included, and a mouse never does. A second finger that lands before the claim leaves the touch to the browser's pinch; once the row has claimed its finger, a second finger no longer cancels the swipe. The click a browser synthesizes after a swipe is cancelled at its source instead of being swallowed by a 400 ms timer. + +@vjeux diff --git a/.changeset/item-swipe-uikit-motion.md b/.changeset/item-swipe-uikit-motion.md new file mode 100644 index 0000000000000..0f2f3367a92c1 --- /dev/null +++ b/.changeset/item-swipe-uikit-motion.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a finger, swipe actions on `Item` and `ListItem` move like a UIKit list row, the same on every touch device. The row now follows the finger on UIKit's own spring and settles on it from where it is and as fast as it was moving, so a release no longer stops dead and restarts. A flick opens the row or closes it, by its direction, and no longer fires the outermost action, which only a long drag does (with `swipeBehavior="commit"` too). Under Reduce Motion nothing a release starts travels or springs: the row cross-fades to where it lands, as `design:reduced-motion` asks. Everywhere, a finger that lands on a moving row catches it instead of being ignored until the row stops, and the motion runs on the compositor: it keeps going while the page is busy, and the colour behind the row stays exactly in the space the row uncovers. + +@vjeux diff --git a/.changeset/long-press-one-source.md b/.changeset/long-press-one-source.md new file mode 100644 index 0000000000000..a73cf7b961b6d --- /dev/null +++ b/.changeset/long-press-one-source.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useLongPress` (ContextMenu's long press, and the held finger that opens a menu from its trigger) takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer. No behavior changes. + +@vjeux diff --git a/.changeset/menu-trigger-drift.md b/.changeset/menu-trigger-drift.md new file mode 100644 index 0000000000000..98ca1acc9096b --- /dev/null +++ b/.changeset/menu-trigger-drift.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A finger held on a DropdownMenu trigger may drift up to 13px on each axis and still open the menu under every primary pointer, a phone's, a tablet's and a touchscreen laptop's alike, as AST-094 FR29 allows; it cancelled past 10px. No menu, row or trigger reads the pointer query. + +@vjeux diff --git a/.changeset/presented-hit-testing.md b/.changeset/presented-hit-testing.md new file mode 100644 index 0000000000000..e27c857277aad --- /dev/null +++ b/.changeset/presented-hit-testing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] On iOS, a touch on an element the compositor animator is moving reaches what is drawn: the animator reports each moving element's offset from its layout box, and the gesture host sends the touch, press bounds and the tap's click there instead of to the layout position iOS WebKit hit-tests. + +@vjeux diff --git a/.changeset/reduced-motion-record.md b/.changeset/reduced-motion-record.md new file mode 100644 index 0000000000000..2ec44f4c1f9c8 --- /dev/null +++ b/.changeset/reduced-motion-record.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Reduce Motion for the touch presentations follows design:reduced-motion beyond the animator too. `reducedMotionFade()` gives code beside the animator the animator's own fade (an opacity-only 200 ms cross-fade on a coarse pointer or after a touch release, at once on any other pointer), `animator.reducedMotion()` reports which form a change takes for feedback that plays no fade of its own, and `reducedMotion.stylex.ts` holds the fade's constants for StyleX transitions. The fade's length is not a theme motion role. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock-gestures.md b/.changeset/touch-selection-lock-gestures.md new file mode 100644 index 0000000000000..d1f3ab7c2b7fc --- /dev/null +++ b/.changeset/touch-selection-lock-gestures.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host applies a recognizer's own `selectionLock` to that recognizer's contact and imports no lock itself; a long press brings one only when it has a hold (a `minimumPressDuration` above 0), and `selectionLock: null` opts out. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock.md b/.changeset/touch-selection-lock.md new file mode 100644 index 0000000000000..e5285afc1f8ad --- /dev/null +++ b/.changeset/touch-selection-lock.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Where the primary pointer is fine (a mouse, a trackpad, a touchscreen laptop, a desktop pen tablet), the press feedback controller no longer registers a touch listener: it reads the primary pointer at the first finger or pen press and keeps its `touchcancel` listener only while that pointer is coarse. + +@vjeux diff --git a/.changeset/uikit-constants-table.md b/.changeset/uikit-constants-table.md index 01fd41a182b0b..2c063cdb50bc7 100644 --- a/.changeset/uikit-constants-table.md +++ b/.changeset/uikit-constants-table.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point; no component reads it yet. +[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-animator-pseudo-element.md b/.changeset/uikit-motion-animator-pseudo-element.md new file mode 100644 index 0000000000000..7ccb10bab35da --- /dev/null +++ b/.changeset/uikit-motion-animator-pseudo-element.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator drives a pseudo-element such as a dialog's ::backdrop: a static filling animation holds its value after a curve ends and after `set`, a custom property on its host promotes it, and a Reduce Motion dip borrows and hands back the pseudo-element's own opacity. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md index d489e3022e6db..6b23ce1985186 100644 --- a/.changeset/uikit-motion-animator.md +++ b/.changeset/uikit-motion-animator.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet. +[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md index 7af3d73a92a98..cbb9399568857 100644 --- a/.changeset/uikit-motion-math.md +++ b/.changeset/uikit-motion-math.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..b234a4e987752 --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: useAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-swipe-actions-values.md b/.changeset/uikit-swipe-actions-values.md new file mode 100644 index 0000000000000..11beecc12f918 --- /dev/null +++ b/.changeset/uikit-swipe-actions-values.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the UIKit values of a swipe-action row (the swipe threshold, the spring the row follows the finger and settles with, the touch claim distance the web needs) and the release rule that picks closed, open or fire (a fast release goes the way it moves, a slow one by where it is, and only a release past the commit point fires), checked against 44 of the 51 releases the UIKit lab read in its batch 10a and against its slow and moving-back releases of batch 38. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/use-finger-scroll.md b/.changeset/use-finger-scroll.md new file mode 100644 index 0000000000000..e48f0b171df03 --- /dev/null +++ b/.changeset/use-finger-scroll.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useFingerScroll(scroller)` tells a finger's scroll from the program's, and the gestures README gains a guide to migrating a raw-touch drag onto the recognizers. Internal to the touch components: not exported from the package entry point or the gestures barrel. + +@vjeux diff --git a/apps/storybook/stories/Item.stories.tsx b/apps/storybook/stories/Item.stories.tsx index eb8c2d3655b28..7337e94e81820 100644 --- a/apps/storybook/stories/Item.stories.tsx +++ b/apps/storybook/stories/Item.stories.tsx @@ -469,8 +469,8 @@ export const AlignFirstLine: Story = { /** * Swipe actions, as a mail list uses them: on a touch screen a drag toward * the inline start uncovers Archive and Delete (Delete outermost, so a long - * drag or a fling fires it); a drag toward the inline end uncovers a - * read/unread toggle. Under `reveal` (the default) the row rests open with + * drag fires it; a flick opens the row); a drag toward the inline end + * uncovers a read/unread toggle. Under `reveal` (the default) the row rests open with * every entry a real button. Every verb is also in the row's own content, so * a mouse and a keyboard reach it without the gesture. `List` clips the rows * in the inline axis for the slide. diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 4c67256a261b7..27e054e9beb71 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -10,9 +10,10 @@ pixels per CSS px, touch, mobile viewport rules), in Chromium and WebKit. The harness proves its own instruments in [`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts). -The busy-main-thread proof has a negative control beside its positive check -(a rAF animation that freezes under a blocked main thread); other self-tests -check an instrument against a known answer. +The busy-main-thread proof and the rendering-trace counts have a negative +control beside their positive checks (a rAF animation that freezes under a +blocked main thread, a `top` animation that lays out every frame); other +self-tests check an instrument against a known answer. ## Running @@ -34,6 +35,12 @@ under a rootful docker daemon the container runs as your user so its node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] ``` +WebKit composites through Mesa's software GL there, which compiles each shader +the first time a page draws it (450–700 ms for the first frame of a blurred or +newly animated layer). The script keeps Mesa's shader cache on the host +(`~/.cache/astryx-webkit-shaders/`, or `ASTRYX_WEBKIT_SHADER_CACHE`), so +only the first run on a machine pays for them. + Evidence (storyboards, curve traces) is written to `test-results/interaction-evidence/` (override with `ASTRYX_INTERACTION_EVIDENCE`) and attached to the test report. It is never @@ -41,17 +48,18 @@ committed: a pull request links or attaches it. ## Instruments -| Module | What it gives a spec | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | -| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | -| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `frameStats`. | -| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | -| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | -| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | -| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event): PNG contact sheet + JSON trace. | -| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | -| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | +| Module | What it gives a spec | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | +| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | +| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `sampleRects` (per-rAF rects), `frameStats`. | +| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | +| `trace.ts` | `traceRendering(page, body)`: main-thread counts of UpdateLayoutTree / Layout / PrePaint / Paint / Layerize / UpdateLayer / ScrollLayer / HitTest, `countBetween`, long tasks; `installLongTaskLog` / `readLongTasks`; `startCompositorTrace`: when the compositor presents. | +| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | +| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | +| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event), `castStoryboard` (real screencast frames): PNG contact sheet + JSON trace. | +| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | +| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | ## The proofs @@ -65,6 +73,14 @@ dispatch latency, and the events go through the compositor's gesture detection the limits below. Read positions a frame after a move: Chromium delivers `pointermove` aligned to animation frames. +**Stories, not Storybook.** `openStory` turns Storybook's highlight module off +on every page it opens, and checks that its root element is absent. Left on, +the module observes `#storybook-root` and, on every insertion, reads the +computed style of every element on the page inside the inserting task (117 +reads and two forced style recalculations for one exit copy in a Button story), +so a proof would measure Storybook as well as the component. +`openStory(page, origin, id, args, {storybookHighlight: true})` keeps it on. + **Curve sampling.** Pause every running animation (CSS transitions and animations are Web Animations too), seek to a time, read `getComputedStyle`: the presented value at that exact time, independent of frame timing. Finished @@ -138,7 +154,8 @@ every frame, so one late frame is not "1 of 1 moved"). Pass `maxMedianFrameMs` (20 at 60 Hz) only in a proof that claims display rate. `verdict.failures` names each miss with its numbers, and `summary` carries the raw numbers for the report. Frames are judged in presentation order: a loaded -machine can deliver them out of order. +machine can deliver them out of order. `castStoryboard` turns the frames of the +block into a filmstrip. A just-started animation can reach the screen after the block began (50–100 ms later in WebKit on a loaded machine). When the cast saw the element at rest @@ -155,8 +172,11 @@ two steps per frame, which never fails it. Move a proof's element at least two steps a frame: the self-tests move 560 px in 1,600 ms, about 4.8 px a frame. On a loaded machine Chromium's screencast can skip frames the compositor -still presents. Pass when the compositor presented each frame (epoch ms on the -page clock, from a trace of `Display::FrameDisplayed`) as `presented`. A gap between two moved +still presents. `startCompositorTrace(page)`, started with the screencast and +stopped after the block, records when the compositor presented each frame +(`Display::FrameDisplayed`); pass it as `presented`. The record is +browser-wide (its frames carry no page identity), so it throws when another +page is open as it starts or stops, or opened while it ran. A gap between two moved frames with nothing delivered inside it is then measured between the compositor's own frames: the frames it presented there count as presented (`recorderSkipped`, in the summary), and a gap it did not present through is @@ -165,8 +185,41 @@ delivered frame that did not move, is judged from the screencast, so a frozen element fails whatever else the compositor draws (a rAF animation's block presents no compositor frame at all). With the record, the 90% counts the compositor's frames from the first to the last moved frame; the gaps before and -after are the stall check's. Without a record (WebKit has no such trace), the -screencast is judged alone. +after are the stall check's. WebKit has no such trace: +`stop()` resolves to `[]` and the screencast is judged alone. + +**No Layout / Paint.** `traceRendering` records a Chromium trace around the +interaction; `countBetween` counts the steady middle of an animation (its +first and last frames may commit). A transform/opacity animation does no +Layout or Paint there; a layout-property animation does one per frame. The +harness's own animation recorder stands down while the trace runs, since its +frame loop would make every frame a main-thread one, restyling each running +animation's target and laying out an anchored one: it reads only the element a +CSS animation or transition event names, once, when that animation starts, and +catches up afterwards. A `waitForRest` awaited inside the traced body stands +down too: it waits on timers for the scheduled ends of the element's curves +(looking `frames` times a frame apart before settling on none, so an +animation the action starts a frame or two later is waited for) and polls no +style until the trace ends (in the anchored fixture's steady middle, +24 Layouts before, 0 now). Its one read of the element's animations, as it is +called, restyles the element (2 Layouts and 2 style recalculations in the +anchored fixture when called 200 ms into its animation), so await it right +after the action that starts the motion, before the window a proof counts. So do `waitForAnimation` and a watch's +`started()`: they take the element's animations from the recorder's log, +judge a started one by its own times, and reach the element through a handle, +since each `locator.evaluate` makes a main-thread frame (there too, 24 +Layouts before, 0 now). That holds for script animations and CSS +transitions. On a CSS `@keyframes` animation (and, unwatched, a cancelled CSS +transition), `waitForAnimation` and `started()` read the animation's timing, +which flushes its style and adds Layouts to the trace: wait on one with +`waitForRest`, or after the trace. Taking a watch (`watchAnimations`) inside +a trace reads the watched element's animations, which restyles it (and lays +out an anchored one), so a proof takes its traced watch before the window it +counts. The trace's `longTasks` are the page's own: only a +task the harness marked as its own read (`waitForAnimation` describing +keyframes, `blockMainThread` without `during`) is in `devtoolsTasks` instead, +and only when no page script ran inside it. A test's own `page.evaluate` stays a +long task, whether it calls page code directly or sets it off. **Storyboards.** Deterministic: frames come from seeking held animations or from stepping scripted input, never from wall-clock capture. The JSON trace @@ -176,14 +229,14 @@ backstop, a cleanup render) would do so behind a held storyboard's back: `installTimerGate` before the animation starts and `freezeTimers` while photographing. The page can still cancel a held callback, a held frame callback gets the time it actually runs, and the harness's own waits (its frame waits, -`blockMainThread` with `during`) are not held. +`blockMainThread` with `during`, `sampleRects`) are not held. An animation that repeats forever does not set a storyboard's default length; with nothing else to film, pass `times`. A touch filmstrip keeps the scripted event `timeStamp`s (Chromium), but a handler that reads `performance.now()` or `Date.now()` sees one event per photograph, about 100 ms apart, so a clock-timed release flings slower when filmed than when played: a storyboard cannot show such a gesture; record it in real time instead, with -`startScreencast`. Chromium can return an empty copy of +`startScreencast` and `castStoryboard`. Chromium can return an empty copy of the page's surface right after it changes ("Unable to capture screenshot"); a storyboard takes that photograph again two frames later, up to three attempts in all, and lists each retake under `recaptures` in the trace. @@ -200,6 +253,8 @@ in all, and lists each retake under `recaptures` in the trace. | Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence | | Curve sampling, storyboards, input log | yes | yes | | Screencast frame evidence (busy main thread) | yes | yes (WebKit's own screencast; frames keep coming while the main thread is blocked) | +| Rendering trace, long tasks | yes | no (Playwright exposes neither) | +| Compositor presentation record | yes | no: `stop()` resolves to `[]`; the busy verdict judges the screencast alone | What WebKit cannot show here — native scroll hand-off, flings, a finger lifting while another stays down, finger tracking under a busy main thread, and diff --git a/internal/interaction-harness/scripts/webkit-container.mjs b/internal/interaction-harness/scripts/webkit-container.mjs index cb20fd44aa725..a077eae415ed5 100644 --- a/internal/interaction-harness/scripts/webkit-container.mjs +++ b/internal/interaction-harness/scripts/webkit-container.mjs @@ -16,10 +16,19 @@ * docker daemon the container runs as the invoking user (rootless podman and * rootless docker already map it), so the `test-results/` it writes into the * mounted checkout stay the host user's and the next host run can clear them. + * + * WebKit composites through Mesa's software GL in the image, which compiles + * each shader variant the first time a page draws it: 450–700 ms for the + * first frame of a blurred or newly animated layer, long enough for a short + * animation to end before it is ever presented. Mesa caches what it compiles, + * and the cache is kept on the host (`~/.cache/astryx-webkit-shaders/`, + * or `ASTRYX_WEBKIT_SHADER_CACHE`), so a run starts with every shader an + * earlier run compiled instead of paying for them in its first tests. */ import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import {createRequire} from 'node:module'; +import os from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -42,6 +51,21 @@ if (engine == null) { process.exit(1); } const args = process.argv.slice(2); +// Where the container sees the host's shader cache (see the header). +const containerShaderCache = '/astryx-shader-cache'; +// Resolved to an absolute path: a bare relative value would be mounted as a +// named volume rather than a host directory. An empty value means the default. +const shaderCache = process.env.ASTRYX_WEBKIT_SHADER_CACHE + ? path.resolve(process.env.ASTRYX_WEBKIT_SHADER_CACHE) + : path.join( + os.homedir(), + '.cache', + 'astryx-webkit-shaders', + image.replace(/[^\w.-]+/g, '_'), + ); +fs.mkdirSync(shaderCache, {recursive: true}); +// The harness self-test reads this marker inside the container. +fs.writeFileSync(path.join(shaderCache, 'ASTRYX-SHADER-CACHE'), `${image}\n`); const env = [ 'ASTRYX_STORYBOOK_DIR', 'ASTRYX_INTERACTION_EVIDENCE', @@ -79,6 +103,10 @@ const command = [ '--ipc=host', '-v', `${root}:${root}`, + '-v', + `${shaderCache}:${containerShaderCache}`, + '-e', + `MESA_SHADER_CACHE_DIR=${containerShaderCache}`, '-w', root, ...env, diff --git a/internal/interaction-harness/src/busy.skip.fixture.ts b/internal/interaction-harness/src/busy.skip.fixture.ts new file mode 100644 index 0000000000000..409886f12e6f8 --- /dev/null +++ b/internal/interaction-harness/src/busy.skip.fixture.ts @@ -0,0 +1,125 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file busy.skip.fixture.ts + * @input One recorded busy-main-thread run in headless Chromium under + * parallel load: a compositor coast through a 566 ms block + * @output `frames`: the screencast frames the marker was tracked in, as + * [ms after the block started, y in CSS px, or null where the marker was not + * found]; `presented`: when the compositor presented each frame + * (`Display::FrameDisplayed`), ms after the block started; `block`: the + * block's span on the same clock. The screencast skipped frames at about + * +366 and +466 ms while the compositor kept presenting every 16.7 ms. + * @position Fixture for busy.test.ts + */ + +export const block = {from: 0, to: 566.1}; + +export const frames: ReadonlyArray = [ + [-1145.8, null], + [-1114, null], + [-1063.9, null], + [-1031.1, null], + [-996.6, null], + [-964.3, null], + [-930.7, null], + [-896.7, null], + [-864, null], + [-830.6, null], + [-797.8, null], + [-762.7, null], + [-731.3, null], + [-686.5, null], + [-680.8, null], + [-651, null], + [-634.1, null], + [-620.7, null], + [-615.8, null], + [-514.4, null], + [-495, null], + [-472.4, null], + [-465.3, null], + [-448.5, null], + [-430.3, null], + [-414.8, null], + [-398.3, null], + [-379.5, null], + [-364.2, null], + [-348.3, null], + [-330.6, null], + [-315, null], + [-297.6, null], + [-281.9, null], + [-265.1, null], + [-247.6, null], + [-230.8, null], + [-215.3, null], + [-198.6, null], + [-181, null], + [-165.3, null], + [-147.7, null], + [-132, null], + [-115.4, null], + [-98.5, null], + [-81.2, 504], + [-65.2, 500], + [-48.5, 496], + [-30.9, 491], + [-15.2, 487], + [1.3, 484], + [32.8, 480], + [49.8, 476], + [66.3, 473], + [82.8, 469], + [99.7, 466], + [116.1, 463], + [132.9, 460], + [149.5, 457], + [166.3, 454], + [182.9, 451], + [199.4, 448], + [216.2, 446], + [232.8, 443], + [249.4, 441], + [266.1, 438], + [282.7, 436], + [299.5, 434], + [316, 432], + [332.7, 430], + [349.5, 428], + [366.1, 426], + [433.1, 418], + [449.7, 417], + [466.2, 415], + [516.2, 411], + [532.8, 409], + [549.5, 408], + [566.2, 407], + [650.6, 401], + [666.2, 400], + [682.9, 399], + [699.6, 398], + [716.3, 397], + [816.1, 391], + [832.8, 391], + [849.5, 390], + [866.2, 389], + [882.9, 388], + [899.5, 388], + [916.1, 387], + [1033.8, 383], + [1050.1, 383], + [1066.7, 382], + [1083.3, 382], + [1099.5, 381], + [1117.3, 381], + [1134.2, 380], + [1149.5, 380], +]; + +export const presented: readonly number[] = [ + -98.7, -81.3, -65.3, -48.7, -31.1, -15.3, 1.3, 32.5, 49.6, 66.1, 82.7, 99.6, + 115.9, 132.6, 149.3, 166.1, 182.6, 199.3, 216, 232.6, 249.3, 266, 282.6, + 299.2, 315.8, 332.6, 349.3, 366, 381.5, 398.3, 415.1, 432.9, 449.6, 466.1, + 481.5, 498.3, 516, 532.6, 549.4, 566.1, 581.7, 598.4, 615, 631.6, 650.3, 666, +]; diff --git a/internal/interaction-harness/src/busy.test.ts b/internal/interaction-harness/src/busy.test.ts index 372860125fe6a..44a2446ecca18 100644 --- a/internal/interaction-harness/src/busy.test.ts +++ b/internal/interaction-harness/src/busy.test.ts @@ -14,6 +14,7 @@ import {describe, expect, it} from 'vitest'; import {busyWindowProof} from './busy'; +import * as skip from './busy.skip.fixture'; import {inPresentationOrder, type TrackedFrame} from './frames'; const FRAME = 1000 / 60; @@ -361,6 +362,15 @@ function drawn( return draws; } +const skipFrames = (): TrackedFrame[] => + skip.frames.map( + ([t, y]) => + ({ + t, + box: y == null ? null : {x: 0, y, width: 10, height: 10}, + }) as TrackedFrame, + ); + describe('busyWindowProof with the compositor record', () => { it('with a compositor record, judges a sparse cast from its first to its last moved frame', () => { // One screencast frame in six, as on a loaded machine, with the compositor @@ -402,6 +412,29 @@ describe('busyWindowProof with the compositor record', () => { expect(clean.movedFraction).toBeLessThanOrEqual(1); }); + it('reads frames the screencast skipped while the compositor presented them as presented: a recorded run that fails without the record', () => { + const frames = skipFrames(); + const alone = busyWindowProof(frames, skip.block, {settledWithin: 1}); + expect(alone.verdict.failures.join('\n')).toMatch( + /longest stall 6\d\.\d ms = 4 intervals > 3/, + ); + // Every frame the screencast delivered moved: only the stall remains, + // which the record resolves. + expect(alone.verdict.failures.join('\n')).not.toMatch( + /expected frames moved/, + ); + const proof = busyWindowProof(frames, skip.block, { + settledWithin: 1, + presented: skip.presented, + }); + expect(proof.verdict.failures).toEqual([]); + expect(proof.recorderSkipped).toBe(5); + expect(proof.longestStallFrames).toBeLessThanOrEqual(2); + expect(proof.summary).toMatch( + /the screencast skipped 5 frames the compositor presented \(at \+3\d\d ms, \+4\d\d ms\)/, + ); + }); + it('counts a gap the compositor drew through as skipped, and one it did not draw through as a stall', () => { const k = at(1300); const gap: [number, number] = [ diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index 20c3d86a1184e..55fd9d670d52a 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -67,6 +67,8 @@ export async function blockMainThread( const minProgress = Math.max(minProgressMs, ONE_FRAME_MS); if (during == null) { return page.evaluate(duration => { + // The harness's own work: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const from = performance.timeOrigin + performance.now(); const end = performance.now() + duration; let spins = 0; @@ -252,8 +254,8 @@ export interface BusyProof extends FrameStats { * no step and read as not moved, which the proof notes (never a failure). * * A loaded machine's screencast can skip frames the compositor still presents - * (Chromium). Given the compositor's record (`presented`: when it presented - * each frame), a gap between two moved frames with no frame + * (Chromium). Given the compositor's record (`presented`, from + * `startCompositorTrace`), a gap between two moved frames with no frame * delivered inside it is measured between the compositor's own frames: it is * a stall only where the compositor presented nothing, and the frames it did * present there are neither stalls nor misses (`recorderSkipped`). A gap @@ -310,8 +312,8 @@ export function busyWindowProof( settledAt?: number; /** * When the compositor presented each frame, epoch ms on the page clock - * (Chromium's `Display::FrameDisplayed`). Empty or absent, the screencast - * is judged alone. + * (`startCompositorTrace`; Chromium). Empty or absent, the screencast is + * judged alone. */ presented?: readonly number[]; } = {}, diff --git a/internal/interaction-harness/src/curves.ts b/internal/interaction-harness/src/curves.ts index 69b9908066f62..4a36629d1f0a7 100644 --- a/internal/interaction-harness/src/curves.ts +++ b/internal/interaction-harness/src/curves.ts @@ -134,17 +134,61 @@ function animationRecorder(): void { return entry.startedAt == null && entry.animation.playState !== 'idle'; }); }; + // While a rendering trace runs (traceRendering sets + // `__harnessRenderingTrace`), the recorder stands down. Its per-frame + // callback would make every frame a main-thread frame, in which Blink + // restyles each running animation's target, a composited one included (and + // lays out an anchored one), and document.getAnimations() flushes style: + // exactly what the trace counts. So it runs no frame callback, and a CSS + // animation or transition event reads only the animations of the element it + // names, once, at that animation's start (one that ends inside the trace is + // otherwise never seen). The animate() wrapper below still records script + // animations (it reads no style), and traceRendering resumes the recorder + // once the trace ends. + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + let looping = false; const tick = () => { + if (tracing()) { + looping = false; + return; + } record(); frame(tick); }; + const loop = () => { + if (!looping) { + looping = true; + frame(tick); + } + }; // What already runs when the recorder starts (a spinner on a page the // harness touches for the first time) is seen now, before any watch arms; // first seen a frame later, it would read as started by the watched action. + // (A recorder that starts during a trace reads once here, then waits.) record(); - frame(tick); + loop(); for (const type of ['animationstart', 'transitionrun', 'transitionstart']) { - document.addEventListener(type, record, true); + document.addEventListener( + type, + event => { + if (!tracing()) { + record(); + return; + } + const target = event.target; + if (target instanceof Element) { + // A pseudo-element's animation (`::backdrop`) is in the subtree. + const pseudo = (event as AnimationEvent | TransitionEvent) + .pseudoElement; + for (const animation of target.getAnimations({subtree: !!pseudo})) { + remember(animation); + } + } + }, + true, + ); } // A script animation is recorded as it is made: WebKit can start and finish // a short one between two frames of a fresh page. @@ -159,12 +203,36 @@ function animationRecorder(): void { }; w.__harnessAnimations = { log, - record, + // A read from outside the recorder (a wait's poll) stands down while a + // trace runs, as the frame loop does: document.getAnimations() flushes + // style. CSS start events and the animate() wrapper still log what + // starts, and resume() catches up. + record: () => { + if (!tracing()) { + record(); + } + }, + // Catches up on what started during a trace, then runs its frames again. + resume: () => { + record(); + loop(); + }, // Record first, then stamp strictly after the newest entry: a step that // changed style just before has its transition created by that read, - // and it started before the arm. - arm: () => { - record(); + // and it started before the arm. While a trace runs the page-wide read + // stands down, as record() does; the watched element's own animations + // are still read, which resolves its pending style change. That read + // restyles the element (and lays out an anchored one) in the task that + // takes the watch, so a traced proof takes its watch before the window + // it counts (README: No Layout / Paint). + arm: (element?: Element) => { + if (!tracing()) { + record(); + } else if (element != null) { + for (const animation of element.getAnimations({subtree: true})) { + remember(animation); + } + } const newest = log.length === 0 ? 0 : log[log.length - 1].seenAt; armedAt = Math.max(performance.now(), newest + 0.001); return armedAt; @@ -197,10 +265,47 @@ async function installCurveEnd(page: Page): Promise { await page.evaluate(() => { const w = window as unknown as { __harnessCurveEnd?: (animation: Animation) => number; + __harnessTiming?: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; + }; + // An effect's timing as getComputedTiming() reports it. Inside a rendering + // trace it is worked out from getTiming() instead: getComputedTiming() + // updates a running animation on demand, which restyles its target in the + // next frame (and lays out an anchored one), exactly what a trace counts. + w.__harnessTiming ??= effect => { + if (effect == null) { + return null; + } + if ( + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace !== true + ) { + return effect.getComputedTiming(); + } + const timing = effect.getTiming(); + const duration = + typeof timing.duration === 'number' ? timing.duration : 0; + const iterations = Number(timing.iterations ?? 1); + const delay = Number(timing.delay ?? 0); + const endDelay = Number(timing.endDelay ?? 0); + const activeDuration = + duration === 0 || iterations === 0 ? 0 : duration * iterations; + const derived: ComputedEffectTiming = { + ...timing, + duration, + iterations, + delay, + endDelay, + activeDuration, + endTime: Math.max(delay + activeDuration + endDelay, 0), + }; + return derived; }; if (w.__harnessCurveEnd) { return; } + const timingOf = w.__harnessTiming; const timingKeys = new Set([ 'offset', 'computedOffset', @@ -216,7 +321,7 @@ async function installCurveEnd(page: Page): Promise { ); w.__harnessCurveEnd = animation => { const effect = animation.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = timingOf(effect); const delay = Number(timing?.delay ?? 0); const active = Number(timing?.activeDuration ?? 0); if ( @@ -826,6 +931,47 @@ export interface ActiveAnimation { readonly finishedAt: number | null; } +/** + * Runs `fn` on `locator`'s element: through the locator, or, `quietly`, + * through a handle to the element it matches now (none yet: `[]`). Each + * locator.evaluate makes a main-thread frame, which a trace counts; + * `elementHandles()` and a handle's evaluate make none. + */ +async function readElement( + locator: Locator, + quietly: boolean, + fn: (element: Element, arg: A) => R[], + arg: A, + timeout: number, +): Promise { + const run = fn as unknown as (element: Element, arg: unknown) => R[]; + if (!quietly) { + return locator.evaluate(run, arg as unknown, {timeout}); + } + const handles = await locator.elementHandles(); + try { + if (handles.length > 1) { + throw new Error( + `waitForAnimation: ${String(locator)} matches ${handles.length} elements`, + ); + } + return handles.length === 0 + ? [] + : await handles[0].evaluate(run, arg as unknown); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } +} + +/** The rejection for a wait whose element is not in the document. */ +function notInDocument(name: string, cause?: unknown): Error { + return new Error( + `${name}: the element is not in the document. An exit that removes ` + + 'its element is read through its container (watch the parent).', + cause === undefined ? undefined : {cause}, + ); +} + /** * Runs a locator read within the wait's own deadline. A locator waits for * its element with no timeout of its own, so one the page removed (an exit @@ -847,11 +993,7 @@ async function withinTimeout( if ((await locator.count()) > 0) { return null; } - throw new Error( - `${name}: the element is not in the document. An exit that removes ` + - 'its element is read through its container (watch the parent).', - {cause: error}, - ); + throw notInDocument(name, error); } throw error; } @@ -876,6 +1018,15 @@ async function withinTimeout( * recorder keeps one entry per `Animation` object, so an animation reversed * or replayed mid-run is misread: a watch taken before the reversal rejects, * and an unwatched wait returns at once with a curve dated before it. + * + * Started inside a `traceRendering` body, it stands down as the recorder + * does, so it adds no frame the trace counts: it takes the element's + * animations from the recorder's log, judges a started one by its own times, + * and reaches the element through a handle to what the locator matches at + * each look. That holds for script animations and CSS transitions. On a CSS + * `@keyframes` animation (and, unwatched, a cancelled CSS transition) it reads + * the animation's timing, which flushes its style and adds Layouts to the + * trace: wait on one with `waitForRest`, or after the trace. */ export async function waitForAnimation( locator: Locator, @@ -891,17 +1042,34 @@ export async function waitForAnimation( } = {}, ): Promise { const deadline = Date.now() + timeout; - await installCurveEnd(locator.page()); + const page = locator.page(); + await installCurveEnd(page); + // Inside a rendering trace the wait stands down (see the page function), + // and it reads the element through handles: each locator.evaluate makes a + // main-thread frame (a Layout of an anchored element), where a handle's + // evaluate adds none. + const traced = await page.evaluate( + () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true, + ); for (;;) { const read = await withinTimeout( 'waitForAnimation', locator, deadline, evaluateTimeout => - locator.evaluate( + readElement( + locator, + traced, (element, {minProgress, since: watchedFrom}) => { + // The harness's own read: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const w = window as unknown as { __harnessCurveEnd: (a: Animation) => number; + __harnessTiming: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; __harnessAnimations: { log: { animation: Animation; @@ -916,6 +1084,13 @@ export async function waitForAnimation( }; const curveEnd = w.__harnessCurveEnd; const recorder = w.__harnessAnimations; + // Inside a rendering trace the wait stands down as the recorder does: + // record() reads nothing, the animations come from the recorder's log + // rather than getAnimations(), and a started one runs by its own + // times, since playState flushes a CSS animation's style. + const traced = + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; recorder.record(); const describe = ( a: Animation, @@ -923,7 +1098,7 @@ export async function waitForAnimation( recordedStart: number | null, ) => { const effect = a.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = w.__harnessTiming(effect); // The recorded start stands in once the page has cancelled it. A // run going backward began at its end: its startTime is where its // current time would reach 0, a whole run later. @@ -994,24 +1169,36 @@ export async function waitForAnimation( // finished must not answer for it. Only document-timeline animations // count, as for holdAnimations: a scroll- or view-timeline one moves // only when something scrolls and never progresses on its own. + const runs = (a: Animation) => { + if (!(a.timeline instanceof DocumentTimeline)) { + return false; + } + if (!traced || a.startTime == null) { + return a.playState === 'running'; + } + const time = Number(a.currentTime ?? 0); + return a.playbackRate < 0 + ? time > 0 + : time < Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + }; + const onElement = () => + recorder.log.filter( + e => inside(e.animation) || e.ancestors.includes(element), + ); const live = ( - watchedFrom == null + watchedFrom == null && !traced ? element.getAnimations({subtree: true}) - : recorded + : (watchedFrom == null ? onElement() : recorded) .filter(e => e.finishedAt == null) .map(e => e.animation) - ).filter( - a => - a.playState === 'running' && - a.timeline instanceof DocumentTimeline, - ); + ).filter(runs); const running = live.filter(a => a.startTime != null); // Progress along the curve, on the timeline: a delay presents nothing // (going backward, the end delay plays first), a run going backward // (`reverse()`) progresses from its end, and a faster rate presents // more curve per ms. const progressed = running.filter(a => { - const timing = a.effect?.getComputedTiming(); + const timing = w.__harnessTiming(a.effect); const time = Number(a.currentTime ?? 0); const local = a.playbackRate < 0 @@ -1036,13 +1223,20 @@ export async function waitForAnimation( return e.finishedAt; } const a = e.animation; - if (a.playState !== 'finished' || a.startTime == null) { + if (a.startTime == null) { + return null; + } + const end = Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + // Traced, by its own times; otherwise by its state. + const time = Number(a.currentTime ?? 0); + const done = traced + ? a.playbackRate < 0 + ? time <= 0 + : time >= end + : a.playState === 'finished'; + if (!done) { return null; } - const end = Number( - (a.effect as KeyframeEffect | null)?.getComputedTiming() - .endTime ?? 0, - ); return a.playbackRate < 0 ? Number(a.startTime) : Number(a.startTime) + end / Math.abs(a.playbackRate || 1); @@ -1052,7 +1246,7 @@ export async function waitForAnimation( .map(e => describe(e.animation, finishedAt(e), e.startedAt)); }, {minProgress: minProgressMs, since: since ?? null}, - {timeout: evaluateTimeout}, + evaluateTimeout, ), ); const found = read ?? []; @@ -1061,6 +1255,12 @@ export async function waitForAnimation( return found; } if (Date.now() > deadline) { + // A traced read goes through handles, which find no element rather + // than wait for one: a missing element is reported as missing, as the + // untraced read reports it. + if (traced && (await locator.count()) === 0) { + throw notInDocument('waitForAnimation'); + } throw new Error( 'waitForAnimation: no animation started on the element, none running ' + 'and none recorded since the last wait. If the action is the ' + @@ -1072,13 +1272,31 @@ export async function waitForAnimation( } } -/** Marks now as the point later waits count animations from. */ -async function armRecorder(page: Page): Promise { - return page.evaluate(() => - ( - window as unknown as {__harnessAnimations: {arm: () => number}} - ).__harnessAnimations.arm(), - ); +/** + * Marks now as the point later waits count animations from. With a locator, + * its element's pending style change is resolved first, also inside a trace + * (reached through a handle, which adds no main-thread frame). + */ +async function armRecorder(page: Page, locator?: Locator): Promise { + const handles = locator == null ? [] : await locator.elementHandles(); + try { + if (handles.length === 1) { + return await handles[0].evaluate(element => + ( + window as unknown as { + __harnessAnimations: {arm: (element?: Element) => number}; + } + ).__harnessAnimations.arm(element as Element), + ); + } + return await page.evaluate(() => + ( + window as unknown as {__harnessAnimations: {arm: () => number}} + ).__harnessAnimations.arm(), + ); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } } export interface AnimationWatch { @@ -1096,7 +1314,9 @@ export interface AnimationWatch { /** * Starts watching `locator`'s element before an action, so the animation the * action starts is caught even when it ends before the test looks (a short - * entrance on a cold page): + * entrance on a cold page). Taking the watch reads the element's animations, + * which restyles it, inside a rendering trace too: take a traced watch before + * the window the proof counts: * * const watch = await watchAnimations(sheet); * await driver.play(tap(button)); @@ -1106,7 +1326,7 @@ export async function watchAnimations( locator: Locator, ): Promise { await installCurveEnd(locator.page()); - const since = await armRecorder(locator.page()); + const since = await armRecorder(locator.page(), locator); return { started: options => waitForAnimation(locator, {...options, since}), }; @@ -1125,6 +1345,19 @@ export async function watchAnimations( * forward run under a linear or cubic-bézier effect easing); under any other * easing or direction, waitForRest waits the hold out, and a hold longer than * `timeout` rejects with "still moving". + * + * While a rendering trace runs (`traceRendering`), it stands down as the + * animation recorder does: a per-frame callback would make every frame a + * main-thread frame, which the trace counts. It then waits on timers for the + * scheduled ends of the curves on the element instead (a run going backward, + * until it finishes), so motion that no animation drives (a script writing + * styles) is not seen until the trace ends. Its one read of the element's + * animations, as it is called, restyles the element (and lays out an anchored + * one) when an animation is already running there: await it right after the + * action that starts the motion, before the window a proof counts, not in its + * middle (README: No Layout / Paint). Finding no animation, it looks again `frames` times a frame + * apart before settling, so one the action starts a few frames later is + * waited for. */ export async function waitForRest( locator: Locator, @@ -1157,15 +1390,94 @@ export async function waitForRest( backdrop, ].join(' | '); }; - // The page's real frames, even while a timer gate holds its callbacks. - const frame = + // The page's real frames and timers, even while a timer gate holds + // its callbacks: a frozen gate never holds the wait itself. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame: typeof requestAnimationFrame; + timeout?: typeof setTimeout; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? requestAnimationFrame; + const timeout = gate?.timeout ?? setTimeout; + // Set by traceRendering while it traces (see the recorder above). + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + const settle = () => { + // Later waits count animations from here. ( - window as unknown as { - __harnessTimerGate?: {frame: typeof requestAnimationFrame}; - } - ).__harnessTimerGate?.frame ?? requestAnimationFrame; - let last = read(); + window as unknown as {__harnessAnimations?: {arm: () => number}} + ).__harnessAnimations?.arm(); + resolve(); + }; + let last = ''; let still = 0; + const frames = () => { + last = read(); + still = 0; + frame(tick); + }; + // While traced: one look at the animations, then a timer to the latest + // scheduled curve end. One not started yet is looked at again a frame + // later; a trace that has ended hands over to the per-frame check. + // Finding nothing settles only after `frames` looks a frame apart, as + // the per-frame check needs `frames` still frames: an animation the + // action starts a frame or two later is still waited for. + let idle = 0; + const scheduled = () => { + let wait = 0; + for (const a of element.getAnimations({subtree: true})) { + // A scroll- or view-timeline animation moves only when something + // scrolls: it has no scheduled end to wait for (holdAnimations + // skips it the same way). + if (!(a.timeline instanceof DocumentTimeline)) { + continue; + } + if ( + a.pending || + (a.playState === 'running' && a.startTime == null) + ) { + wait = Math.max(wait, 16); + } else if (a.playState === 'running') { + // The curve's time left through the playback rate: forward, to + // its (held) end; backward (`reverse()`), to local time 0, + // where it finishes, as the untraced path waits it out. + const rate = a.playbackRate || 1; + const at = Number(a.currentTime ?? 0); + const left = + rate > 0 ? (curveEnd(a) - at) / rate : at / -rate; + wait = Math.max(wait, left); + } + } + if (wait <= 0) { + idle += 1; + if (idle >= need) { + settle(); + return; + } + wait = 16; + } else { + idle = 0; + } + const room = deadline - performance.now(); + timeout( + () => { + if (performance.now() > deadline) { + reject( + new Error('waitForRest: still moving at the deadline'), + ); + } else if (tracing()) { + scheduled(); + } else { + frames(); + } + }, + Math.min(wait, Math.max(0, room)) + 1, + ); + }; const tick = () => { const now = read(); const moving = element.getAnimations({subtree: true}).some( @@ -1182,20 +1494,20 @@ export async function waitForRest( still = !moving && now === last ? still + 1 : 0; last = now; if (still >= need) { - // Later waits count animations from here. - ( - window as unknown as { - __harnessAnimations?: {arm: () => number}; - } - ).__harnessAnimations?.arm(); - resolve(); + settle(); } else if (performance.now() > deadline) { reject(new Error(`waitForRest: still moving (${now})`)); + } else if (tracing()) { + scheduled(); } else { frame(tick); } }; - frame(tick); + if (tracing()) { + scheduled(); + } else { + frames(); + } }), {ms: timeout, need: frames}, {timeout: evaluateTimeout}, diff --git a/internal/interaction-harness/src/frames.ts b/internal/interaction-harness/src/frames.ts index c61ea7e171c6f..83841d259c17f 100644 --- a/internal/interaction-harness/src/frames.ts +++ b/internal/interaction-harness/src/frames.ts @@ -7,11 +7,12 @@ * `Screencast` domain) * @output `startScreencast` (timestamped composited frames), `markElement` * (a marker outline the frames can find), `trackMarker` (per-frame marker - * box), `frameStats`. + * box), `sampleRects` (per-rAF rect sampling), `frameStats`. * @position Frame evidence for motion proofs. The screencast is captured off * the page's main thread, from what the compositor presents, so it keeps * delivering frames while a script blocks the main thread: that is what makes - * the busy-main-thread proof possible. + * the busy-main-thread proof possible. Rect sampling is the cheap path when + * the main thread is free. * * Marker tracking needs no image library: frames are decoded and scanned in a * scratch page of the same browser (createImageBitmap + canvas), so the @@ -399,6 +400,71 @@ export async function trackMarker( } } +export interface RectSample { + /** Epoch ms of the rAF callback. */ + readonly t: number; + readonly box: Box | null; + readonly transform: string; + readonly opacity: number; +} + +/** + * Samples the element's rect, transform and opacity on every animation frame + * for `durationMs`. Main-thread sampling: it stops while the main thread is + * blocked, which is the point of the screencast path above. + */ +export async function sampleRects( + locator: Locator, + durationMs: number, +): Promise { + return locator.evaluate( + (element, duration) => + new Promise< + { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] + >(resolve => { + // The harness's own waits run on the real timers: a frozen timer gate + // (story.ts) holds only the page's. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame?: typeof requestAnimationFrame; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); + const samples: { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] = []; + const end = performance.now() + duration; + const tick = (now: number) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + samples.push({ + t: performance.timeOrigin + now, + box: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + transform: style.transform, + opacity: Number(style.opacity), + }); + if (now < end) { + frame(tick); + } else { + resolve(samples); + } + }; + frame(tick); + }), + durationMs, + ); +} + export interface FrameStats { readonly frames: number; /** Frames whose tracked value differs from the frame before. */ diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index a6c73fdf88362..b1a50ebc8c9e3 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -4,12 +4,17 @@ * @file harness.interaction.browser.spec.ts * @input Fixture pages written inline (no Storybook), real Chromium/WebKit * @output Proof that each harness instrument measures what it claims (the - * busy-main-thread proof with a negative control beside its positive - * check): scripted timestamps reach the + * busy-main-thread proof and the rendering-trace counts with a negative + * control beside their positive checks): scripted timestamps reach the * page; a flick flings natively (Chromium); a second finger and a pinch * arrive as distinct pointers; the screencast shows a compositor animation * advancing through a blocked main thread while a rAF animation freezes; - * sampled curves + * the trace counts zero Layout for a transform animation and one per frame + * for a `top` animation, and none from the harness's own animation recorder + * while an anchored element's composited animation runs (a page that asks + * for a frame every frame lays it out in each); the long-task log records + * the page's own long task but not a DevTools evaluate, which the trace + * counts; sampled curves * match the oracles, individual transform properties included; a sample at * an end reports the time it was read at; holding takes only running * document-timeline animations, each at its own offset, holds each where it @@ -18,23 +23,32 @@ * animation-state waits report a running transition and its end, and judge * rest by presented values even while an animation holds its end, and a * watch taken on first contact ignores an animation already running and - * keeps the start of one the page cancels when it ends; storyboards write a + * keeps the start of one the page cancels when it ends, and the recorder + * reads only at CSS starts while a rendering trace runs and catches up + * after it; + * storyboards write a * sheet and a trace that follows the curve, an endless animation does not * set their length, and a photograph Chromium returns empty is taken again * two frames later; a touch filmstrip keeps scripted event times while a * handler clock sees its pacing; the timer gate holds a component timer * until the filmstrip is done, drops callbacks the page cancels, stamps held - * frames with the time they run, and leaves the harness its own frames. + * frames with the time they run, and leaves the harness its own frames; a + * cast filmstrip keeps exactly the presented frames of its window, labelled + * from its zero, as PNG. * @position The harness's own test. A component spec trusts these * instruments; this file is why it may. */ +import fs from 'node:fs'; +import path from 'node:path'; import {expect, test, type Page} from '@playwright/test'; import { animationStoryboard, + castStoryboard, blockMainThread, busyWindowProof, compareCurve, + countBetween, cubicBezier, curveOf, drag, @@ -43,6 +57,7 @@ import { freezeTimers, holdAnimations, installInputLog, + installLongTaskLog, installTimerGate, markElement, merge, @@ -50,10 +65,13 @@ import { PARTIAL_LIFT_SKIP, pinch, readInputLog, + readLongTasks, releaseAnimations, sampleCurve, + sampleRects, seekAnimations, springStep, + startCompositorTrace, startScreencast, Storyboard, tap, @@ -61,6 +79,7 @@ import { timeline, touchDriver, touchStoryboard, + traceRendering, trackMarker, waitForAnimation, waitForRest, @@ -68,6 +87,16 @@ import { withFinger, } from './index'; +// An element positioned against an anchor: a composited animation of it lays +// out in every main-thread frame, so it shows any per-frame main-thread work. +const ANCHORED = ` + +
`; + const FIXTURE = `
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); }); test.describe('curve sampling', () => { @@ -1143,6 +1905,91 @@ test.describe('animation state', () => { expect(started.map(a => a.durationMs)).toEqual([300]); }); + test('while a rendering trace runs the recorder reads only at CSS starts, and catches up after it', async ({ + page, + }) => { + await fixture(page); + const box = page.locator('#box'); + const watch = await watchAnimations(box); + // Counts the recorder's reads of the page's animations (each one flushes + // style): the whole document's, and one element's. Then flags a trace, as + // traceRendering does. + await page.evaluate(() => { + const w = window as unknown as { + __reads: number; + __elementReads: number; + __harnessRenderingTrace?: boolean; + }; + w.__reads = 0; + w.__elementReads = 0; + const read = document.getAnimations.bind(document); + document.getAnimations = () => { + w.__reads += 1; + return read(); + }; + const readElement = Element.prototype.getAnimations; + Element.prototype.getAnimations = function ( + this: Element, + ...args: Parameters + ) { + w.__elementReads += 1; + return readElement.apply(this, args); + }; + w.__harnessRenderingTrace = true; + }); + // During the trace: a CSS transition and a CSS animation that both end + // inside it, and a script animation still running after it. + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + await box.evaluate(el => { + const box = el as HTMLElement; + box.style.transition = 'transform 120ms linear'; + void box.offsetWidth; + box.style.transform = 'translateX(100px)'; + box.style.animation = 'harness-fade 120ms linear'; + box.animate([{scale: '1'}, {scale: '0.9'}], {duration: 600}); + }); + await page.waitForTimeout(300); + const reads = () => + page.evaluate(() => (window as unknown as {__reads: number}).__reads); + // No frame loop: no document-wide read. One element read per CSS start + // event (transitionrun, transitionstart, animationstart), not per frame. + expect(await reads()).toBe(0); + expect( + await page.evaluate( + () => (window as unknown as {__elementReads: number}).__elementReads, + ), + ).toBeLessThanOrEqual(3); + // The trace ends: traceRendering clears the flag and resumes the recorder, + // which reads again, every frame. + await page.evaluate(() => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations: {resume: () => void}; + }; + w.__harnessRenderingTrace = false; + w.__harnessAnimations.resume(); + }); + await frames(page, 3); + expect(await reads()).toBeGreaterThan(2); + // All three are reported, the two CSS ones that ended inside the trace + // with the time they finished. + await waitForRest(box); + const started = await watch.started(); + expect(started.map(a => a.kind).sort()).toEqual([ + 'Animation', + 'CSSAnimation', + 'CSSTransition', + ]); + expect( + started + .filter(a => a.kind !== 'Animation') + .map(a => a.finishedAt != null), + ).toEqual([true, true]); + }); + test('a wait answers with the animation the action started, not one that finished before it', async ({ page, }) => { @@ -1207,6 +2054,28 @@ test.describe('animation state', () => { await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); }); + test('inside a rendering trace too, a watch taken right after a style change does not report that change', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + await fixture(page); + const box = page.locator('#box'); + await waitForRest(box); + await traceRendering(page, async () => { + // The step: a transition the next style read creates. + await box.evaluate(el => { + el.style.transition = 'opacity 300ms linear'; + el.style.opacity = '0.5'; + }); + const watch = await watchAnimations(box); + await expect(watch.started({timeout: 200})).rejects.toThrow( + /waitForAnimation/, + ); + }); + await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); + }); + test('an exit whose element the page removes at its end is still reported', async ({ page, }) => { @@ -1258,6 +2127,43 @@ test.describe('animation state', () => { } }); + test('inside a rendering trace too, a wait on an element no longer in the document rejects as missing', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + await page.evaluate(() => { + const gone = document.createElement('div'); + gone.id = 'gone'; + document.body.append(gone); + }); + const gone = page.locator('#gone'); + const watch = await watchAnimations(gone); + await page.evaluate(() => document.getElementById('gone')?.remove()); + const outcomes: string[] = []; + await traceRendering(page, async () => { + for (const wait of [ + () => watch.started({timeout: 300}), + () => waitForAnimation(gone, {timeout: 300}), + ]) { + outcomes.push( + await wait().then( + () => 'resolved', + (error: Error) => error.message, + ), + ); + } + }); + expect(outcomes).toHaveLength(2); + for (const outcome of outcomes) { + expect(outcome).toMatch(/not in the document/); + } + }); + test('a watch reports only what started after it', async ({page}) => { await fixture(page); const box = page.locator('#box'); @@ -1611,7 +2517,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); - test("the harness's own waits run on the real timers: a frozen timer gate does not hold blockMainThread's block", async ({ + test("the harness's own waits run on the real timers: a frozen timer gate holds neither blockMainThread's block nor sampleRects", async ({ page, }) => { await fixture(page); @@ -1635,6 +2541,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect( await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); await thawTimers(page); }); @@ -1823,4 +2730,58 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect(state).toMatchObject({timer: false, frame: false}); expect(state.heldFor).toBeGreaterThanOrEqual(140); }); + + test('a cast filmstrip keeps exactly the presented frames of its window, labelled from its zero', async ({ + page, + }, testInfo) => { + await fixture(page); + const box = page.locator('#box'); + // JPEG frames (WebKit's are always JPEG) take the conversion path. + const cast = await startScreencast(page, {format: 'jpeg'}); + await box.evaluate(el => + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600}, + ), + ); + await page.waitForTimeout(500); + const presented = await cast.stop(); + expect(presented.length).toBeGreaterThan(8); + // A window of the middle frames, zeroed on one of them. + const zero = presented[2].t; + const from = presented[3].t; + const to = presented[presented.length - 3].t; + const inside = presented.filter(f => f.t >= from && f.t <= to); + const seen: number[] = []; + const film = await castStoryboard(page, presented, { + name: 'harness-self-test-cast', + zero, + from, + to, + data: t => { + seen.push(t); + return {t}; + }, + }); + expect(film.frames.map(f => f.t)).toEqual(inside.map(f => f.t - zero)); + expect(film.frames.map(f => f.label)).toEqual( + inside.map(f => `t=${(f.t - zero).toFixed(1)} ms`), + ); + expect(seen).toEqual(inside.map(f => f.t)); + // Every cell is a PNG, whatever the screencast delivered. + for (const frame of film.frames) { + expect([...frame.png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + } + const files = await film.write(testInfo); + expect(files['sheet.png']).toMatch(/sheet\.png$/); + // A window with no presented frame in it films nothing. + const empty = await castStoryboard(page, presented, { + name: 'harness-self-test-cast-empty', + zero, + from: to + 1, + to: to + 2, + }); + expect(empty.frames).toEqual([]); + await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); + }); }); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 2bcb127f36d88..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -21,3 +21,4 @@ export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 5a1f2237c6725..cb57d59b50be2 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -6,7 +6,8 @@ * static server over a built Storybook * @output `phone` (the touch-phone browser context every interaction proof * runs in), `withStorybook` (serve the built Storybook for a spec file), and - * `openStory` (load one story and wait until it has rendered). + * `openStory` (load one story, with Storybook's highlight module off, and + * wait until it has rendered). * @position Stories are the reproduction path, as for every other browser * check in this repository: a scenario used for verification is checked in, * not built in a one-off page. @@ -59,13 +60,47 @@ export function withStorybook(test: TestType): () => string { }; } -/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +/** Pages whose Storybook highlight module `openStory` turned off. */ +const highlightOff = new WeakSet(); + +export interface OpenStoryOptions { + /** + * Keeps Storybook's highlight module on. It is off by default: when on, it + * observes `#storybook-root` and, on every insertion, reads the computed + * style of every element on the page inside the inserting task, so a proof + * would measure Storybook as well as the component. + */ + readonly storybookHighlight?: boolean; +} + +/** + * Loads a story in the page and waits for its first render, its fonts and two + * frames. Storybook's highlight module is off (see `OpenStoryOptions`), and + * that is checked: its root element must be absent once the story renders. + */ export async function openStory( page: Page, origin: string, storyId: string, args?: Readonly>, + {storybookHighlight = false}: OpenStoryOptions = {}, ): Promise { + if (storybookHighlight) { + if (highlightOff.has(page)) { + throw new Error( + "openStory: this page already turned Storybook's highlight module off; open the story in a new page to keep it on", + ); + } + } else if (!highlightOff.has(page)) { + highlightOff.add(page); + // Storybook's highlight module returns at once when this is set before + // its preview runs. + await page.addInitScript(() => { + ( + globalThis as unknown as {__STORYBOOK_HIGHLIGHT_INITIALIZED?: boolean} + ).__STORYBOOK_HIGHLIGHT_INITIALIZED = true; + }); + } const params = new URLSearchParams({id: storyId, viewMode: 'story'}); if (args != null) { params.set( @@ -86,6 +121,14 @@ export async function openStory( requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); + if ( + !storybookHighlight && + (await page.locator('#storybook-highlights-root').count()) > 0 + ) { + throw new Error( + "openStory: Storybook's highlight module is on (#storybook-highlights-root exists), so every insertion would also read the style of every element on the page; this Storybook may no longer read __STORYBOOK_HIGHLIGHT_INITIALIZED", + ); + } } /** Resolves after `count` animation frames have been presented in the page. */ @@ -110,8 +153,9 @@ export async function frames(page: Page, count = 2): Promise { // A held callback is queued under its timer or frame id, so the page's own // clearTimeout and cancelAnimationFrame still cancel it while frozen; a held -// frame callback gets the time it actually runs at. `frame` stays the real -// requestAnimationFrame, for the harness's own waits. +// frame callback gets the time it actually runs at. `frame` and `timeout` +// stay the real requestAnimationFrame and setTimeout, for the harness's own +// waits. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); diff --git a/internal/interaction-harness/src/storyboard.ts b/internal/interaction-harness/src/storyboard.ts index 2dc22fdc8cf04..a49207c4f81c4 100644 --- a/internal/interaction-harness/src/storyboard.ts +++ b/internal/interaction-harness/src/storyboard.ts @@ -336,7 +336,8 @@ export interface TouchStoryboardOptions extends StoryboardOptions { * photograph per event, about 100 ms between moves instead of the script's * 16.7. Its release velocity is that much lower, so a flick that dismisses * when played can settle back when filmed: a storyboard cannot show such a - * gesture; record it in real time instead. + * gesture; record it in real time instead (`startScreencast`, + * `castStoryboard`). */ export async function touchStoryboard( page: Page, @@ -364,3 +365,65 @@ export async function touchStoryboard( }); return storyboard; } + +/** + * A filmstrip of screencast frames (real presented frames, e.g. the frames of + * a busy-main-thread window), labelled with their time relative to `zero`. + * Evidence for what the compositor presented, not a deterministic storyboard. + */ +export async function castStoryboard( + page: Page, + frames: readonly { + readonly t: number; + readonly data: string; + readonly format: 'png' | 'jpeg'; + }[], + // A cast is already the screen's frames: no clip is applied to them. + options: Omit & { + readonly zero: number; + readonly from?: number; + readonly to?: number; + readonly data?: (t: number) => Readonly>; + }, +): Promise { + const storyboard = new Storyboard(page, options); + for (const frame of frames) { + if (options.from != null && frame.t < options.from) { + continue; + } + if (options.to != null && frame.t > options.to) { + continue; + } + const png = + frame.format === 'png' + ? Buffer.from(frame.data, 'base64') + : await toPng(page, frame.data); + const t = frame.t - options.zero; + storyboard.frames.push({ + label: `t=${t.toFixed(1)} ms`, + t, + data: options.data?.(frame.t) ?? {}, + png, + }); + } + return storyboard; +} + +async function toPng(page: Page, jpegBase64: string): Promise { + const scratch = await page.context().newPage(); + try { + const url = await scratch.evaluate(async data => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/jpeg;base64,${data}`)).blob(), + ); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + return canvas.toDataURL('image/png'); + }, jpegBase64); + return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + } finally { + await scratch.close(); + } +} diff --git a/internal/interaction-harness/src/storybook.interaction.browser.spec.ts b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a711feb62cb84 --- /dev/null +++ b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts @@ -0,0 +1,104 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file storybook.interaction.browser.spec.ts + * @input The built Storybook (`withStorybook`) and one of its stories + * @output `openStory` turns Storybook's highlight module off, so an insertion + * in a story carries no `getComputedStyle` scan of the page; with the module + * on (the control), every insertion carries one. Both engines; Chromium's + * trace also counts the style recalculations the scan forces + * @position Self-test for story.ts; it needs the built Storybook, as every + * component proof does + */ + +import {expect, test, type Page} from '@playwright/test'; +import {openStory, withStorybook} from './story'; +import {countBetween, traceRendering} from './trace'; + +const storybook = withStorybook(test); + +/** Counts the page's `getComputedStyle` calls, from before Storybook loads. */ +function countStyleReads(): void { + const w = window as unknown as {__styleReads: number}; + w.__styleReads = 0; + const read = window.getComputedStyle.bind(window); + window.getComputedStyle = ((element: Element, pseudo?: string | null) => { + w.__styleReads += 1; + return read(element, pseudo); + }) as typeof window.getComputedStyle; +} + +/** + * Inserts an exit copy of the story's first element into `#storybook-root`, + * as an exit animation's copy is, lets the insertion's mutation microtasks run, + * and reports what ran between its marks. + */ +async function insertCopy(page: Page) { + return page.evaluate(async () => { + const w = window as unknown as {__styleReads: number}; + const root = document.getElementById('storybook-root') as HTMLElement; + const reads = w.__styleReads; + const from = + performance.timeOrigin + performance.mark('copy:start').startTime; + const copy = (root.firstElementChild as HTMLElement).cloneNode( + true, + ) as HTMLElement; + copy.setAttribute('aria-hidden', 'true'); + root.appendChild(copy); + // A MutationObserver's callback is a microtask the insertion queued: + // these run after it. + await Promise.resolve(); + await Promise.resolve(); + const to = performance.timeOrigin + performance.mark('copy:end').startTime; + const scanned = w.__styleReads - reads; + const elements = document.body.querySelectorAll('*').length; + copy.remove(); + return {from, to, scanned, elements}; + }); +} + +test("openStory turns Storybook's highlight module off: an insertion in a story carries no getComputedStyle scan of the page", async ({ + page, + browserName, +}) => { + // The insertion's reads in every engine, and in Chromium (whose trace the + // harness reads) the style recalculations between its marks. + const measure = async (p: Page) => { + if (browserName !== 'chromium') { + return {copy: await insertCopy(p), styleRecalc: null}; + } + let copy = {from: 0, to: 0, scanned: 0, elements: 0}; + const trace = await traceRendering(p, async () => { + copy = await insertCopy(p); + }); + return { + copy, + styleRecalc: + countBetween(trace, copy.from, copy.to).UpdateLayoutTree ?? 0, + }; + }; + // The harness default. + await page.addInitScript(countStyleReads); + await openStory(page, storybook(), 'core-button--primary'); + expect(await page.locator('#storybook-highlights-root').count()).toBe(0); + const off = await measure(page); + expect(off.copy.scanned).toBe(0); + if (off.styleRecalc != null) { + expect(off.styleRecalc).toBe(0); + } + + // Control: Storybook's module kept on reads the style of every element on + // the page inside the inserting task. + const control = await page.context().newPage(); + await control.addInitScript(countStyleReads); + await openStory(control, storybook(), 'core-button--primary', undefined, { + storybookHighlight: true, + }); + expect(await control.locator('#storybook-highlights-root').count()).toBe(1); + const on = await measure(control); + expect(on.copy.scanned).toBeGreaterThanOrEqual(on.copy.elements); + if (on.styleRecalc != null) { + expect(on.styleRecalc).toBeGreaterThan(0); + } + await control.close(); +}); diff --git a/internal/interaction-harness/src/trace.ts b/internal/interaction-harness/src/trace.ts new file mode 100644 index 0000000000000..ce6a3d853d2cd --- /dev/null +++ b/internal/interaction-harness/src/trace.ts @@ -0,0 +1,466 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file trace.ts + * @input Uses a Playwright `Page` (Chromium: CDP `Tracing`) and the page's + * PerformanceObserver for long tasks + * @output `traceRendering` — counts of the main thread's rendering work + * (Layout, Paint, UpdateLayoutTree, PrePaint, …) between two marks — and + * `installLongTaskLog` / `readLongTasks`. + * @position The "no Layout/Paint during a transform/opacity animation" proof. + * A compositor animation costs the main thread nothing per frame; one that + * animates a layout property, or writes styles from JavaScript each frame, + * shows a Layout (and usually a Paint) per frame. Chromium only: WebKit + * exposes no comparable trace to Playwright. + * + * Counting is scoped to the renderer main thread that ran the page's own + * `performance.mark` calls, and to the time between the start and end marks, + * so other pages, the browser process and setup work are excluded. + * + * SYNC: Keep RENDERING_EVENTS equal to the README's list. + */ + +import type {CDPSession, Page} from '@playwright/test'; + +/** + * The mark a harness function puts on its own page-side work + * (`console.timeStamp('harness-read')` at the start of the function it + * evaluates in the page), so a trace can tell the harness's reads from page + * code a test's own evaluate runs. + */ +export const HARNESS_READ = 'harness-read'; + +/** Trace events that run page script: a listener, a timer, a frame callback. */ +const PAGE_SCRIPT_EVENTS = new Set([ + 'EventDispatch', + 'FunctionCall', + 'TimerFire', + 'FireAnimationFrame', + 'FireIdleCallback', +]); + +/** Main-thread trace events that are rendering work, by their trace names. */ +export const RENDERING_EVENTS = [ + 'UpdateLayoutTree', // style recalculation + 'Layout', + 'PrePaint', + 'Paint', + 'Layerize', + 'UpdateLayer', + 'ScrollLayer', + 'HitTest', +] as const; + +export interface RenderingTrace { + /** Count per trace event name inside the window, on the page's main thread. */ + readonly counts: Readonly>; + /** Shorthand for the four that matter most. */ + readonly layout: number; + readonly paint: number; + readonly styleRecalc: number; + readonly prePaint: number; + /** Window length, ms. */ + readonly durationMs: number; + /** + * The page's own main-thread tasks longer than 50 ms inside the window (long + * tasks), epoch ms. A task that ran a test's own DevTools command (a + * `page.evaluate`) is one, whether it calls page code directly or sets it + * off; only the harness's own marked reads are in `devtoolsTasks`. + */ + readonly longTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** + * Main-thread tasks longer than 50 ms inside the window that ran a DevTools + * command the harness marked as its own read (`HARNESS_READ`) and no page + * script, epoch ms: `waitForAnimation` describing every animation's + * keyframes, or `blockMainThread(page, ms)` without `during`. + */ + readonly devtoolsTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** Every counted rendering event with its epoch-ms time, for sub-window counts. */ + readonly events: readonly {readonly name: string; readonly t: number}[]; + /** The window, epoch ms on the page's clock. */ + readonly window: {readonly from: number; readonly to: number}; +} + +/** Counts rendering events by name inside `[from, to]` (epoch ms). */ +export function countBetween( + trace: RenderingTrace, + from: number, + to: number, +): Record { + const counts: Record = {}; + for (const e of trace.events) { + if (e.t >= from && e.t <= to) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + } + } + return counts; +} + +interface TraceEvent { + name: string; + cat: string; + ph: string; + ts: number; + dur?: number; + pid: number; + tid: number; + args?: Record; +} + +/** + * Traces the page while `body` runs and counts the rendering work its main + * thread did between a start mark (placed just before `body`) and an end mark + * (just after). The harness's animation recorder, `waitForRest`, + * `waitForAnimation` and a watch's `started()` stand down meanwhile, so a body + * may wait with any of them and add no frame of its own, with one gap: on a + * CSS `@keyframes` animation (and, for an unwatched `waitForAnimation`, a + * cancelled CSS transition) the wait reads its timing, which flushes that + * animation's style, so wait on such an animation with `waitForRest`, or + * after the trace. Taking a watch, or calling `waitForRest` while an + * animation already runs on the element, reads its animations once, which + * restyles it: do either before the window a proof counts. + */ +export async function traceRendering( + page: Page, + body: () => Promise, +): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'blink.user_timing', + 'toplevel', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-${Math.random().toString(36).slice(2, 8)}`; + const mark = (name: string) => + page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + name, + ); + // The harness's animation recorder (curves.ts) runs a callback every frame, + // which makes each frame a main-thread one where Blink restyles every running + // animation's target (and lays out an anchored one), and it reads the page's + // animations, which flushes style: exactly what this counts. It stands down + // while this flag is set, a recorder the body itself starts included, and + // resumes once the trace ends. + const standDown = (on: boolean) => + page.evaluate(value => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations?: {resume?: () => void}; + }; + w.__harnessRenderingTrace = value; + if (!value) { + w.__harnessAnimations?.resume?.(); + } + }, on); + await standDown(true); + let fromEpoch: number; + let toEpoch: number; + try { + fromEpoch = await mark(`${tag}:start`); + await body(); + toEpoch = await mark(`${tag}:end`); + } finally { + // Its catch-up read lands after the end mark, outside the counted window. + // A failure here would hide the body's own error, which is the one to see. + await standDown(false).catch(() => {}); + } + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + + const start = events.find(e => e.name === `${tag}:start`); + const end = events.find(e => e.name === `${tag}:end`); + if (start == null || end == null) { + throw new Error('traceRendering: the window marks were not traced'); + } + const onMain = (e: TraceEvent) => + e.pid === start.pid && + e.tid === start.tid && + e.ts >= start.ts && + e.ts <= end.ts; + // Trace time (µs, monotonic) -> epoch ms, anchored on the start mark. + const toEpochMs = (ts: number) => fromEpoch + (ts - start.ts) / 1000; + const counts: Record = {}; + const counted: {name: string; t: number}[] = []; + for (const e of events) { + if (!onMain(e) || e.ph === 'E') { + continue; + } + if ((RENDERING_EVENTS as readonly string[]).includes(e.name)) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + counted.push({name: e.name, t: toEpochMs(e.ts)}); + } + } + // A task that ran a DevTools command received it over the DevTools + // session's pipe; page script it set off shows as its listener, timer or + // frame callback. + const main = events.filter(e => onMain(e) && e.ph !== 'E'); + const longTasks: {start: number; durationMs: number}[] = []; + const devtoolsTasks: {start: number; durationMs: number}[] = []; + for (const task of main) { + if (task.name !== 'RunTask' || (task.dur ?? 0) <= 50_000) { + continue; + } + const end = task.ts + (task.dur ?? 0); + const inside = main.filter( + e => e !== task && e.ts >= task.ts && e.ts <= end, + ); + const devtools = inside.some( + e => + e.name === 'Receive mojo message' && + ( + e.args?.chrome_mojo_event_info as + {mojo_interface_tag?: unknown} | undefined + )?.mojo_interface_tag === 'blink.mojom.DevToolsSession', + ); + // Only the harness's own reads are set apart: a test's evaluate may call + // page code directly, which leaves no listener or timer in the trace. + const harnessRead = inside.some( + e => + e.name === 'TimeStamp' && + (e.args?.data as {message?: unknown} | undefined)?.message === + HARNESS_READ, + ); + const pageScript = inside.some(e => PAGE_SCRIPT_EVENTS.has(e.name)); + (devtools && harnessRead && !pageScript ? devtoolsTasks : longTasks).push({ + start: toEpochMs(task.ts), + durationMs: (task.dur ?? 0) / 1000, + }); + } + return { + counts, + layout: counts.Layout ?? 0, + paint: counts.Paint ?? 0, + styleRecalc: counts.UpdateLayoutTree ?? 0, + prePaint: counts.PrePaint ?? 0, + durationMs: (end.ts - start.ts) / 1000, + longTasks, + devtoolsTasks, + events: counted, + window: {from: fromEpoch, to: toEpoch}, + }; +} + +/** + * The browser's open pages. The compositor record is browser-wide: its + * `Display::FrameDisplayed` events carry no page identity. + */ +function openPages(page: Page): Page[] { + const browser = page.context().browser(); + return browser == null + ? page.context().pages() + : browser.contexts().flatMap(context => context.pages()); +} + +/** + * Watches for a page that opens while the record runs, in any context (it + * may close again before the record stops). Returns whether one opened. + */ +async function watchForOtherPages( + page: Page, +): Promise<{stop(): Promise}> { + let opened = false; + const onPage = (other: Page) => { + if (other !== page) { + opened = true; + } + }; + // The page's own context, through Playwright (a persistent context has no + // browser to ask). + page.context().on('page', onPage); + // Every context, through the browser's target discovery: a page in a + // context created during the record is a new target too. + let targets: CDPSession | null = null; + const browser = page.context().browser(); + if (browser != null) { + try { + targets = await browser.newBrowserCDPSession(); + const {targetInfos} = await targets.send('Target.getTargets'); + const known = new Set(targetInfos.map(info => info.targetId)); + targets.on('Target.targetCreated', ({targetInfo}) => { + if (targetInfo.type === 'page' && !known.has(targetInfo.targetId)) { + opened = true; + } + }); + await targets.send('Target.setDiscoverTargets', {discover: true}); + } catch { + targets = null; + } + } + return { + async stop() { + page.context().off('page', onPage); + await targets?.detach().catch(() => {}); + return opened; + }, + }; +} + +function assertOnlyPage(page: Page, when: string): void { + if (openPages(page).some(other => other !== page && !other.isClosed())) { + throw new Error( + `startCompositorTrace: another page is open ${when}; the compositor record is browser-wide (its frames carry no page identity), so another page's frames would be credited to this one`, + ); + } +} + +/** + * Records when the compositor presents each frame, for `busyWindowProof`'s + * `presented`: a Chromium trace of `Display::FrameDisplayed`, on the page + * clock. Start it with the screencast, before the block, and stop it after. + * Engines without a CDP trace (WebKit) resolve `stop()` to [], and the + * verdict then judges the screencast alone. It runs its own trace, so not + * inside `traceRendering`. The record is browser-wide, so it throws when + * another page is open as it starts or stops, or opened while it ran. + */ +export async function startCompositorTrace( + page: Page, +): Promise<{stop(): Promise}> { + let cdp: CDPSession; + try { + cdp = await page.context().newCDPSession(page); + } catch { + return {stop: async () => []}; + } + try { + assertOnlyPage(page, 'as the trace starts'); + } catch (error) { + await cdp.detach().catch(() => {}); + throw error; + } + const others = await watchForOtherPages(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'disabled-by-default-display.framedisplayed', + 'blink.user_timing', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-compositor-${Math.random().toString(36).slice(2, 8)}`; + const epoch = await page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + tag, + ); + return { + async stop() { + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + if (await others.stop()) { + throw new Error( + "startCompositorTrace: another page opened during the record; the compositor record is browser-wide (its frames carry no page identity), so that page's frames would be credited to this one", + ); + } + assertOnlyPage(page, 'as the trace stops'); + const mark = events.find(e => e.name === tag); + if (mark == null) { + throw new Error('startCompositorTrace: the clock mark was not traced'); + } + // Trace time (µs, monotonic) -> epoch ms, anchored on the mark. + return events + .filter(e => e.name === 'Display::FrameDisplayed') + .map(e => epoch + (e.ts - mark.ts) / 1000) + .sort((a, b) => a - b); + }, + }; +} + +const LONG_TASK_SCRIPT = `(() => { + if (window.__harnessLongTasks) return; + const tasks = []; + const log = {tasks, supported: false}; + window.__harnessLongTasks = log; + if (typeof PerformanceObserver === 'undefined' || !(PerformanceObserver.supportedEntryTypes || []).includes('longtask')) return; + try { + new PerformanceObserver(list => { + for (const entry of list.getEntries()) tasks.push({start: performance.timeOrigin + entry.startTime, durationMs: entry.duration, name: entry.name}); + }).observe({type: 'longtask', buffered: true}); + log.supported = true; + } catch {} +})()`; + +/** + * Records every long task (> 50 ms) the page runs (install before navigation; + * Chromium), through the Long Tasks API. That sees the tasks of the page's own + * event loop: a timer, an event handler, `blockMainThread(page, ms, {during})`. + * A DevTools `page.evaluate`, such as `blockMainThread(page, ms)` without + * `during`, is not one of them and is never recorded here; `traceRendering` + * reports it in `devtoolsTasks`, apart from the page's `longTasks`. + */ +export async function installLongTaskLog(page: Page): Promise { + await page.addInitScript(LONG_TASK_SCRIPT); +} + +export interface LongTask { + /** Epoch ms. */ + readonly start: number; + readonly durationMs: number; + readonly name: string; +} + +/** + * Long tasks recorded since navigation, optionally only those overlapping a + * window. Fails closed: it throws when the log never ran on this document + * (installed after the page navigated) or the engine has no Long Tasks API + * (WebKit), so an empty answer is evidence of no long task, never of no log. + */ +export async function readLongTasks( + page: Page, + range?: {from: number; to: number}, +): Promise { + const log = await page.evaluate( + () => + ( + window as unknown as { + __harnessLongTasks?: {tasks: LongTask[]; supported: boolean}; + } + ).__harnessLongTasks ?? null, + ); + if (log == null) { + throw new Error( + 'readLongTasks: the long-task log is not running on this document; call installLongTaskLog(page) before navigating', + ); + } + if (!log.supported) { + throw new Error( + 'readLongTasks: this engine has no Long Tasks API, so the log records nothing', + ); + } + const tasks = log.tasks; + return range == null + ? tasks + : tasks.filter( + t => t.start < range.to && t.start + t.durationMs > range.from, + ); +} diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/core/package.json b/packages/core/package.json index 295c3d98433c4..3bba9ae4f24ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -864,6 +864,7 @@ "devDependencies": { "@astryxdesign/a11y-spec": "workspace:*", "@astryxdesign/cli": "workspace:*", + "@astryxdesign/interaction-harness": "workspace:*", "@babel/cli": "catalog:", "@babel/core": "catalog:", "@babel/preset-react": "catalog:", diff --git a/packages/core/src/Item/Item.doc.mjs b/packages/core/src/Item/Item.doc.mjs index b5cf3702eb0ca..e9c724802aaee 100644 --- a/packages/core/src/Item/Item.doc.mjs +++ b/packages/core/src/Item/Item.doc.mjs @@ -199,13 +199,13 @@ export const docs = { name: 'swipeActions', type: '{leading?: ItemSwipeAction[]; trailing?: ItemSwipeAction[]}', description: - "Swipe actions for touch: the verbs a sideways drag uncovers on each side, outermost last, each `{id?, label, icon?, onActivate, isDisabled?, variant?: 'neutral' | 'accent' | 'destructive', hasRemoval?}`. `leading` is uncovered by a drag toward the inline end, `trailing` toward the inline start. A mouse never starts the drag and a mostly vertical drag stays the scroller's. The gesture is a touch accelerator and never a row's only path to a verb: a verb reachable only by swipe is unreachable by keyboard and by mouse, so every entry must also be reachable through the row's own content. Served on a row whose role permits interactive descendants (a listitem, a row with no role) and not on an option, a menu item, or a row that is the enlarged target of a native radio. The element containing the rows clips in the inline axis with overflow-inline: clip — List does; any other host does it once. `hasRemoval` only says the row holds out after the action fires instead of springing back. The gesture and its panels are loaded on demand the first time a row has swipe actions, so a row without them ships none of it.", + "Swipe actions for touch: the verbs a sideways drag uncovers on each side, outermost last, each `{id?, label, icon?, onActivate, isDisabled?, variant?: 'neutral' | 'accent' | 'destructive', hasRemoval?}`. `leading` is uncovered by a drag toward the inline end, `trailing` toward the inline start. A mouse never starts the drag and a mostly vertical drag stays the scroller's. The gesture is a touch accelerator and never a row's only path to a verb: a verb reachable only by swipe is unreachable by keyboard and by mouse, so every entry must also be reachable through the row's own content. Served on a row whose role permits interactive descendants (a listitem, a row with no role) and not on an option, a menu item, or a row that is the enlarged target of a native radio. The element containing the rows clips in the inline axis with overflow-inline: clip — List does; any other host does it once. `hasRemoval` only says the row holds out after the action fires instead of springing back. The swipe is a touch behaviour, the same on every device: a finger swipes the row whatever the primary pointer, a touchscreen laptop's included, and a mouse never does. The gesture and its panels load with the row, so its first swipe never waits.", }, { name: 'swipeBehavior', type: "'reveal' | 'commit'", description: - 'What a swipe does. `reveal` rests the row open with every entry a real button and fires the outermost on a long drag or a fling; `commit` slides the row out and fires the outermost on release, with nothing resting and a presentational panel. `commit` fits one entry per side.', + 'What a swipe does. `reveal` rests the row open with every entry a real button, and a long drag fires the outermost. As in a UIKit list, a fast release goes the way it moves (a flick opens the row, a flick back closes it) and a slow one past half the panel opens it. `commit` slides the row out and fires the outermost on a release past the commit point, with nothing resting and a presentational panel. `commit` fits one entry per side. The row follows the finger on a spring and settles on the same one, from where it is and as fast as it was moving, on the compositor, and a finger that lands on a moving row catches it.', default: "'reveal'", }, { diff --git a/packages/core/src/Item/Item.source-build.test.mjs b/packages/core/src/Item/Item.source-build.test.mjs index f9f6c16da7375..abd170f16058b 100644 --- a/packages/core/src/Item/Item.source-build.test.mjs +++ b/packages/core/src/Item/Item.source-build.test.mjs @@ -4,8 +4,9 @@ * @file Item.source-build.test.mjs * @input Uses Babel, the core build config, and the Item and ItemSwipeLayer * sources - * @output Verifies the shipped Item module reaches the swipe gesture only - * through a dynamic import, so a row without swipe actions bundles none of it + * @output Verifies the shipped Item module imports the swipe layer with the + * row (no lazy chunk, which could miss a first swipe) and reaches the + * gesture only through it * @position Regression test for the import graph of the shipped Item module */ @@ -72,13 +73,13 @@ async function compiledImportsOf(fileName) { } describe('the shipped Item module and the swipe gesture', () => { - it('reaches the swipe layer only through a dynamic import, and the gesture not at all', async () => { + it('imports the swipe layer with the row, with no lazy chunk, and the gesture only through it', async () => { const {statics, dynamics} = await compiledImportsOf('Item.tsx'); - // The gesture and its panels are the layer's: a row without swipe actions - // never loads them, so this module must not import them statically. + // A lazily loaded layer could miss a row's first swipe while its chunk + // was on the way: the layer comes with the module, the gesture with it. + expect(statics).toContain('./ItemSwipeLayer.js'); expect(statics).not.toContainEqual(expect.stringMatching(/useSwipeAction/)); - expect(statics).not.toContainEqual(expect.stringMatching(/ItemSwipeLayer/)); - expect(dynamics).toEqual(['./ItemSwipeLayer.js']); + expect(dynamics).toEqual([]); }); it('keeps the gesture inside the swipe layer', async () => { diff --git a/packages/core/src/Item/Item.swipeLayer.test.tsx b/packages/core/src/Item/Item.swipeLayer.test.tsx index 9f1ebc039872a..4bec52cbe24b9 100644 --- a/packages/core/src/Item/Item.swipeLayer.test.tsx +++ b/packages/core/src/Item/Item.swipeLayer.test.tsx @@ -4,15 +4,16 @@ * @file Item.swipeLayer.test.tsx * @input Item with and without swipe actions; the swipe layer module, spied * @output Verifies a row without swipe actions never renders the swipe layer, - * and a row with them renders it into the root once it has loaded - * @position Companion to Item.test.tsx for the lazily loaded swipe layer + * and a row with them renders it into the root at its first render + * @position Companion to Item.test.tsx for the swipe layer */ import {createElement, type ReactNode} from 'react'; -import {describe, expect, it, vi} from 'vitest'; -import {act, render, screen, waitFor} from '@testing-library/react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {act, render, screen} from '@testing-library/react'; import {Item} from './Item'; import type {ItemSwipeLayerProps} from './ItemSwipeLayer'; +import {emulateTouch} from './__tests__/emulateTouch'; const {layerRenders} = vi.hoisted(() => ({layerRenders: vi.fn()})); @@ -30,6 +31,10 @@ vi.mock('./ItemSwipeLayer', async importOriginal => { }); describe('Item and the swipe layer', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('renders a row without swipe actions with no swipe layer at all', async () => { render(
    @@ -43,15 +48,34 @@ describe('Item and the swipe layer', () => {
, ); - // Let anything the first render scheduled — a lazy chunk resolving, an - // effect — run before looking. + // Let anything the first render scheduled run before looking. await act(async () => {}); expect(layerRenders).not.toHaveBeenCalled(); expect(document.querySelector('[data-swipe-panel]')).toBeNull(); expect(screen.getByTestId('row').childElementCount).toBe(1); }); - it('renders the swipe layer into the root of a row with swipe actions', async () => { + it('renders the swipe layer under a fine primary pointer too: a finger swipes on every device', () => { + // A touchscreen laptop: a fine primary pointer, and a finger beside it. + // The gesture reads each touch's pointer (`spec:AST-057`), so the layer + // is there; a mouse never starts it (Item.test.tsx). + // A fine primary pointer in this context, before anything is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + layerRenders.mockClear(); + render( + {}}]}} + />, + ); + const row = screen.getByTestId('row'); + expect(row.querySelector('[data-swipe-panel="trailing"]')).not.toBeNull(); + expect(layerRenders).toHaveBeenCalled(); + }); + + it("renders the swipe layer into the root of a row with swipe actions on a phone's pointer", () => { + emulateTouch(); render( { />, ); const row = screen.getByTestId('row'); - const panel = await waitFor(() => { - const found = row.querySelector('[data-swipe-panel="trailing"]'); - expect(found).not.toBeNull(); - return found; - }); + // At the first render: nothing to wait for. + const panel = row.querySelector('[data-swipe-panel="trailing"]'); + expect(panel).not.toBeNull(); expect(layerRenders).toHaveBeenCalled(); // The layer adds nothing but the panel: the root is still the content // followed by the out-of-flow panel, with no wrapper for either. diff --git a/packages/core/src/Item/Item.test.tsx b/packages/core/src/Item/Item.test.tsx index 8358b096fb9ef..99d78603993b2 100644 --- a/packages/core/src/Item/Item.test.tsx +++ b/packages/core/src/Item/Item.test.tsx @@ -10,27 +10,15 @@ */ import {use, useRef} from 'react'; -import { - afterEach, - beforeAll, - beforeEach, - describe, - it, - expect, - vi, -} from 'vitest'; -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import {afterEach, beforeEach, describe, it, expect, vi} from 'vitest'; +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; import {rulesDeclaredFor} from '../__tests__/pressState'; import userEvent from '@testing-library/user-event'; import {Item} from './Item'; import {ItemDescriptionContext} from './ItemDescriptionContext'; +import {emulateTouch} from './__tests__/emulateTouch'; +import {TapGestureRecognizer, useGestureRecognizer} from '../gestures'; +import {pointerQuery} from '../gestures/__tests__/pointerQuery'; /** * Item in delegation mode: `interactiveRef` points at a nested control that @@ -683,29 +671,17 @@ describe('Item', () => { }); describe('swipeActions', () => { - // The panels and the gesture are loaded lazily (React.lazy + Suspense), so - // the first row with swipe actions renders them a tick after it mounts. - // Mount one once up front: the resolved module is cached on the lazy - // component, and every row below renders its panels synchronously. - beforeAll(async () => { - render( - {}}]}} - />, - ); - await waitFor(() => { - expect( - screen.getByTestId('warm').querySelector('[data-swipe-panel]'), - ).not.toBeNull(); - }); - cleanup(); - }); - + // The gesture system outlives a test (one per document) and keeps the time + // of the touches it has seen; a document clock never runs backwards, so + // each test's fake clock starts after the last one's. + let epoch = 0; beforeEach(() => { - // The gesture reads the clock for its fling test and its settle timers: - // fake both, and space the moves out the way a finger does. + // The suite answers as a phone (a coarse primary pointer), where Reduce + // Motion cross-fades every change; `under a fine primary pointer` below + // checks that a finger swipes there too and a mouse never does. + emulateTouch(); + // The gesture reads touch times, and the springs run on the document + // clock and finish on timers: fake them all and move them together. vi.useFakeTimers({ toFake: [ 'setTimeout', @@ -716,67 +692,164 @@ describe('swipeActions', () => { 'performance', ], }); + epoch += 100_000; + vi.advanceTimersByTime(epoch); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); - /** The first finger down: the primary touch pointer. */ - function touch( - element: Element, - type: 'pointerDown' | 'pointerMove' | 'pointerUp' | 'pointerCancel', - x: number, - y = 10, - ) { - vi.advanceTimersByTime(50); - fireEvent[type](element, { - clientX: x, - clientY: y, - isPrimary: true, - pointerId: 7, - pointerType: 'touch', - }); + interface Contact { + id: number; + x: number; + y: number; + target: Element; } - /** A second finger: a touch pointer that is not the primary one. */ - function secondFinger( - element: Element, - type: 'pointerDown' | 'pointerMove' | 'pointerUp' | 'pointerCancel', - x: number, - y = 10, - ) { - vi.advanceTimersByTime(10); - fireEvent[type](element, { - clientX: x, - clientY: y, - isPrimary: false, - pointerId: 8, - pointerType: 'touch', + /** The fingers down on the page, for each touch event's `touches`. */ + const down = new Map(); + + /** + * A touch event as a phone sends it, stamped with the document clock. The + * gesture system reads touches from touch events (jsdom has `ontouchstart`, + * as a phone does), so a finger here is its touch events. + */ + function sendTouch( + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', + contact: Contact, + cancelable = type !== 'touchcancel', + ): Event { + const event = new Event(type, { + bubbles: true, + cancelable, + composed: true, + }); + const toTouch = (c: Contact) => ({ + identifier: c.id, + clientX: c.x, + clientY: c.y, + target: c.target, }); + Object.defineProperty(event, 'changedTouches', { + value: [toTouch(contact)], + }); + Object.defineProperty(event, 'touches', { + value: [...down.values()].map(toTouch), + }); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + contact.target.dispatchEvent(event); + return event; } - /** The time a settle takes, plus the frame before it. */ - const settled = () => void act(() => vi.advanceTimersByTime(250)); + /** + * One finger: `down` lands it, `move` slides it (16 ms per step, a frame), + * `rest` holds it still, `up` lifts it (a frame later, or at once for a + * flick that is still moving when it lifts). Each returns the event it + * sent, so a test can ask whether the row cancelled it. A resting finger + * keeps reporting where it is, which is what brings UIKit's velocity + * filter (the gesture system's) down to rest: it never decays on its own. + */ + function finger(target: Element, id = 1) { + let contact: Contact = {id, x: 0, y: 10, target}; + const send = ( + type: Parameters[0], + cancelable?: boolean, + ) => { + let event: Event | undefined; + act(() => { + event = sendTouch(type, contact, cancelable); + }); + return event as Event; + }; + return { + down(x: number, y = 10) { + void act(() => vi.advanceTimersByTime(16)); + contact = {id, x, y, target}; + down.set(id, contact); + return send('touchstart'); + }, + /** `cancelable: false`: the browser is already panning or zooming with this finger. */ + move(x: number, y = contact.y, {cancelable = true} = {}) { + void act(() => vi.advanceTimersByTime(16)); + contact = {...contact, x, y}; + down.set(id, contact); + return send('touchmove', cancelable); + }, + rest(ms = 320) { + for (let t = 0; t < ms; t += 16) { + void act(() => vi.advanceTimersByTime(16)); + send('touchmove'); + } + }, + up({afterMs = 16} = {}) { + void act(() => vi.advanceTimersByTime(afterMs)); + down.delete(id); + return send('touchend'); + }, + cancel() { + down.delete(id); + return send('touchcancel'); + }, + }; + } + afterEach(() => { + down.clear(); + }); - const travelOf = (row: HTMLElement) => - row.style.getPropertyValue('--_item-swipe-travel'); + /** Long enough for any settle to finish, and for what it lands to render. */ + const settled = async () => { + await act(async () => { + vi.advanceTimersByTime(1500); + }); + }; - /** The panel of a side: the out-of-flow child on the root carrying it. */ + /** Answer `prefers-reduced-motion: reduce` on top of the page's other answers. */ + const reduceMotion = () => { + const matchMedia = window.matchMedia.bind(window); + vi.stubGlobal('matchMedia', (query: string) => { + const list = matchMedia(query); + return query === '(prefers-reduced-motion: reduce)' + ? {...list, matches: true} + : list; + }); + }; + + /** + * Where a moving part is going: the animator writes each part's resting + * value inline (jsdom runs no Web Animations, so that is all it writes). + */ + const xOf = (element: Element | null) => + parseFloat( + (element as HTMLElement | null)?.style.getPropertyValue('translate') || + '0', + ); + + /** The window of a side: the out-of-flow child of the root carrying it. */ const panelOf = (row: HTMLElement, side: 'leading' | 'trailing') => - row.querySelector(`[data-swipe-panel="${side}"]`); + row.querySelector(`[data-swipe-panel="${side}"]`); + const trackOf = (row: HTMLElement, side: 'leading' | 'trailing') => + panelOf(row, side)?.firstElementChild as HTMLElement; + /** A side's entries: the track's children, each its own moving element. */ + const entriesOf = (row: HTMLElement, side: 'leading' | 'trailing') => + Array.from(trackOf(row, side)?.children ?? []) as HTMLElement[]; /** - * jsdom lays nothing out: give the row a width and each side's entries a - * natural width of 72 px per entry, as the real panel measures them. + * jsdom lays nothing out: give the row a width and each entry a natural + * width of 72 px, as the real panel measures them. */ function layOut(row: HTMLElement) { Object.defineProperty(row, 'clientWidth', {configurable: true, value: 300}); - for (const panel of row.querySelectorAll('[data-swipe-panel]')) { - const entries = panel.firstElementChild as HTMLElement; - Object.defineProperty(entries, 'offsetWidth', { - configurable: true, - value: 72 * entries.childElementCount, - }); + for (const side of ['leading', 'trailing'] as const) { + if (panelOf(row, side) == null) { + continue; + } + for (const entry of entriesOf(row, side)) { + Object.defineProperty(entry, 'offsetWidth', { + configurable: true, + value: 72, + }); + } } } @@ -799,7 +872,7 @@ describe('swipeActions', () => { behavior?: 'reveal' | 'commit'; onClick?: () => void; } = {}) { - render( + const {unmount} = render( { ); const row = screen.getByTestId('row'); layOut(row); - return {row, onClick}; + return {row, onClick, unmount}; + } + + /** + * A drag toward the leading side (rightward) by `travel` px of swipe, past + * the 25 px the pan needs to begin, held still, then lifted. + */ + function dragLeading(row: HTMLElement, travel: number) { + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); // 25 px: the swipe begins here, at 0 + f.move(35 + travel / 2); + f.move(35 + travel); + f.rest(); + f.up(); + return f; } + it('swipes on the first touch right after mount, with no import to wait for', async () => { + // The first row with swipe actions in this file: a lazily loaded layer + // would still be on its way here, and the touch would move nothing. + const {row} = renderRow(); + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); + }); + it('is served on a role-less row and a listitem, and not on a row whose role forbids interactive descendants', () => { const actions = {leading: [{label: 'Unread', onActivate: vi.fn()}]}; const {rerender} = render( @@ -895,232 +993,409 @@ describe('swipeActions', () => { expect( screen.getByRole('button', {name: 'Unread'}).closest('[inert]'), ).toBe(panel); - // Zero width with no padding of its own: a padded box at `width: 0` is - // still as wide as its padding and would paint a sliver of the colour. - expect(rulesDeclaredFor(panel).some(r => r.includes('padding'))).toBe( - false, - ); - // Its width is the travel toward its side, floored at zero; at rest the - // travel is unset. - expect( - rulesDeclaredFor(panel).some(r => r.includes('width: max(0px')), - ).toBe(true); - expect(travelOf(row)).toBe(''); + // The window clips to the row's resting box and takes no touch; the + // track that paints sits beside it at rest, so nothing shows. + const windowRules = rulesDeclaredFor(panel).join(';'); + expect(windowRules).toContain('overflow: clip'); + expect(windowRules).toContain('pointer-events: none'); + const trackRules = rulesDeclaredFor(trackOf(row, 'leading')).join(';'); + expect(trackRules).toContain('inset-inline-end: 100%'); + expect(trackRules).not.toContain('padding'); + // Nothing moved: the root carries no translation of the swipe's. + expect(row.style.getPropertyValue('translate')).toBe(''); + }); + + it('moves only by transforms: the root, and a window, a track and entries that can never drift apart', () => { + const {row} = renderRow({ + leading: [ + {label: 'Unread', onActivate: vi.fn()}, + {label: 'Pin', onActivate: vi.fn()}, + ], + }); + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(95); + // 60 px of swipe toward the leading side (rightward). + expect(xOf(row)).toBe(60); + // The window stays over the resting box; the track moves with the row; + // every entry stays pinned at the outer edge (144 px of entries), as one + // block with no wrapper. + expect(xOf(panelOf(row, 'leading'))).toBe(-60); + expect(xOf(trackOf(row, 'leading'))).toBe(60); + expect(entriesOf(row, 'leading').map(xOf)).toEqual([144 - 60, 144 - 60]); + // No layout property is ever written. + for (const element of [ + row, + panelOf(row, 'leading'), + trackOf(row, 'leading'), + ...entriesOf(row, 'leading'), + ]) { + const style = (element as HTMLElement).style; + expect(style.width).toBe(''); + expect(style.left).toBe(''); + expect(style.transform).toBe(''); + } + f.up(); }); - it('claims a sideways drag, writes the travel on the root, and tells the browser the row owns the drag', () => { + it("claims the touch at 8 px, before a browser would scroll it, and moves the row only past UIKit's 25 px, from 0", () => { const {row} = renderRow(); - const touchMove = (x: number, y = 10) => - fireEvent.touchMove(row, { - cancelable: true, - touches: [{clientX: x, clientY: y}], - }); - touch(row, 'pointerDown', 10); - // Before the axis is decided the scroller may still take the gesture: - // the default stands (fireEvent answers false once it is prevented). - expect(touchMove(14)).toBe(true); - touch(row, 'pointerMove', 14); - expect(touchMove(14)).toBe(true); - expect(travelOf(row)).toBe(''); - // Past the lock and more horizontal than vertical: the row's. Every - // further touchmove is cancelled, which is what keeps iOS Safari's pan - // from taking a drag that is not perfectly level. - touch(row, 'pointerMove', 22, 18); - expect(touchMove(22, 18)).toBe(false); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe('12px'); - expect(row.style.getPropertyValue('--_item-swipe-dir')).toBe('1'); - expect(row.style.getPropertyValue('--_item-swipe-duration')).toBe('0s'); - touch(row, 'pointerMove', 50, 20); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe('40px'); - touch(row, 'pointerUp', 50, 20); - settled(); + const f = finger(row); + f.down(10); + // Short of the claim the scroller may still take the touch: the + // default stands. + expect(f.move(15).defaultPrevented).toBe(false); + // 10 px, mostly sideways: the row's. Every further touchmove is + // cancelled, which is what keeps a browser (iOS Safari commits at about + // 10 px) from scrolling a drag that is not perfectly level. + expect(f.move(20, 12).defaultPrevented).toBe(true); + // The swipe itself has not begun: the row has not moved. + expect(f.move(34, 13).defaultPrevented).toBe(true); + expect(row.style.getPropertyValue('translate')).toBe(''); + // 25 px: the swipe begins, at 0 (the threshold's travel is not part of + // it, as in UIKit). + expect(f.move(35, 14).defaultPrevented).toBe(true); + expect(xOf(row)).toBe(0); + expect(f.move(55, 16).defaultPrevented).toBe(true); + expect(xOf(row)).toBe(20); + // The row owned the touch, so the lift is cancelled too: no click is + // synthesized from it. + expect(f.up().defaultPrevented).toBe(true); + }); + + it('counts the swipe from where the finger crossed the threshold, so coming back past the start moves nothing', () => { + const {row} = renderRow(); + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(60); + expect(xOf(row)).toBe(25); + // Back to 10 px short of the touch-down point: the row heads home, and + // stays there rather than counting the threshold the other way. + f.move(20); + expect(xOf(row)).toBe(0); + f.move(4); + expect(xOf(row)).toBe(0); + f.up(); + }); + + describe('under a fine primary pointer (a touchscreen laptop, a mouse)', () => { + // The rest of the suite answers as a phone; here the primary pointer is + // fine. The gesture reads each touch's pointer (`spec:AST-057`): a finger + // swipes the row whatever the primary pointer, and a mouse never does. + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it('swipes under a finger, claiming the touch and resting open as on a phone', async () => { + // A fine primary pointer in this context, before anything is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {row} = renderRow(); + expect(panelOf(row, 'leading')).toHaveAttribute('inert'); + const f = finger(row); + f.down(10); + // Claimed at 8 px, before a browser would scroll it. + expect(f.move(20).defaultPrevented).toBe(true); + f.move(35); + f.move(80); + f.rest(); + f.up(); + await settled(); + expect(xOf(row)).toBe(72); + expect(panelOf(row, 'leading')).not.toHaveAttribute('inert'); + }); + + it('keeps every other recognizer on the gesture host inactive while a finger swipes the row (spec:AST-094 FR19; the row swipe reads each event, spec:AST-057)', async () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const taps: string[] = []; + function Other() { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new TapGestureRecognizer({name: 'test-other-tap'}), + tap => { + taps.push(tap.state); + }, + ); + return
; + } + // Attached where the primary pointer is fine without reading each + // event's pointer, the other recognizer warns once in development (its + // misuse is this case's point); the row warns nothing. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const {row} = renderRow(); + render(); + const t = finger(screen.getByTestId('other'), 3); + t.down(20); + t.up(); + // Not opted out of the gate: inactive where the primary pointer is fine. + expect(taps).not.toContain('ended'); + // The row swipes under a finger beside it. + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('attached where the primary pointer is fine'), + ); + warn.mockRestore(); + // The control: on a phone's pointer the same tap is recognized. + const coarse = pointerQuery(true); + try { + cleanup(); + taps.length = 0; + render(); + const u = finger(screen.getByTestId('other'), 4); + u.down(20); + u.up(); + expect(taps).toContain('ended'); + } finally { + coarse.restore(); + } + }); + + it('never swipes under a mouse', async () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {row} = renderRow(); + const mouse = (type: string, x: number) => + act(() => { + row.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: 10, + pointerId: 1, + pointerType: 'mouse', + }), + ); + }); + mouse('pointerdown', 10); + mouse('pointermove', 60); + mouse('pointermove', 120); + mouse('pointerup', 120); + await settled(); + expect(xOf(row)).toBe(0); + expect(panelOf(row, 'leading')).toHaveAttribute('inert'); + }); + }); + + it('leaves the row as it was when a claimed touch lifts before the swipe begins', async () => { + const {row} = renderRow(); + const f = finger(row); + f.down(10); + f.move(30); + expect(f.up().defaultPrevented).toBe(true); + expect(row.style.getPropertyValue('translate')).toBe(''); + expect(panelOf(row, 'leading')).toHaveAttribute('inert'); + // An open row stays open, its panel live. + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); + const g = finger(row); + g.down(150); + g.move(135); + g.up(); + await settled(); + expect(xOf(row)).toBe(72); + expect(panelOf(row, 'leading')).not.toHaveAttribute('inert'); + }); + + it('keeps the rest a settle lands while a claimed touch holds the row, when that touch lifts before the swipe begins', async () => { + const {row} = renderRow(); + // Released past half the panel: the row settles open. + dragLeading(row, 45); + // While it settles, a finger claims it and holds short of the swipe. + const f = finger(row); + f.down(150); + f.move(160); + // The settle lands under the held finger. + await settled(); + expect(xOf(row)).toBe(72); + f.up(); + await settled(); + // The row rests open, its panel live, and it still closes. + expect(xOf(row)).toBe(72); + expect(panelOf(row, 'leading')).not.toHaveAttribute('inert'); + const button = screen.getByRole('button', {name: 'Unread'}); + button.focus(); + fireEvent.keyDown(button, {key: 'Escape'}); + await settled(); + expect(xOf(row)).toBe(0); + }); + + it('springs back when the browser cancels a swipe in progress', async () => { + const onActivate = vi.fn(); + const {row} = renderRow({leading: [{label: 'Unread', onActivate}]}); + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(95); + expect(xOf(row)).toBe(60); + f.cancel(); + expect(xOf(row)).toBe(0); + await settled(); + expect(xOf(row)).toBe(0); + expect(panelOf(row, 'leading')).toHaveAttribute('inert'); + expect(onActivate).not.toHaveBeenCalled(); }); it('leaves a mostly vertical drag to the scroller and never cancels its touchmove', () => { const {row} = renderRow(); - touch(row, 'pointerDown', 10, 10); - touch(row, 'pointerMove', 14, 40); - expect( - fireEvent.touchMove(row, { - cancelable: true, - touches: [{clientX: 14, clientY: 40}], - }), - ).toBe(true); - touch(row, 'pointerMove', 120, 200); - expect(travelOf(row)).toBe(''); - touch(row, 'pointerUp', 120, 200); + const f = finger(row); + f.down(10, 10); + expect(f.move(14, 40).defaultPrevented).toBe(false); + expect(f.move(60, 200).defaultPrevented).toBe(false); + expect(row.style.getPropertyValue('translate')).toBe(''); + expect(f.up().defaultPrevented).toBe(false); }); it('ignores a mouse', () => { const {row} = renderRow(); - fireEvent.pointerDown(row, { - clientX: 10, - clientY: 10, - pointerId: 1, - pointerType: 'mouse', - }); - fireEvent.pointerMove(row, { - clientX: 60, - clientY: 10, - pointerId: 1, - pointerType: 'mouse', - }); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - fireEvent.pointerUp(row, { - clientX: 60, - clientY: 10, - pointerId: 1, - pointerType: 'mouse', + const mouse = (type: string, x: number) => + act(() => { + row.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: 10, + pointerId: 1, + pointerType: 'mouse', + }), + ); + }); + mouse('pointerdown', 10); + mouse('pointermove', 60); + mouse('pointermove', 120); + expect(row.style.getPropertyValue('translate')).toBe(''); + mouse('pointerup', 120); + }); + + it('does not move toward a side without entries', () => { + const {row} = renderRow({ + leading: [{label: 'Unread', onActivate: vi.fn()}], }); + const f = finger(row); + f.down(200); + f.move(180); + expect(f.move(150).defaultPrevented).toBe(false); + expect(row.style.getPropertyValue('translate')).toBe(''); + f.up(); }); describe('a pinch', () => { - /** A touchmove listing both fingers; true when the row cancelled it. */ - const pinchMoveCancelled = (row: Element, x: number) => - !fireEvent.touchMove(row, { - cancelable: true, - touches: [ - {clientX: x, clientY: 10}, - {clientX: 300 - x, clientY: 10}, - ], - }); - - it('lets the touch go when a second finger lands on the row before the axis is decided, and cancels nothing', () => { + it('lets the touch go when a second finger lands before the swipe begins', () => { const {row} = renderRow(); - touch(row, 'pointerDown', 100); - secondFinger(row, 'pointerDown', 60); + const first = finger(row, 1); + const second = finger(row, 2); + first.down(100); + second.down(60); // The fingers spread apart; the first one travels toward the leading - // side far past the axis lock. - touch(row, 'pointerMove', 130); - secondFinger(row, 'pointerMove', 30); - touch(row, 'pointerMove', 160); - expect(pinchMoveCancelled(row, 160)).toBe(false); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - // The browser takes the pinch. - touch(row, 'pointerCancel', 160); - secondFinger(row, 'pointerCancel', 0); - settled(); - expect(travelOf(row)).toBe(''); + // side far past the threshold. + first.move(130); + second.move(30); + expect(first.move(160).defaultPrevented).toBe(false); + expect(row.style.getPropertyValue('translate')).toBe(''); + first.up(); + second.up(); }); - it('lets the touch go when the second finger lands off the row', () => { + it('lets the touch go when the second finger lands off the row and the browser zooms', () => { const {row} = renderRow(); - touch(row, 'pointerDown', 100); - secondFinger(document.body, 'pointerDown', 60, 200); - touch(row, 'pointerMove', 130); - touch(row, 'pointerMove', 160); - expect(pinchMoveCancelled(row, 160)).toBe(false); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - touch(row, 'pointerCancel', 160); + const first = finger(row, 1); + const second = finger(document.body, 2); + first.down(100); + second.down(60, 200); + // The browser starts its pinch: it cancels the fingers' pointers, as + // Chromium does, and from here on no longer asks the page whether it + // may (the moves arrive uncancelable). + act(() => { + const cancel = new PointerEvent('pointercancel', { + bubbles: true, + clientX: 100, + clientY: 10, + pointerId: 99, + pointerType: 'touch', + }); + Object.defineProperty(cancel, 'timeStamp', {value: performance.now()}); + row.dispatchEvent(cancel); + }); + first.move(110, 10, {cancelable: false}); + first.move(130, 10, {cancelable: false}); + first.move(160, 10, {cancelable: false}); + expect(row.style.getPropertyValue('translate')).toBe(''); + first.up(); + second.up(); }); - it('never starts a drag from a finger that is not the first one down', () => { + it('never starts a swipe from a second finger whose first finger is off the row: the pinch stays the page’s (spec:AST-094 FR10)', () => { const {row} = renderRow(); - // The first finger is somewhere else on the page; this one lands on - // the row and travels sideways. - secondFinger(row, 'pointerDown', 100); - secondFinger(row, 'pointerMove', 130); - secondFinger(row, 'pointerMove', 160); - expect(pinchMoveCancelled(row, 160)).toBe(false); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - secondFinger(row, 'pointerUp', 160); + // The first finger is somewhere else on the page; the second lands on + // the row and travels sideways, past the claim and the swipe. + const first = finger(document.body, 1); + const second = finger(row, 2); + first.down(60, 200); + second.down(100); + second.move(110); + second.move(130); + expect(second.move(160).defaultPrevented).toBe(false); + expect(xOf(row)).toBe(0); + second.up(); + first.up(); }); - it('keeps a drag the row already owns when a second finger lands', () => { + it('keeps a begun swipe when a second finger lands: the row stays on its finger, which it keeps from the browser (spec:AST-094 FR10)', async () => { const {row} = renderRow(); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - touch(row, 'pointerMove', 45); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe('35px'); - secondFinger(row, 'pointerDown', 200); - touch(row, 'pointerMove', 60); - expect(pinchMoveCancelled(row, 60)).toBe(true); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe('50px'); - touch(row, 'pointerUp', 60); - secondFinger(row, 'pointerUp', 200); - settled(); - expect(travelOf(row)).toBe('72px'); - }); - - it('stops listening to the document once the axis is decided or the touch ends', () => { - const add = vi.spyOn(document, 'addEventListener'); - const remove = vi.spyOn(document, 'removeEventListener'); - try { - const {row} = renderRow(); - const watching = () => - add.mock.calls.filter(([type]) => type === 'pointerdown').length - - remove.mock.calls.filter(([type]) => type === 'pointerdown').length; - const before = watching(); - touch(row, 'pointerDown', 10); - expect(watching()).toBe(before + 1); - touch(row, 'pointerMove', 40); - expect(watching()).toBe(before); - touch(row, 'pointerUp', 40); - settled(); - touch(row, 'pointerDown', 10); - expect(watching()).toBe(before + 1); - touch(row, 'pointerUp', 12); - expect(watching()).toBe(before); - } finally { - add.mockRestore(); - remove.mockRestore(); - } + const first = finger(row, 1); + const second = finger(row, 2); + first.down(10); + first.move(20); + first.move(35); + first.move(55); + expect(xOf(row)).toBe(20); + second.down(200); + // A second finger neither cancels nor redirects a claimed drag: the + // row's finger is still the row's, its moves still cancelled. + expect(first.move(75).defaultPrevented).toBe(true); + expect(xOf(row)).toBe(40); + second.up(); + first.rest(); + first.up(); + await settled(); + // Released held at 40 px of a 72 px panel: past half, it rests open. + expect(xOf(row)).toBe(72); }); }); - it('does not move toward a side without entries', () => { - const {row} = renderRow({ - leading: [{label: 'Unread', onActivate: vi.fn()}], - }); - touch(row, 'pointerDown', 200); - touch(row, 'pointerMove', 150); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - touch(row, 'pointerUp', 150); - }); - describe('reveal (the default)', () => { - it('springs back when released short of half the panel, on the settle clock', () => { + it('springs back when released short of half the panel', async () => { const {row} = renderRow(); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - touch(row, 'pointerMove', 40); - touch(row, 'pointerUp', 40); - // 30 px of a 72 px panel: short. The spring back runs on the clock the - // transforms read, then every inline property is gone. - expect(row.style.getPropertyValue('--_item-swipe-duration')).toBe( - '200ms', - ); - expect(travelOf(row)).toBe('0px'); - settled(); - expect(travelOf(row)).toBe(''); - expect(row.style.getPropertyValue('--_item-swipe-dir')).toBe(''); + dragLeading(row, 30); + // 30 px of a 72 px panel, released at rest: short. + expect(xOf(row)).toBe(0); + await settled(); + expect(xOf(row)).toBe(0); expect(panelOf(row, 'leading')).toHaveAttribute('inert'); }); - it('rests open past half the panel, with every entry a real button, and fires a tapped entry', () => { + it('rests open past half the panel, with every entry a real button, and fires a tapped entry', async () => { const onActivate = vi.fn(); const onClick = vi.fn(); const {row} = renderRow({ leading: [{label: 'Unread', onActivate}], onClick, }); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - touch(row, 'pointerMove', 45); - touch(row, 'pointerMove', 60); - touch(row, 'pointerUp', 60); - settled(); + dragLeading(row, 45); + await settled(); // Resting at the panel's width; the panel is live. - expect(travelOf(row)).toBe('72px'); + expect(xOf(row)).toBe(72); const panel = panelOf(row, 'leading') as HTMLElement; expect(panel).not.toHaveAttribute('inert'); const button = screen.getByRole('button', {name: 'Unread'}); @@ -1128,68 +1403,165 @@ describe('swipeActions', () => { // A tap fires it at once; without `hasRemoval` the row closes. fireEvent.click(button); expect(onActivate).toHaveBeenCalledTimes(1); - settled(); - expect(travelOf(row)).toBe(''); + await settled(); + expect(xOf(row)).toBe(0); expect(panel).toHaveAttribute('inert'); // The row's own primary never fired. expect(onClick).not.toHaveBeenCalled(); }); - it('holds the row out after a tapped entry with hasRemoval', () => { + it('opens on a flick from short of half the panel, and fires nothing', async () => { + const onActivate = vi.fn(); + const {row} = renderRow({leading: [{label: 'Unread', onActivate}]}); + const f = finger(row); + f.down(10); + f.move(25); + f.move(40); + // 12 px of swipe, still moving at about 850 px/s when it lifts: the + // release projects far past half the panel. + f.move(52); + f.up({afterMs: 0}); + await settled(); + expect(xOf(row)).toBe(72); + expect(onActivate).not.toHaveBeenCalled(); + }); + + it('closes an open row on a flick back, even from past half the panel', async () => { + const {row} = renderRow(); + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); + const f = finger(row); + f.down(150); + f.move(140); + f.move(125); // 25 px: the swipe begins where the row is + f.move(110); + f.up({afterMs: 0}); + await settled(); + expect(xOf(row)).toBe(0); + }); + + it('catches a settling row where it is, without a jump', async () => { + const {row} = renderRow(); + const f = finger(row); + f.down(10); + f.move(25); + f.move(40); + f.move(55); + f.up({afterMs: 0}); + // A flick: the row is on its way to 72 px. A finger lands 100 ms in. + void act(() => vi.advanceTimersByTime(100)); + const g = finger(row); + g.down(150); + g.move(160); + g.move(175); + // The swipe began: the row heads for where it was when the finger + // caught it, neither back to 15 nor on to 72. + const caught = xOf(row); + expect(caught).toBeGreaterThan(15); + expect(caught).toBeLessThan(72); + g.move(185); + expect(xOf(row)).toBeCloseTo(caught + 10, 5); + g.rest(); + g.up(); + await settled(); + expect(xOf(row)).toBe(72); + }); + + it('holds the row out after a tapped entry with hasRemoval', async () => { const onActivate = vi.fn(); const {row} = renderRow({ trailing: [{label: 'Archive', onActivate, hasRemoval: true}], leading: [], }); - touch(row, 'pointerDown', 200); - touch(row, 'pointerMove', 180); - touch(row, 'pointerMove', 160); - touch(row, 'pointerMove', 140); - touch(row, 'pointerUp', 140); - settled(); - expect(travelOf(row)).toBe('-72px'); + const f = finger(row); + f.down(200); + f.move(185); + f.move(170); + f.move(150); + f.move(130); + f.rest(); + f.up(); + await settled(); + expect(xOf(row)).toBe(-72); fireEvent.click(screen.getByRole('button', {name: 'Archive'})); expect(onActivate).toHaveBeenCalledTimes(1); - settled(); + await settled(); // Out past the edge it travelled toward, and staying there: the // caller removes the row, or it sits off-screen (their bug). - expect(travelOf(row)).toBe('-324px'); - settled(); - expect(travelOf(row)).toBe('-324px'); + expect(xOf(row)).toBe(-324); + await settled(); + expect(xOf(row)).toBe(-324); + // A row that is out takes no swipe. + const g = finger(row); + g.down(100); + g.move(130); + expect(g.move(160).defaultPrevented).toBe(false); + g.up(); }); - it('closes a resting row on a pointer outside it, and on Escape with focus inside', () => { + it('keeps the other side’s panel inert while the row rests open: only the open side’s entries are in the tab order and the accessibility tree', async () => { + const {row} = renderRow({ + leading: [{label: 'Unread', onActivate: vi.fn()}], + trailing: [ + {label: 'Delete', onActivate: vi.fn(), variant: 'destructive'}, + ], + }); + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); + expect(panelOf(row, 'leading')).not.toHaveAttribute('inert'); + // The trailing panel is out of sight behind the row: nothing in it + // takes focus or reaches assistive technology. + expect(panelOf(row, 'trailing')).toHaveAttribute('inert'); + }); + + it('cross-fades a release under Reduce Motion instead of springing: it lands when the fade is over', async () => { + // design:reduced-motion DR2 governs where UIKit keeps its spring: the + // finger's own drag is direct manipulation, what its release starts + // cross-fades. + reduceMotion(); const {row} = renderRow(); - const open = () => { - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - touch(row, 'pointerMove', 45); - touch(row, 'pointerMove', 60); - touch(row, 'pointerUp', 60); - settled(); - expect(travelOf(row)).toBe('72px'); + dragLeading(row, 45); + await act(async () => { + vi.advanceTimersByTime(16); + }); + expect(panelOf(row, 'leading')).toHaveAttribute('inert'); + await act(async () => { + vi.advanceTimersByTime(534); + }); + expect(xOf(row)).toBe(72); + expect(panelOf(row, 'leading')).not.toHaveAttribute('inert'); + }); + + it('closes a resting row on a pointer outside it, and on Escape with focus inside', async () => { + const {row} = renderRow(); + const open = async () => { + dragLeading(row, 45); + await settled(); + expect(xOf(row)).toBe(72); }; - open(); + await open(); // A drag on a neighbour is a pointer outside this row. fireEvent.pointerDown(document.body, { pointerId: 9, pointerType: 'touch', }); - settled(); - expect(travelOf(row)).toBe(''); - open(); + await settled(); + expect(xOf(row)).toBe(0); + await open(); const button = screen.getByRole('button', {name: 'Unread'}); button.focus(); fireEvent.keyDown(button, {key: 'Escape'}); - settled(); - expect(travelOf(row)).toBe(''); + await settled(); + expect(xOf(row)).toBe(0); // Focus lands on the row's own content, not on a now-inert button. expect(document.activeElement).toBe( screen.getByRole('button', {name: 'Conversation'}), ); }); - it('fires the outermost entry and no other on a drag past the commit point, then springs back', () => { + it('fires the outermost entry and no other on a drag past the commit point, then springs back', async () => { const inner = vi.fn(); const outer = vi.fn(); const {row} = renderRow({ @@ -1198,33 +1570,288 @@ describe('swipeActions', () => { {label: 'Pin', onActivate: outer}, ], }); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 40); // Two entries: a 144 px panel; the commit point is 192 (panel + 48), // past half the 300 px row. - touch(row, 'pointerMove', 210); - touch(row, 'pointerUp', 210); + dragLeading(row, 200); expect(outer).not.toHaveBeenCalled(); // One beat: the slide out, then the entry fires. - expect(travelOf(row)).toBe('324px'); - settled(); + expect(xOf(row)).toBe(324); + await settled(); expect(outer).toHaveBeenCalledTimes(1); expect(inner).not.toHaveBeenCalled(); // No `hasRemoval`: back home. - settled(); - expect(travelOf(row)).toBe(''); + await settled(); + expect(xOf(row)).toBe(0); }); - it('commits on a fling short of the commit point', () => { - const onActivate = vi.fn(); - const {row} = renderRow({leading: [{label: 'Unread', onActivate}]}); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - // 50 px in the 50 ms step: well past the fling speed. - touch(row, 'pointerMove', 80); - touch(row, 'pointerUp', 80); - settled(); - expect(onActivate).toHaveBeenCalledTimes(1); + it("fires the outermost entry once the row's content has left its box, long before the slide-out comes to rest", async () => { + const outer = vi.fn(); + const {row} = renderRow({ + leading: [ + {label: 'Unread', onActivate: vi.fn()}, + {label: 'Pin', onActivate: outer}, + ], + }); + // Released held still just past the commit point (192), inside the + // 300 px row: nothing has fired yet. + dragLeading(row, 200); + expect(outer).not.toHaveBeenCalled(); + // UIKit calls the handler at the lift; the row fires once its content + // has left its box (300 px), about a third of a second into a + // slide-out whose tail toward 324 px lasts another 0.7 s. + await act(async () => { + vi.advanceTimersByTime(450); + }); + expect(outer).toHaveBeenCalledTimes(1); + }); + + it('starts every part of a release that continues the drag on one clock, so the window, track and entries stay in step with the row', async () => { + // A browser's animations: one from rest starts when a frame commits it; + // one that continues motion starts where its call pins it. + const created: { + target: Element; + startTime: number | null; + commit: (at: number) => void; + }[] = []; + HTMLElement.prototype.animate = function (this: HTMLElement) { + let resolveReady: () => void = () => {}; + const ready = new Promise(resolve => { + resolveReady = resolve; + }); + const record = { + target: this as Element, + startTime: null as number | null, + commit(at: number) { + if (record.startTime == null) { + record.startTime = at; + } + resolveReady(); + }, + }; + created.push(record); + return { + get startTime() { + return record.startTime; + }, + set startTime(value: number | null) { + record.startTime = value; + resolveReady(); + }, + get playState() { + return 'running'; + }, + ready, + cancel() {}, + } as unknown as Animation; + }; + try { + const {row} = renderRow(); + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(65); + // A frame starts the drag's first springs. + const at = performance.now(); + for (const animation of created) { + animation.commit(at); + } + await act(async () => {}); + f.move(80); + // Touch events are tasks of their own. + await act(async () => {}); + // The finger lifts while the row still moves, and each call takes + // time on a real page: the clock moves between calls. + let clock = performance.now(); + const now = vi + .spyOn(performance, 'now') + .mockImplementation(() => (clock += 0.4)); + const from = created.length; + try { + f.up(); + } finally { + now.mockRestore(); + } + const parts = [ + row, + panelOf(row, 'leading'), + trackOf(row, 'leading'), + ...entriesOf(row, 'leading'), + ]; + const starts = parts.map(part => { + const mine = created.slice(from).filter(a => a.target === part); + return mine[mine.length - 1]?.startTime; + }); + expect(typeof starts[0]).toBe('number'); + expect(new Set(starts).size).toBe(1); + } finally { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + } + }); + + it("fires a slide-out the browser starts late once the row's content has left its box, timed from that start", async () => { + // Like a browser on a busy page: an animation starts only when a frame + // commits it, and until then the row is where it was on screen. + const pending: {commit: (at: number) => void}[] = []; + HTMLElement.prototype.animate = function () { + let start: number | null = null; + let cancelled = false; + let resolveReady: () => void = () => {}; + let rejectReady: (error: Error) => void = () => {}; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const animation = { + get startTime() { + return start; + }, + set startTime(value: number | null) { + start = value; + resolveReady(); + }, + get playState() { + return cancelled ? 'idle' : 'running'; + }, + ready, + commit(at: number) { + if (!cancelled && start == null) { + start = at; + } + resolveReady(); + }, + cancel() { + cancelled = true; + rejectReady(new Error('AbortError')); + }, + }; + pending.push(animation); + return animation as unknown as Animation; + }; + try { + const outer = vi.fn(); + const {row} = renderRow({ + leading: [ + {label: 'Unread', onActivate: vi.fn()}, + {label: 'Pin', onActivate: outer}, + ], + }); + // Released held still past the commit point: the slide-out starts + // from rest, and no frame has started it yet. + dragLeading(row, 200); + await act(async () => { + vi.advanceTimersByTime(600); + }); + expect(outer).not.toHaveBeenCalled(); + // The browser starts it now; it fires once the row's content has left + // its box, a third of a second later. + const at = performance.now(); + for (const animation of pending) { + animation.commit(at); + } + await act(async () => { + vi.advanceTimersByTime(450); + }); + expect(outer).toHaveBeenCalledTimes(1); + } finally { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + } + }); + + it("drops a slide-out's pending fire when the row leaves, after a second slide-out replaced the first", async () => { + const outer = vi.fn(); + const {row, unmount} = renderRow({ + leading: [ + {label: 'Unread', onActivate: vi.fn()}, + {label: 'Pin', onActivate: outer}, + ], + }); + // When each slide-out's fire is due. + const due: number[] = []; + const schedule = window.setTimeout; + vi.spyOn(window, 'setTimeout').mockImplementation((( + callback: TimerHandler, + delay?: number, + ...rest: unknown[] + ) => { + if (typeof callback === 'function' && callback.name === 'fireIfStill') { + due.push(performance.now() + (delay ?? 0)); + } + return schedule(callback, delay, ...rest); + }) as typeof window.setTimeout); + dragLeading(row, 200); + await act(async () => {}); + // A finger catches the sliding row, pulls it back in, and flings it + // out past the commit point again before the first fire is due. + const f = finger(row, 2); + f.down(150); + f.move(140); + f.move(110); + f.move(80); + f.move(400); + f.up(); + // Each fire is timed from its slide-out's start, read when it resolves. + await act(async () => {}); + expect(due).toHaveLength(2); + expect(due[0]).toBeLessThan(due[1]); + // The first comes due (the catch cancelled it); then the row leaves + // while the second is pending. + await act(async () => { + vi.advanceTimersByTime(due[0] - performance.now() + 1); + }); + unmount(); + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(outer).not.toHaveBeenCalled(); + }); + + it("fires at once when the release is already past the row's edge", () => { + const outer = vi.fn(); + const {row} = renderRow({leading: [{label: 'Pin', onActivate: outer}]}); + // 72 px panel, commit point 150: 640 px of swipe resist to 321.5 px, + // past the 300 px row, and the row is there when the finger lifts. + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(35 + 320); + f.move(35 + 640); + f.rest(1200); + expect(xOf(row)).toBeGreaterThan(300); + f.up(); + expect(outer).toHaveBeenCalledTimes(1); + }); + + it("a disabled outermost entry's fill beside it takes the disabled entry's look", () => { + const {row} = renderRow({ + trailing: [ + {label: 'Archive', onActivate: vi.fn()}, + { + label: 'Delete', + onActivate: vi.fn(), + variant: 'destructive', + isDisabled: true, + }, + ], + }); + // The disabled entry is its colour at half strength, and so is the + // fill painted beside it (its own pseudo-element, which the entry's + // opacity covers): never the full colour of a verb that cannot fire. + const [, outer] = entriesOf(row, 'trailing'); + const rules = rulesDeclaredFor(outer); + expect(rules.some(r => r.includes('opacity: 0.5'))).toBe(true); + expect( + rules.some( + r => r.includes('::after') && r.includes('background-color: inherit'), + ), + ).toBe(true); + // The track paints only the surface the entries sit on. + expect( + rulesDeclaredFor(trackOf(row, 'trailing')).some(r => + r.includes('::before'), + ), + ).toBe(false); }); it('lays the entries outermost last: at the leading edge the row of entries runs in reverse', () => { @@ -1238,9 +1865,8 @@ describe('swipeActions', () => { {label: 'Delete', onActivate: vi.fn(), variant: 'destructive'}, ], }); - const leading = panelOf(row, 'leading')?.firstElementChild as HTMLElement; - const trailing = panelOf(row, 'trailing') - ?.firstElementChild as HTMLElement; + const leading = trackOf(row, 'leading'); + const trailing = trackOf(row, 'trailing'); expect( rulesDeclaredFor(leading).some(r => r.includes('flex-direction: row-reverse'), @@ -1251,19 +1877,27 @@ describe('swipeActions', () => { r.includes('flex-direction: row-reverse'), ), ).toBe(false); - // The panel wears its outermost entry's colour, so a slide-out fills - // the row with it. - const outerVariant = (panelOf(row, 'trailing') as HTMLElement).className; - const deleteVariant = (trailing.lastElementChild as HTMLElement) + // The outermost entry's colour is painted beside it out to the outer + // edge (a pseudo-element, no element), so a slide-out fills the row + // with it; the other entries carry none. + const outerFill = (entry: HTMLElement) => + rulesDeclaredFor(entry).some( + r => r.includes('::after') && r.includes('background-color: inherit'), + ); + expect(outerFill(entriesOf(row, 'trailing')[1])).toBe(true); + expect(outerFill(entriesOf(row, 'trailing')[0])).toBe(false); + expect(outerFill(entriesOf(row, 'leading')[1])).toBe(true); + const trackClasses = trackOf(row, 'trailing').className; + const deleteClasses = (trailing.lastElementChild as HTMLElement) .className; - expect(outerVariant.split(' ').some(c => deleteVariant.includes(c))).toBe( + expect(trackClasses.split(' ').some(c => deleteClasses.includes(c))).toBe( true, ); }); }); describe('commit', () => { - it('slides out and fires on a release past the commit point; nothing rests', () => { + it('slides out and fires on a release past the commit point; nothing rests', async () => { const onActivate = vi.fn(); const {row} = renderRow({ behavior: 'commit', @@ -1273,50 +1907,57 @@ describe('swipeActions', () => { // Presentational at all times: nothing in it is ever tappable. expect(panel).toHaveAttribute('aria-hidden', 'true'); expect(panel.querySelector('button')).toBeNull(); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 40); // One entry: 72 px panel; the commit point is half the 300 px row. - touch(row, 'pointerMove', 160); - touch(row, 'pointerUp', 160); - expect(travelOf(row)).toBe('324px'); - settled(); + dragLeading(row, 160); + expect(xOf(row)).toBe(324); + await settled(); expect(onActivate).toHaveBeenCalledTimes(1); - settled(); - expect(travelOf(row)).toBe(''); + await settled(); + expect(xOf(row)).toBe(0); expect(panel).toHaveAttribute('aria-hidden', 'true'); }); - it('springs back from any release short of the commit point, even past the panel', () => { + it('springs back from any release short of the commit point, even past the panel', async () => { + const onActivate = vi.fn(); + const {row} = renderRow({ + behavior: 'commit', + leading: [{label: 'Unread', onActivate}], + }); + dragLeading(row, 100); + await settled(); + expect(onActivate).not.toHaveBeenCalled(); + expect(xOf(row)).toBe(0); + }); + + it('never fires on a flick short of the commit point', async () => { const onActivate = vi.fn(); const {row} = renderRow({ behavior: 'commit', leading: [{label: 'Unread', onActivate}], }); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 40); - touch(row, 'pointerMove', 65); - touch(row, 'pointerMove', 90); - touch(row, 'pointerMove', 100); - touch(row, 'pointerUp', 100); - settled(); + const f = finger(row); + f.down(10); + f.move(25); + f.move(40); + f.move(60); + f.move(80); + f.up({afterMs: 0}); + await settled(); expect(onActivate).not.toHaveBeenCalled(); - expect(travelOf(row)).toBe(''); + expect(xOf(row)).toBe(0); }); - it('holds out after a committed entry with hasRemoval', () => { + it('holds out after a committed entry with hasRemoval', async () => { const onActivate = vi.fn(); const {row} = renderRow({ behavior: 'commit', leading: [{label: 'Archive', onActivate, hasRemoval: true}], }); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 40); - touch(row, 'pointerMove', 160); - touch(row, 'pointerUp', 160); - settled(); + dragLeading(row, 160); + await settled(); expect(onActivate).toHaveBeenCalledTimes(1); - settled(); - expect(travelOf(row)).toBe('324px'); + await settled(); + expect(xOf(row)).toBe(324); }); it('warns in development when a committing side has more than one entry', () => { @@ -1338,23 +1979,23 @@ describe('swipeActions', () => { }); }); - it('swallows the click the browser synthesizes after a drag, so the primary does not fire', () => { - const onClick = vi.fn(); - const {row} = renderRow({onClick}); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 30); - touch(row, 'pointerMove', 40); - touch(row, 'pointerUp', 40); - fireEvent.click(screen.getByRole('button', {name: 'Conversation'})); - expect(onClick).not.toHaveBeenCalled(); - settled(); - // A later tap is a tap. - vi.advanceTimersByTime(500); - fireEvent.click(screen.getByRole('button', {name: 'Conversation'})); - expect(onClick).toHaveBeenCalledTimes(1); + it('gives the root back its own translation when the swipe actions go', () => { + const {row} = renderRow(); + const f = finger(row); + f.down(10); + f.move(20); + f.move(35); + f.move(65); + expect(xOf(row)).toBe(30); + f.up(); + cleanup(); + render(); + expect(screen.getByTestId('row').style.getPropertyValue('translate')).toBe( + '', + ); }); - it('reads the direction logically under RTL: leading is toward the inline end, and the sign rides the root', () => { + it('reads the direction logically under RTL: leading is toward the inline end', async () => { // jsdom resolves `direction` only on the element that carries `dir`; a // browser inherits it down to the row, so resolve that inheritance by // hand for this test. @@ -1386,29 +2027,34 @@ describe('swipeActions', () => { const row = screen.getByTestId('row'); layOut(row); // Toward the inline end is leftward: the finger moves to smaller x. - touch(row, 'pointerDown', 200); - touch(row, 'pointerMove', 180); - touch(row, 'pointerMove', 160); - touch(row, 'pointerMove', 140); - void act(() => vi.advanceTimersByTime(20)); - expect(row.style.getPropertyValue('--_item-swipe-dir')).toBe('-1'); - // Physical travel is negative; the transforms multiply by the sign. - expect(travelOf(row)).toBe('-60px'); - touch(row, 'pointerUp', 140); - settled(); - expect(travelOf(row)).toBe('-72px'); + const f = finger(row); + f.down(200); + f.move(190); + f.move(175); + f.move(155); + f.move(135); + // Physical travel is negative, and the panel parts mirror it. + expect(xOf(row)).toBe(-40); + expect(xOf(panelOf(row, 'leading'))).toBe(40); + expect(xOf(entriesOf(row, 'leading')[0])).toBe(-72 + 40); + f.rest(); + f.up(); + await settled(); + expect(xOf(row)).toBe(-72); // Rightward from a closed row is toward the inline start: no trailing // entries, so it is not claimed. fireEvent.pointerDown(document.body, { pointerId: 9, pointerType: 'touch', }); - settled(); - touch(row, 'pointerDown', 10); - touch(row, 'pointerMove', 60); - void act(() => vi.advanceTimersByTime(20)); - expect(travelOf(row)).toBe(''); - touch(row, 'pointerUp', 60); + await settled(); + expect(xOf(row)).toBe(0); + const g = finger(row); + g.down(10); + g.move(40); + expect(g.move(70).defaultPrevented).toBe(false); + expect(xOf(row)).toBe(0); + g.up(); } finally { spy.mockRestore(); } diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 34b264d00124d..aa62fa540a4e2 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -8,13 +8,12 @@ * @output Exports Item component, ItemProps type; publishes the shared inline inset * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx * - * The swipe actions live in ItemSwipeLayer, reached only through a dynamic - * import when `swipeActions` is set: this module imports nothing of the - * gesture, so a row without swipe actions ships none of it. The root here - * still carries the swipe styles and forwards its events into the bridge the - * layer fills, so a swipeable row keeps the DOM and the event model it had - * when the gesture was imported here (Item.source-build.test.mjs pins the - * import graph). + * The swipe actions live in ItemSwipeLayer, imported with this module: a + * lazily loaded layer could miss a row's first swipe while its chunk was on + * the way. The root here still carries the swipe styles and forwards its key + * events into the bridge the layer fills; the layer attaches the gesture to + * the root itself, so a swipeable row keeps the DOM it had when the gesture + * was imported here (Item.source-build.test.mjs pins the import graph). * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Item/Item.doc.mjs @@ -25,14 +24,7 @@ * - /packages/cli/assets/templates/blocks/components/Item/ (showcase blocks) */ -import { - lazy, - Suspense, - useCallback, - useId, - useRef, - type ReactNode, -} from 'react'; +import {useCallback, useId, useRef, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import { colorVars, @@ -54,14 +46,7 @@ import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; -import type {ItemSwipeBridge} from './ItemSwipeLayer'; - -// The gesture and the panels, loaded only by a row that has swipe actions: a -// row without them never bundles useSwipeAction. While the chunk loads the -// row is already painted and at rest — the panels show nothing at rest, so -// nothing appears or moves when the layer mounts; the gesture simply attaches -// once the chunk is ready, as ProgressBar's and Timestamp's lazy affordances do. -const LazyItemSwipeLayer = lazy(async () => import('./ItemSwipeLayer')); +import ItemSwipeLayer, {type ItemSwipeBridge} from './ItemSwipeLayer'; // ============================================================================= // Types @@ -113,10 +98,12 @@ export interface ItemSwipeActions { } /** - * `reveal`: past half the panel the row rests open with every entry a real - * button; a long drag or a fling fires the outermost. `commit`: a release - * past the commit point slides the row out and fires the outermost entry; - * nothing rests, and the panel is presentational. + * `reveal`: every entry of a resting row is a real button, and a long drag + * fires the outermost. As in a UIKit list, a fast release goes the way it + * moves (a flick opens the row, a flick back closes it) and a slow one rests + * the row open past half the panel. `commit`: a + * release past the commit point slides the row out and fires the outermost + * entry; nothing rests, and the panel is presentational. */ export type ItemSwipeBehavior = 'reveal' | 'commit'; @@ -281,16 +268,20 @@ export interface ItemProps extends BaseProps { * radio). The element containing the rows clips in the inline axis * (`overflow-inline: clip`): `List` does; any other host does it once. * - * The gesture and its panels are loaded on demand, the first time a row has - * swipe actions, so a row without them ships none of it; a row with them is - * drawn at once and becomes swipeable as soon as that module has loaded. + * The swipe is a touch behaviour, the same on every device: the gesture + * reads each touch's pointer, so a finger swipes the row whatever the + * primary pointer (a touchscreen laptop's included) and a mouse never does. + * The gesture and its panels load with the row, so its first swipe never + * waits for a chunk. */ swipeActions?: ItemSwipeActions; /** * What a swipe does: `reveal` rests the row open with every entry tappable - * and fires the outermost on a long drag; `commit` slides the row out and - * fires the outermost on release. `commit` fits one entry per side. + * (a flick opens it and a flick back closes it, as in a UIKit list) and + * fires the outermost on a long drag; + * `commit` slides the row out and fires the outermost on a release past + * the commit point. `commit` fits one entry per side. * @default 'reveal' */ swipeBehavior?: ItemSwipeBehavior; @@ -326,11 +317,6 @@ const ARIA_SELECTED_ROLES = new Set([ */ const SWIPE_ROLES = new Set(['listitem']); -/** The travel a drag writes on the row root, in physical px (see useSwipeAction). */ -const SWIPE_TRAVEL = 'var(--_item-swipe-travel, 0px)'; -/** The settle clock, `0s` while the finger drives the row. */ -const SWIPE_DURATION = 'var(--_item-swipe-duration, 0s)'; - // ============================================================================= // Styles // ============================================================================= @@ -501,19 +487,15 @@ const styles = stylex.create({ display: 'flex', marginInlineStart: 'auto', }, - // Swipe actions. The root translates by the drag's travel and settles on - // the clock the gesture writes; `pan-y` lets the browser keep scrolling the - // list while the row owns horizontal movement, and `pinch-zoom` leaves a - // two-finger pinch that starts on the row to the browser, so the page stays - // zoomable (WCAG 1.4.4). The hover transition keeps its own clock beside - // the transform's. + // Swipe actions. The gesture moves the root with its `translate` property + // (compositor animations, see useSwipeAction), so the row's own `transform` + // stays free; `pan-y` lets the browser keep scrolling the list while the + // row owns horizontal movement, and `pinch-zoom` leaves a two-finger pinch + // that starts on the row to the browser, so the page stays zoomable + // (WCAG 1.4.4). The gesture writes the same value inline once it loads. swipeRoot: { isolation: 'isolate', touchAction: 'pan-y pinch-zoom', - transform: `translate3d(${SWIPE_TRAVEL}, 0, 0)`, - transitionProperty: 'background-color, transform', - transitionDuration: `${durationVars['--duration-fast-min']}, ${SWIPE_DURATION}`, - transitionTimingFunction: `${easeVars['--ease-standard']}, ease-out`, }, }); @@ -626,23 +608,26 @@ export function Item({ // a row that is the enlarged target of a nested control are not served. const leadingSwipe = swipeActions?.leading ?? []; const trailingSwipe = swipeActions?.trailing ?? []; - const hasSwipe = + const isSwipeServed = swipeActions != null && (leadingSwipe.length > 0 || trailingSwipe.length > 0) && !isDisabled && !isDelegate && !isLinkRoot && (role == null || SWIPE_ROLES.has(role)); + // The swipe is a touch behaviour, the same on every device: the gesture + // reads each touch's pointer (`spec:AST-057`), so a finger swipes whatever + // the primary pointer and a mouse never starts the drag. + const hasSwipe = isSwipeServed; useDevWarning( 'Item', '`swipeBehavior="commit"` fires only the outermost entry of a side; the ' + 'other entries have no touch path. Give a committing side one entry, ' + 'or use `swipeBehavior="reveal"`.', - hasSwipe && + isSwipeServed && swipeBehavior === 'commit' && (leadingSwipe.length > 1 || trailingSwipe.length > 1), ); - const swipeRootRef = useRef(null); // Filled by the swipe layer once it has mounted; empty until then and on a // row without swipe actions, when the forwarders below do nothing. const swipeBridgeRef = useRef(null); @@ -814,13 +799,10 @@ export function Item({ ); const mergedRef = useMergedRefs(ref, containerRef); - const rootRef = useMergedRefs( - (isDelegate ? mergedRef : ref) as React.Ref, - hasSwipe ? swipeRootRef : undefined, - ); + const rootRef = isDelegate ? mergedRef : ref; - // The row's own primary, which the click the browser synthesizes after a - // drag must not fire. + // The row's own primary. The click the browser synthesizes after a swipe + // never reaches it: the gesture system swallows it. const primaryOnClick = isDelegate ? delegatedOnClick : hasParentRole @@ -828,35 +810,15 @@ export function Item({ : isInteractive ? handleContainerClick : undefined; - const rootOnClick = - hasSwipe && primaryOnClick != null - ? (event: React.MouseEvent) => { - if (swipeBridgeRef.current?.shouldSuppressClick() === true) { - return; - } - primaryOnClick(event); - } - : primaryOnClick; - - // The gesture's handlers, forwarded from the root into the bridge the swipe - // layer fills: the root keeps the event model it had before the split — React's - // own pointer, click-capture and key events on the row's root — while the - // gesture itself lives in the lazily loaded layer. Until the layer has - // mounted the bridge is empty and each forwarder does nothing. + + // Escape on a resting row, forwarded from the root into the bridge the swipe + // layer fills: React's own key event on the row's root, so the row keeps the + // event model it had before the split. Until the layer has mounted the + // bridge is empty and the forwarder does nothing. const swipeHandlers = hasSwipe ? { - onPointerDown: (event: React.PointerEvent) => - swipeBridgeRef.current?.handlers.onPointerDown(event), - onPointerMove: (event: React.PointerEvent) => - swipeBridgeRef.current?.handlers.onPointerMove(event), - onPointerUp: (event: React.PointerEvent) => - swipeBridgeRef.current?.handlers.onPointerUp(event), - onPointerCancel: (event: React.PointerEvent) => - swipeBridgeRef.current?.handlers.onPointerCancel(event), - onClickCapture: (event: React.MouseEvent) => - swipeBridgeRef.current?.handlers.onClickCapture(event), onKeyDown: (event: React.KeyboardEvent) => - swipeBridgeRef.current?.handlers.onKeyDown(event), + swipeBridgeRef.current?.onKeyDown(event), } : undefined; @@ -897,24 +859,20 @@ export function Item({ style, )} role={role} - onClick={rootOnClick}> + onClick={primaryOnClick}> {innerContent} {hasSwipe && ( // The panels: out-of-flow children of this root, so the DOM is the one - // the row had before the split. Suspense and lazy add no element, and - // the fallback is nothing — at rest a panel shows nothing either. - - - + // the row had before the split; at rest a panel shows nothing. + )} ); diff --git a/packages/core/src/Item/ItemSwipeLayer.tsx b/packages/core/src/Item/ItemSwipeLayer.tsx index d778c43302b71..06c41ad492d3a 100644 --- a/packages/core/src/Item/ItemSwipeLayer.tsx +++ b/packages/core/src/Item/ItemSwipeLayer.tsx @@ -4,23 +4,31 @@ /** * @file ItemSwipeLayer.tsx - * @input The row root (by ref), the swipe entries per side, the behavior; - * useSwipeAction, theme tokens + * @input The swipe entries per side, the behavior; useSwipeAction, + * theme tokens * @output Exports ItemSwipeLayer (default) — the panels of a row's swipe * actions and the gesture that uncovers them — and the ItemSwipeBridge type - * through which the root reaches the gesture's handlers - * @position Internal to Item, reached only through a dynamic import from - * Item.tsx when `swipeActions` is set, so a row without them never loads - * the gesture. Tested through Item.test.tsx and Item.source-build.test.mjs. + * through which the root reaches the gesture's key handler + * @position Internal to Item, imported by Item.tsx with the row (no lazy + * chunk: a row's first swipe never waits for one). Tested through + * Item.test.tsx and Item.source-build.test.mjs. * * The row's root stays Item's: it carries the swipe root styles and forwards - * its pointer, click-capture and key events into the bridge this layer fills. - * This layer renders the two panels as out-of-flow children of that root and - * runs the gesture against it, so the DOM is the one Item rendered before the - * split — no element is added for the swipe (spec AST-057, FR2). + * its key events into the bridge this layer fills; the gesture attaches to + * the root itself. This layer renders the two panels as out-of-flow children + * of that root, so the DOM is the one Item rendered before the split — no + * element is added to the row for the swipe (spec AST-057, FR2). + * + * A panel is two boxes and the entries themselves, all moved by transforms + * only (see useSwipeAction): a window over the row's resting box that clips + * to it, a track inside it that moves with the row and clips the entries to + * the space the row vacates, and the entries, each translated to stay pinned + * at the outer edge. Past the open width the outermost entry's colour, + * painted beside it, fills the rest out to that edge. No wrapper holds the + * entries (minimal DOM, `architecture:component-style-authoring` INV7). */ -import {useEffect, useRef, type RefObject} from 'react'; +import {useEffect, useRef} from 'react'; import * as stylex from '@stylexjs/stylex'; import { fontWeightVars, @@ -32,12 +40,7 @@ import { import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; -import {useMediaQuery} from '../hooks/useMediaQuery'; -import { - useSwipeAction, - type SwipeHandlers, - type SwipeSide, -} from './useSwipeAction'; +import {useSwipeAction, type SwipeSide} from './useSwipeAction'; import type {ItemSwipeAction, ItemSwipeBehavior} from './Item'; // ============================================================================= @@ -45,18 +48,15 @@ import type {ItemSwipeAction, ItemSwipeBehavior} from './Item'; // ============================================================================= /** - * What the root needs from the gesture: the handlers it forwards its events - * into, and the test for the click the browser synthesizes after a drag. The - * layer writes it on every render; the root reads it on every event. + * What the root needs from the gesture: the key handler it forwards its key + * events into (Escape closes a resting row). The layer writes it on every + * render; the root reads it on every event. */ export interface ItemSwipeBridge { - handlers: SwipeHandlers; - shouldSuppressClick: () => boolean; + onKeyDown: (event: React.KeyboardEvent) => void; } export interface ItemSwipeLayerProps { - /** The row root: the gesture's target and the element that translates. */ - rootRef: RefObject; /** * Called with the gesture for the root to forward into, after every render * of the layer, and with `null` when the layer leaves. @@ -67,72 +67,51 @@ export interface ItemSwipeLayerProps { behavior: ItemSwipeBehavior; } -// ============================================================================= -// Constants -// ============================================================================= - -// The custom properties the gesture writes on the root (see useSwipeAction; -// StyleX wants literals here, so the names are spelled out rather than imported). -/** The travel a drag writes on the row root, in physical px. */ -const SWIPE_TRAVEL = 'var(--_item-swipe-travel, 0px)'; -/** +1 when the inline end is to the right, -1 under RTL; written beside the travel. */ -const SWIPE_DIR = 'var(--_item-swipe-dir, 1)'; -/** The settle clock, `0s` while the finger drives the row. */ -const SWIPE_DURATION = 'var(--_item-swipe-duration, 0s)'; -/** The travel measured toward the inline end. */ -const SWIPE_LOGICAL_TRAVEL = `calc(${SWIPE_TRAVEL} * ${SWIPE_DIR})`; - // ============================================================================= // Styles // ============================================================================= const styles = stylex.create({ - // A side's panel: an out-of-flow child of the root at the inline start or - // end, counter-translated so it appears fixed in the space the row - // vacates, and exactly as wide as the travel toward its side — a padded box - // at `width: 0` would still paint its padding, so the panel has none; its - // entries carry their own. Not a pixel of it shows at rest. + // A side's window: the row's resting box. It is translated back by the + // travel, so it stays where the row rests, and clips the track to that box + // and its corners. Transparent and not hit: only the entries take touches. swipePanel: { position: 'absolute', - top: 0, - bottom: 0, - width: 0, - display: 'flex', - alignItems: 'stretch', - boxSizing: 'border-box', - overflow: 'hidden', + insetBlock: 0, + insetInline: 0, + overflow: 'clip', borderRadius: 'inherit', - transform: `translate3d(calc(-1 * ${SWIPE_TRAVEL}), 0, 0)`, - transitionProperty: 'transform, width', - transitionDuration: `${SWIPE_DURATION}, ${SWIPE_DURATION}`, - transitionTimingFunction: 'ease-out, ease-out', - }, - swipePanelLeading: { - insetInlineStart: 0, - justifyContent: 'flex-start', - width: `max(0px, ${SWIPE_LOGICAL_TRAVEL})`, + pointerEvents: 'none', }, - swipePanelTrailing: { - insetInlineEnd: 0, - justifyContent: 'flex-end', - width: `max(0px, calc(-1 * ${SWIPE_LOGICAL_TRAVEL}))`, - }, - // The entries at their natural width; the panel clips them to the travel. - // The outermost entry is the last, at the panel's outer edge: on the leading - // side that is the inline start, so the row of entries runs in reverse. - swipeEntries: { + // The track: beside the window at rest (so not a pixel of it shows), moved + // with the row, so the part inside the window is exactly the space the row + // vacates, and the entries show only there. Twice the row's width covers + // every travel, the slide-out included. The entries run in a row from its + // edge next to the row, each translated back so they stay pinned at the + // row's outer edge, on the surface it paints: the neutral variant is + // translucent and shows what is under it. + swipeTrack: { + position: 'absolute', + insetBlock: 0, + width: '200%', + overflow: 'clip', + borderRadius: 'inherit', display: 'flex', - flexShrink: 0, - height: '100%', - // The entries sit on the surface, not on the panel's own colour: the - // neutral variant is translucent and would otherwise take the outermost - // entry's colour through it. backgroundColor: colorVars['--color-background-surface'], }, - swipeEntriesLeading: { + // The outermost entry is the last, at the outer edge: on the leading side + // that is the inline start, so the row of entries runs in reverse from the + // track's inline end. + swipeTrackLeading: { + insetInlineEnd: '100%', flexDirection: 'row-reverse', }, + swipeTrackTrailing: { + insetInlineStart: '100%', + }, swipeEntry: { + flexShrink: 0, + pointerEvents: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', @@ -159,10 +138,39 @@ const styles = stylex.create({ swipeEntryDisabled: { opacity: 0.5, }, + // The outermost entry: its colour, painted beside it out to the row's + // outer edge, fills whatever the row uncovers past the open width (the + // entry stretches), and a slide-out fills the row with it. Decoration + // only, on a pseudo-element; the entry's own fill, theme included. + swipeOuterEntry: { + position: 'relative', + }, + swipeOuterEntryLeading: { + '::after': { + content: '""', + position: 'absolute', + insetBlock: 0, + insetInlineEnd: '100%', + width: '100vw', + backgroundColor: 'inherit', + pointerEvents: 'none', + }, + }, + swipeOuterEntryTrailing: { + '::after': { + content: '""', + position: 'absolute', + insetBlock: 0, + insetInlineStart: '100%', + width: '100vw', + backgroundColor: 'inherit', + pointerEvents: 'none', + }, + }, }); -// A verb's colour: ordinary, the one you mean, the dangerous one. The panel -// wears its outermost entry's, so a slide-out fills the row with it. +// A verb's colour: ordinary, the one you mean, the dangerous one. The +// outermost entry's fills the row on a slide-out (swipeOuterEntry). const swipeVariantStyles = stylex.create({ neutral: { backgroundColor: colorVars['--color-neutral'], @@ -196,49 +204,41 @@ const swipeVariantStyles = stylex.create({ * the root Item owns, and the gesture that drives that root. */ export default function ItemSwipeLayer({ - rootRef, onBridge, leading, trailing, behavior, }: ItemSwipeLayerProps) { - const leadingEntriesRef = useRef(null); - const trailingEntriesRef = useRef(null); - const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); - const outermost = (side: SwipeSide): ItemSwipeAction | undefined => { - const entries = side === 'leading' ? leading : trailing; - return entries[entries.length - 1]; - }; + // The entries, read when an entry fires (after the render that showed them). + // Synchronizes the gesture system's fire callback, which runs outside React + // from the recognizer, with the committed entries. + const entriesRef = useRef({leading, trailing}); + useEffect(() => { + entriesRef.current = {leading, trailing}; + }); const swipe = useSwipeAction({ - isEnabled: true, behavior, - sides: { - leading: leading.length > 0, - trailing: trailing.length > 0, - }, - measurePanel: side => - (side === 'leading' ? leadingEntriesRef : trailingEntriesRef).current - ?.offsetWidth ?? 0, fireOutermost: side => { - const entry = outermost(side); + const entries = entriesRef.current[side]; + const entry = entries[entries.length - 1]; if (entry == null || entry.isDisabled === true) { return false; } void entry.onActivate(); return entry.hasRemoval === true; }, - isReducedMotion, - rootRef, + sides: { + leading: leading.length > 0, + trailing: trailing.length > 0, + }, }); - // The root forwards its events into what this publishes. Published from an - // effect, like the gesture's own latest-callback refs: React flushes - // effects before it delivers a discrete event, so a handler never meets a - // stale bridge; and withdrawn when the layer leaves. + // The root forwards its key events into what this publishes. The Effect + // installs this layer's key handler on Item's root, which + // lives outside the layer, and releases it when the layer leaves. React + // flushes effects before it delivers a discrete event, so a handler never + // meets a stale bridge. useEffect(() => { - onBridge({ - handlers: swipe.handlers, - shouldSuppressClick: swipe.shouldSuppressClick, - }); + onBridge({onKeyDown: swipe.onKeyDown}); return () => { onBridge(null); }; @@ -247,57 +247,64 @@ export default function ItemSwipeLayer({ // A side's panel: entries as real buttons under `reveal`, presentational // blocks under `commit`. Out of the accessibility tree and the tab order - // except while the row rests open, so the row keeps one tab stop and a - // pointer that can hover never meets it. + // except while the row rests open on its side, so the row keeps one tab + // stop, the other side's entries (out of sight behind the row) are never + // reached, and a pointer that can hover never meets it. const renderSwipePanel = (side: SwipeSide, entries: ItemSwipeAction[]) => { if (entries.length === 0) { return null; } const outer = entries[entries.length - 1]; const isCommit = behavior === 'commit'; - const isLive = isSwipeResting && !isCommit; + const isLive = isSwipeResting && swipe.state.side === side && !isCommit; + const refs = swipe.panelRefs[side]; return (
+ {...stylex.props(styles.swipePanel)}>
{entries.map(entry => { const variant = entry.variant ?? 'accent'; + const key = entry.id ?? entry.label; + const isOuter = entry === outer; const content = ( <> {entry.icon} {entry.label} ); - const entryStyle = stylex.props( + const entryStyles = [ styles.swipeEntry, swipeVariantStyles[variant], entry.isDisabled === true && styles.swipeEntryDisabled, - ); + isOuter && styles.swipeOuterEntry, + isOuter && + (side === 'leading' + ? styles.swipeOuterEntryLeading + : styles.swipeOuterEntryTrailing), + ]; if (isCommit) { return ( {content} @@ -305,7 +312,8 @@ export default function ItemSwipeLayer({ } return ( diff --git a/packages/core/src/Item/__tests__/ItemSwipeActions.a11y.chromium.spec.ts b/packages/core/src/Item/__tests__/ItemSwipeActions.a11y.chromium.spec.ts index c380161273f3b..09fb0a08ea910 100644 --- a/packages/core/src/Item/__tests__/ItemSwipeActions.a11y.chromium.spec.ts +++ b/packages/core/src/Item/__tests__/ItemSwipeActions.a11y.chromium.spec.ts @@ -6,17 +6,20 @@ * @input The checked-in Core/Item › Swipe Actions story, a built Storybook and * real Chromium touch input on a phone-sized viewport * @output Pixel and geometry proof of Item's swipe actions: no pixel of a - * panel at rest, the panel fixed in the space the row vacates while the row - * translates, none again when the drag comes back to its start without - * lifting, the row resting open with a real button that fires on a tap and - * closes the row, the panel shrinking with the row on the spring back, and a - * fling toward the trailing side firing the outermost entry + * panel at rest, the panel painting exactly the space the row vacates while + * the row translates, none again when the drag comes back to its start + * without lifting, the row resting open with a real button that fires on a + * tap and closes the row, the panel's edge moving with the row on the spring + * back, a fling opening the row without firing anything, and a drag past the + * commit point firing the outermost entry * @position Browser-only regression test for Item's swipeActions. jsdom can * hold the declared rules but not the paint or the geometry, and a real - * device has already disagreed with jsdom once on this gesture. + * device has already disagreed with jsdom once on this gesture. The swipe + * reads each touch's pointer, the same on every device; the context's + * touchscreen gives Chromium a phone's (a coarse primary pointer). */ -import {expect, test, type Locator} from '@playwright/test'; +import {expect, test, type Locator, type Page} from '@playwright/test'; // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned. import {PNG} from 'pngjs'; import { @@ -42,6 +45,14 @@ test.afterAll(async () => { // point within a finger's reach. test.use({hasTouch: true, viewport: {height: 800, width: 420}}); +/** Opens the story under a finger: the context's touchscreen makes the primary pointer coarse. */ +async function openUnderFinger(page: Page, url: string) { + await page.goto(url, {waitUntil: 'load'}); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); +} + type Rgb = [number, number, number]; /** The leading panel sits at the inline start: in this LTR story, the left edge. */ @@ -92,14 +103,17 @@ function paintedAtEdge(buffer: Buffer): number { return count; } -/** The row's horizontal translation, from its computed transform. */ +/** The row's horizontal translation, from its `translate` property. */ async function translationOf(row: Locator): Promise { return row.evaluate(node => { - const matrix = new DOMMatrix(getComputedStyle(node).transform); - return matrix.e; + const value = parseFloat(getComputedStyle(node).translate); + return Number.isNaN(value) ? 0 : value; }); } +/** The fill of a side's panel: the track inside the panel's window. */ +const trackOf = (panel: Locator) => panel.locator(':scope > div').first(); + interface Box { x: number; y: number; @@ -118,9 +132,9 @@ async function boxOf(locator: Locator): Promise { test('paints no panel at rest, keeps the panel fixed while the row moves, rests open on a real button, and springs back with the panel', async ({ page, }) => { - await page.goto( + await openUnderFinger( + page, `${storybook.origin}/iframe.html?id=core-item--swipe-actions&viewMode=story`, - {waitUntil: 'load'}, ); const row = page.locator('.astryx-item').first(); await expect(row).toBeVisible(); @@ -153,37 +167,53 @@ test('paints no panel at rest, keeps the panel fixed while the row moves, rests await page.waitForTimeout(40); } }; + // A finger coming to rest: UIKit's velocity filter never decays on its + // own (a finger at rest sends no events, and a browser drops a move that + // goes nowhere), so the finger creeps a tenth of a pixel a frame until the + // velocity it reports is nearly zero. + const holdStill = async (dx: number) => { + for (let i = 1; i <= 20; i += 1) { + await touch('touchMove', startX + dx + i / 10); + await page.waitForTimeout(16); + } + }; await touch('touchStart', startX); - // Past the axis lock and clearly horizontal, short of the commit point. - await dragTo([6, 14, 28, 44, 60]); + // Past the 25 px the swipe needs to begin and clearly horizontal, short of + // the commit point: the swipe is the travel past those 25 px. + await dragTo([6, 14, 28, 50, 70, 90]); - // Under the drag: the row has moved by the travel, the panel is fixed at - // the row's resting edge (counter-translated) and exactly as wide as the - // travel, and its colour fills the vacated edge. - await expect.poll(async () => translationOf(row)).toBeGreaterThan(50); + // Under the drag: the row has caught up with the finger (it follows on a + // spring), the panel's window is fixed over the row's resting box, its + // fill ends exactly at the row's moved edge, and its colour fills the + // vacated edge. + await expect.poll(async () => translationOf(row)).toBeGreaterThan(55); const dragged = await boxOf(panel); expect(Math.abs(dragged.x - rest.x)).toBeLessThanOrEqual(1); + expect(Math.abs(dragged.width - rest.width)).toBeLessThanOrEqual(1); + const fill = await boxOf(trackOf(panel)); expect( - Math.abs(dragged.width - (await translationOf(row))), + Math.abs(fill.x + fill.width - (rest.x + (await translationOf(row)))), ).toBeLessThanOrEqual(1); expect(paintedAtEdge(await shoot())).toBeGreaterThan(0); // Back to the start and past it without lifting: the row is home and the - // panel is a zero-width box again, so nothing of it paints. - await dragTo([40, 20, 4, -6]); + // fill is beside the window again, so nothing of it paints. + await dragTo([60, 30, 10, -6]); await expect.poll(async () => translationOf(row)).toBe(0); await expect.poll(async () => paintedAtEdge(await shoot())).toBe(0); - // Out again past half the panel, then released: the row rests open at the - // panel's width with its entry a real, focusable button. - await dragTo([10, 30, 45, 60]); + // Out again past half the panel, held still, then released: the row rests + // open at the panel's width with its entry a real, focusable button. + await dragTo([30, 60, 90, 110]); + await page.waitForTimeout(100); await touch('touchEnd'); await expect(panel).not.toHaveAttribute('inert'); const entry = panel.getByRole('button', {name: 'Read'}); await expect(entry).toBeVisible(); await expect.poll(async () => translationOf(row)).toBe(72); - expect(Math.abs((await boxOf(panel)).width - 72)).toBeLessThanOrEqual(1); + const open = await boxOf(trackOf(panel)); + expect(Math.abs(open.x + open.width - (rest.x + 72))).toBeLessThanOrEqual(1); await entry.focus(); await expect(entry).toBeFocused(); @@ -211,47 +241,49 @@ test('paints no panel at rest, keeps the panel fixed while the row moves, rests }); await expect.poll(async () => paintedAtEdge(await shoot())).toBe(0); - // The spring back: a short drag released before half the panel. The row - // slides home on its clock and the panel's width runs on the same one, so - // mid-flight the gap the row has not yet covered is still the panel. + // The spring back: a short drag held still and released before half the + // panel. The row and the panel's fill run on one clock, so mid-flight the + // gap the row has not yet covered is exactly the fill. await touch('touchStart', startX); - await dragTo([8, 16, 24, 30]); + await dragTo([10, 30, 40, 50]); await expect.poll(async () => translationOf(row)).toBeGreaterThan(20); + await holdStill(50); await touch('touchEnd'); const midFlight = await row.evaluate( async (node, selector) => - new Promise<{rowX: number; panelWidth: number}>(resolve => { + new Promise<{rowX: number; fillEnd: number}>(resolve => { setTimeout(() => { - const panelEl = node.querySelector(selector) as HTMLElement; - const matrix = new DOMMatrix(getComputedStyle(node).transform); + const track = node.querySelector(selector) as HTMLElement; resolve({ - panelWidth: parseFloat(getComputedStyle(panelEl).width), - rowX: Math.abs(matrix.e), + fillEnd: + track.getBoundingClientRect().right - + (node.parentElement as HTMLElement).getBoundingClientRect().left, + rowX: + node.getBoundingClientRect().left - + (node.parentElement as HTMLElement).getBoundingClientRect().left, }); }, 60); }), - '[data-swipe-panel="leading"]', + '[data-swipe-panel="leading"] > div', ); expect(midFlight.rowX).toBeGreaterThan(2); - expect(Math.abs(midFlight.panelWidth - midFlight.rowX)).toBeLessThanOrEqual( - 6, - ); + expect(Math.abs(midFlight.fillEnd - midFlight.rowX)).toBeLessThanOrEqual(1); await expect.poll(async () => translationOf(row)).toBe(0); await expect.poll(async () => paintedAtEdge(await shoot())).toBe(0); }); -test('fires the outermost trailing entry on a fling and the row leaves', async ({ +test('opens the row on a fling without firing, and fires the outermost trailing entry past the commit point, and the row leaves', async ({ page, }) => { - await page.goto( + await openUnderFinger( + page, `${storybook.origin}/iframe.html?id=core-item--swipe-actions&viewMode=story`, - {waitUntil: 'load'}, ); const rows = page.locator('.astryx-item'); const row = rows.first(); await expect(row.getByText('Message 1')).toBeVisible(); - // The gesture arrives with the lazily loaded swipe layer; its panel is the - // sign the row can be dragged. + // The gesture arrives with the swipe layer; its panel is the sign the row + // can be dragged. await expect(row.locator('[data-swipe-panel="trailing"]')).toBeAttached(); const before = await rows.count(); const rest = await boxOf(row); @@ -263,20 +295,45 @@ test('fires the outermost trailing entry on a fling and the row leaves', async ( // Toward the inline start: the trailing panel, Delete outermost. A slow // start claims the drag; the last step is quick, so the release is a fling - // short of the commit point. + // short of the commit point: it opens the row, as UIKit's does, and fires + // nothing. await at(startX, 'touchStart'); - for (const dx of [6, 14, 28]) { + for (const dx of [6, 14, 28, 36]) { await at(startX - dx, 'touchMove'); await page.waitForTimeout(40); } - await at(startX - 44, 'touchMove'); + await at(startX - 60, 'touchMove'); await at(startX - 90, 'touchMove'); await cdp.send('Input.dispatchTouchEvent', { touchPoints: [], type: 'touchEnd', }); + const panel = row.locator('[data-swipe-panel="trailing"]'); + await expect(panel).not.toHaveAttribute('inert'); + // How far the row opens: the entries (the track's children) together. + const panelWidth = await trackOf(panel).evaluate(node => + Array.from(node.children).reduce( + (width, entry) => width + entry.getBoundingClientRect().width, + 0, + ), + ); + await expect.poll(async () => translationOf(row)).toBeCloseTo(-panelWidth, 0); + await expect(rows).toHaveCount(before); - // One beat: the slide out, then the entry fires; the story removes the row. + // From open, a drag on past the commit point fires the outermost entry: + // one beat, the slide out, then the entry fires and the story removes the + // row. + const from = rest.x + rest.width - panelWidth - 24; + await at(from, 'touchStart'); + for (const dx of [10, 30, 60, 120, 180, 240]) { + await at(from - dx, 'touchMove'); + await page.waitForTimeout(40); + } + await page.waitForTimeout(100); + await cdp.send('Input.dispatchTouchEvent', { + touchPoints: [], + type: 'touchEnd', + }); await expect(page.getByText('Message 1')).toHaveCount(0); await expect(rows).toHaveCount(before - 1); }); @@ -284,13 +341,13 @@ test('fires the outermost trailing entry on a fling and the row leaves', async ( test('the trailing panel: Delete is outermost and the panel wears its colour', async ({ page, }) => { - await page.goto( + await openUnderFinger( + page, `${storybook.origin}/iframe.html?id=core-item--swipe-actions&viewMode=story`, - {waitUntil: 'load'}, ); const row = page.locator('.astryx-item').first(); const panel = row.locator('[data-swipe-panel="trailing"]'); - // The gesture arrives with the lazily loaded swipe layer. + // The gesture arrives with the swipe layer. await expect(panel).toBeAttached(); const rest = await boxOf(row); const y = rest.y + rest.height / 2; @@ -300,7 +357,7 @@ test('the trailing panel: Delete is outermost and the panel wears its colour', a touchPoints: [{x: startX, y}], type: 'touchStart', }); - for (const dx of [6, 14, 28, 44, 60, 80]) { + for (const dx of [6, 14, 28, 44, 60, 80, 100]) { await cdp.send('Input.dispatchTouchEvent', { touchPoints: [{x: startX - dx, y}], type: 'touchMove', @@ -308,24 +365,34 @@ test('the trailing panel: Delete is outermost and the panel wears its colour', a await page.waitForTimeout(40); } await expect.poll(async () => translationOf(row)).toBeLessThan(-60); - // Fixed at the row's resting trailing edge, as wide as the travel. + // The window is fixed over the row's resting box; the fill starts exactly + // at the row's moved trailing edge. const dragged = await boxOf(panel); expect( Math.abs(dragged.x + dragged.width - (rest.x + rest.width)), ).toBeLessThanOrEqual(1); - // The entries run Archive then Delete toward the outer edge; the panel's - // own background is Delete's. - const [panelColor, deleteColor, archiveColor] = await panel.evaluate(node => { + const fill = await boxOf(trackOf(panel)); + expect( + Math.abs(fill.x - (rest.x + rest.width + (await translationOf(row)))), + ).toBeLessThanOrEqual(1); + // The entries run Archive then Delete toward the outer edge; the fill out + // to the edge is Delete's colour, painted beside it (its pseudo-element), + // and the track paints the surface the entries sit on. + const [trackColor, fillColor, deleteColor, archiveColor] = await trackOf( + panel, + ).evaluate(node => { const buttons = Array.from(node.querySelectorAll('button')); const last = buttons[buttons.length - 1]; const first = buttons[0]; return [ getComputedStyle(node).backgroundColor, + getComputedStyle(last, '::after').backgroundColor, getComputedStyle(last).backgroundColor, getComputedStyle(first).backgroundColor, ]; }); - expect(panelColor).toBe(deleteColor); + expect(trackColor).not.toBe(deleteColor); + expect(fillColor).toBe(deleteColor); expect(archiveColor).not.toBe(deleteColor); const deleteBox = await boxOf(panel.getByRole('button', {name: 'Delete'})); const archiveBox = await boxOf(panel.getByRole('button', {name: 'Archive'})); diff --git a/packages/core/src/Item/__tests__/ItemSwipeActions.interaction.browser.spec.ts b/packages/core/src/Item/__tests__/ItemSwipeActions.interaction.browser.spec.ts new file mode 100644 index 0000000000000..d80c2518f8c85 --- /dev/null +++ b/packages/core/src/Item/__tests__/ItemSwipeActions.interaction.browser.spec.ts @@ -0,0 +1,1018 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ItemSwipeActions.interaction.browser.spec.ts + * @input The built "Swipe Actions" Item story, real touch input (Chromium + * CDP / WebKit's input command), the interaction harness and the UIKit + * constants table (motion/uikit/swipeActions.ts) + * @output Interaction and animation proofs for a row's swipe actions: the row + * trails the finger on UIKit's tracking spring and catches up when it + * stops; the settle after a held release is UIKit's spring sampled frame by + * frame, with the panel's window, track and entries in step with the row + * at every sample; a flick hands its presentation velocity to the settle; + * a finger that lands on a settling row catches it without a jump; the + * settle keeps advancing through a blocked main thread; the drag and the + * settle do no Layout or Paint per frame; a storyboard of the drag and the + * settle. Under Reduce Motion a release lands with no travel at the + * bottom of an opacity dip (design:reduced-motion DR2). + * @position Interaction proofs of Item's swipeActions (the shape of + * BottomSheetDetent.interaction.browser.spec.ts, the harness exemplar). The + * row moves by the individual `translate` property (the compositor + * animator), so curves are read from computed `translate`, not `transform`. + * The swipe reads each touch's pointer, the same on every device; the + * project's touchscreen gives Chromium and WebKit a phone's (a coarse + * primary pointer). + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + busyWindowProof, + castStoryboard, + compareCurve, + countBetween, + drag, + flick, + freezeTimers, + holdAnimations, + installLongTaskLog, + installTimerGate, + markElement, + openStory, + readLongTasks, + releaseAnimations, + sampleRects, + seekAnimations, + springStep, + startScreencast, + thawTimers, + timeline, + touchDriver, + toTouchEvents, + touchStoryboard, + traceRendering, + trackMarker, + withStorybook, + waitForAnimation, + waitForRest, + writeEvidence, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {UIKIT_VELOCITY} from '../../motion/uikit/gestures'; +import {swipeActions} from '../../motion/uikit/swipeActions'; + +const storybook = withStorybook(test); + +/** + * The tracking and settle spring, as Core Animation renders it: overdamped on + * paper (mass 2, stiffness 200, damping 100), critically damped in effect at + * ω₀ = √(stiffness ÷ mass). + */ +const OMEGA = Math.sqrt( + swipeActions.settle.stiffness / swipeActions.settle.mass, +); +const SPRING = {response: (2 * Math.PI) / OMEGA, dampingFraction: 1}; + +interface OpenRow { + driver: TouchDriver; + row: Locator; + panel: Locator; + track: Locator; + entries: Locator; + /** The row's resting box. */ + rest: {x: number; y: number; width: number; height: number}; + /** Where a finger lands to drag toward the trailing side (leftward). */ + start: {x: number; y: number}; +} + +async function openRow(page: Page): Promise { + await installLongTaskLog(page); + await openStory(page, storybook(), 'core-item--swipe-actions'); + const driver = await touchDriver(page); + const row = page.locator('.astryx-item').first(); + const panel = row.locator('[data-swipe-panel="trailing"]'); + // The gesture arrives with the swipe layer. + await expect(panel).toBeAttached(); + const track = panel.locator(':scope > div').first(); + // The entries are the track's children, each its own moving element. + const entries = track; + const rest = await row.boundingBox(); + if (rest == null) { + throw new Error('no row'); + } + return { + driver, + row, + panel, + track, + entries, + rest, + start: {x: rest.x + rest.width - 24, y: rest.y + rest.height / 2}, + }; +} + +/** How far the row opens: the entries' natural widths together. */ +const openWidthOf = async (track: Locator) => + track.evaluate(node => + Array.from(node.children).reduce( + (width, entry) => width + entry.getBoundingClientRect().width, + 0, + ), + ); + +/** The x of an element's `translate` (0 when it has none). */ +const xOf = async (locator: Locator) => + locator.evaluate(node => parseFloat(getComputedStyle(node).translate) || 0); + +/** The four moving parts' x at once (one evaluate: also while timers are frozen). */ +const partsOf = async (row: Locator) => + row.evaluate(node => { + const x = (el: Element | null) => + el == null ? NaN : parseFloat(getComputedStyle(el).translate) || 0; + const panel = node.querySelector('[data-swipe-panel="trailing"]'); + const track = panel?.firstElementChild ?? null; + return { + root: x(node), + panel: x(panel), + track: x(track), + entries: x(track?.firstElementChild ?? null), + }; + }); + +/** + * The settle the row is running: when it started (page clock, epoch ms) and + * how long it moves. The animator holds a finished curve's end state a while + * longer (so a blocked main thread cannot drop it), so the motion ends at the + * first keyframe that reaches the final value, not at the animation's end. + */ +async function motionOf( + row: Locator, + target?: number, +): Promise<{from: number; ms: number}> { + // The settle toward `target`, not a tracking spring still running when the + // lift has been sent but not yet handled (WebKit dispatches in real time). + if (target != null) { + await expect + .poll(async () => + row.evaluate(node => { + const animation = node.getAnimations()[0]; + const frames = ( + animation?.effect as KeyframeEffect | undefined + )?.getKeyframes(); + return frames == null + ? null + : parseFloat(String(frames[frames.length - 1].translate)); + }), + ) + .toBeCloseTo(target, 0); + } + await waitForAnimation(row); + return row.evaluate(node => { + const animation = node.getAnimations()[0]; + const effect = animation.effect as KeyframeEffect; + const duration = Number(effect.getComputedTiming().duration); + const frames = effect.getKeyframes(); + const last = String(frames[frames.length - 1].translate); + const end = frames.find(frame => String(frame.translate) === last); + return { + from: performance.timeOrigin + Number(animation.startTime), + ms: (end?.computedOffset ?? 1) * duration, + }; + }); +} + +/** + * A slow drag by `dx` (negative: toward the trailing side) that comes to rest + * before the lift: + * the finger creeps a tenth of a pixel a frame for its last 20 frames, so + * UIKit's velocity filter (which never decays on its own) reads nearly zero, + * and the row has caught up with the finger when it lifts. + */ +/** + * `design:reduced-motion` DR1 and DR2 for a change of the row under Reduce + * Motion, read from the animations running on the row and its parts now: no + * travel (a value that changes, changes in one step, never between frames), + * and an opacity-only cross-fade that dips and is over within 200 ms. Then, + * once it is over, the row is at `to`, fully opaque. + */ +async function expectCrossFade( + page: Page, + row: Locator, + to: number, +): Promise { + const animations = await row.evaluate(node => + document + .getAnimations() + .filter(animation => { + const target = (animation.effect as KeyframeEffect | null)?.target; + return target != null && node.contains(target); + }) + .map(animation => { + const effect = animation.effect as KeyframeEffect; + return { + duration: Number(effect.getComputedTiming().duration), + frames: effect.getKeyframes().map(frame => ({ + offset: frame.computedOffset, + travel: + frame.translate != null + ? String(frame.translate) + : frame.transform != null + ? String(frame.transform) + : null, + opacity: frame.opacity == null ? null : Number(frame.opacity), + })), + }; + }), + ); + for (const {frames} of animations) { + const moving = frames.filter(frame => frame.travel != null); + for (let k = 1; k < moving.length; k += 1) { + if (moving[k].travel !== moving[k - 1].travel) { + expect(moving[k].offset, JSON.stringify(frames)).toBe( + moving[k - 1].offset, + ); + } + } + } + const fades = animations.filter(({frames}) => + frames.some(frame => frame.opacity != null && frame.opacity < 0.05), + ); + expect(fades.length, JSON.stringify(animations)).toBeGreaterThan(0); + for (const {duration, frames} of fades) { + // The animator holds the end state a while after the curve; the fade is + // over at the first keyframe that reaches the final opacity after the + // dip. + const final = frames[frames.length - 1].opacity; + const dip = frames.findIndex(f => f.opacity != null && f.opacity < 0.05); + const back = frames.findIndex((f, k) => k > dip && f.opacity === final); + expect(back, JSON.stringify(frames)).toBeGreaterThan(dip); + expect(frames[back].offset * duration).toBeLessThanOrEqual(200); + } + await page.waitForTimeout(260); + expect(await xOf(row)).toBeCloseTo(to, 0); + expect( + await row.evaluate(node => Number(getComputedStyle(node).opacity)), + ).toBe(1); +} + +function heldDrag(from: {x: number; y: number}, dx: number) { + const moving = drag( + from, + {x: from.x + dx, y: from.y}, + {durationMs: 400, hz: 60}, + ); + const lift = moving[moving.length - 1]; + const last = moving[moving.length - 2]; + const frames = moving.slice(0, -1); + for (let i = 1; i <= 20; i += 1) { + frames.push({ + t: last.t + i * (1000 / 60), + fingers: [{id: 0, x: from.x + dx + (Math.sign(dx) * i) / 10, y: from.y}], + }); + } + // Hold still a while longer, so the tracking spring has settled too. + frames.push({t: frames[frames.length - 1].t + 900, fingers: lift.fingers}); + return frames; +} + +test('the row trails the finger on the tracking spring and catches up when it stops', async ({ + page, + browserName, +}) => { + const {driver, row, start} = await openRow(page); + const dx = 140; + const script = drag( + start, + {x: start.x - dx, y: start.y}, + {durationMs: 400, hz: 60, restMs: 1100}, + ); + const moves = toTouchEvents(script).filter(e => e.type === 'touchMove'); + const lastMove = moves.length - 1; + const samples: {finger: number; row: number}[] = []; + let caughtUp = NaN; + let index = 0; + const started = Date.now(); + await driver.play(script, { + onEvent: async step => { + if (step.type !== 'touchMove') { + return; + } + const finger = start.x - (step.fingers[0]?.x ?? start.x); + samples.push({finger, row: -(await xOf(row))}); + if (index++ === lastMove) { + // The finger has stopped: give the spring most of the rest (within + // 0.05 px of the finger after a second). + await page.waitForTimeout(1000); + caughtUp = -(await xOf(row)); + } + }, + }); + // The swipe is the finger's travel past UIKit's 25 px threshold. While + // the finger moves the row trails it, never ahead and never backwards... + const swipe = (finger: number) => + Math.max(0, finger - swipeActions.hysteresis); + expect(samples.every(s => s.row <= swipe(s.finger) + 0.5)).toBe(true); + expect( + samples.every((s, i) => i === 0 || s.row >= samples[i - 1].row - 0.5), + ).toBe(true); + // ...visibly: a 1:1 drag would never be this far behind. + expect(samples.some(s => swipe(s.finger) - s.row > 10)).toBe(true); + // When the finger stops, the row catches up with it. + expect(caughtUp).toBeCloseTo(swipe(dx), 0); + await waitForRest(row); + // No long task on the page while it tracked: the Long Tasks API is + // Chromium-only, so WebKit runs the rest of the case. + if (browserName === 'chromium') { + expect(await readLongTasks(page, {from: started, to: Date.now()})).toEqual( + [], + ); + } +}); + +test('the settle after a held release is UIKit’s spring, frame by frame, with the panel in step', async ({ + page, +}, testInfo) => { + const {driver, row, entries, start} = await openRow(page); + const openWidth = await openWidthOf(entries); + await installTimerGate(page); + // 105 px of swipe, held: past half the panel, so the row settles open. + await driver.play(heldDrag(start, -130)); + const motion = await motionOf(row, -openWidth); + // Sampling takes real time: hold the page's timers too, or the animator + // retires the held animation at the curve's end mid-sampling. + await freezeTimers(page); + const held = await holdAnimations(page, row); + const samples: { + t: number; + root: number; + panel: number; + track: number; + entries: number; + }[] = []; + for (const t of timeline(motion.ms + 50)) { + await seekAnimations(page, t); + samples.push({t, ...(await partsOf(row))}); + } + await releaseAnimations(page); + await thawTimers(page); + const from = samples[0].root; + const to = -openWidth; + // Released at rest: the settle starts from the row's place at zero + // velocity (the tracking spring had settled), toward the open width. + const expected = (t: number) => from + (to - from) * springStep(SPRING, t); + const result = compareCurve( + samples.map(s => ({t: s.t, value: s.root})), + expected, + 0.5, + ); + await writeEvidence( + 'curves/item-swipe-settle', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {spring: SPRING, openWidth, held, comparison: result, samples}, + null, + 1, + ), + }, + testInfo, + ); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.5, + ); + expect(samples.at(-1)?.root).toBeCloseTo(to, 0); + // Every part on one clock: the window cancels the row, the track moves + // with it, the entries stay pinned (sampling error of the baked keyframes + // only). + const pinned = samples[0].track + samples[0].entries; + for (const s of samples) { + expect(Math.abs(s.root + s.panel)).toBeLessThanOrEqual(0.2); + expect(Math.abs(s.track - s.root)).toBeLessThanOrEqual(0.2); + expect(Math.abs(s.track + s.entries - pinned)).toBeLessThanOrEqual(0.2); + } +}); + +test('a flick hands the row’s velocity to the settle', async ({ + page, +}, testInfo) => { + // A quick throw toward the trailing side: about 55 px of swipe, short of + // half the panel, still moving at 1000 px/s when it lifts. The release + // projects past half the panel, so the row opens; the settle starts with + // the row's own velocity, which the tracking spring had built up. + // WebKit stamps a touch event when it dispatches it, so a stall on a busy + // machine between the last move and the lift reads as a finger that rested + // (`spec:AST-100` FR2: the release velocity is 0), and the throw no longer + // flicks. Only a delivered flick is judged: the throw is replayed on a + // fresh page until the lift arrives within the stillness interval. + let opened: OpenRow | undefined; + let openWidth = 0; + let liftGap = Number.POSITIVE_INFINITY; + for ( + let attempt = 0; + attempt < 3 && liftGap > UIKIT_VELOCITY.stillness.value; + attempt += 1 + ) { + opened = await openRow(page); + openWidth = await openWidthOf(opened.entries); + await installTimerGate(page); + // The row's tracking curve as the finger lifts, before the release + // replaces it: its keyframes, points on the curve. And how long after + // the last move the lift was stamped. The lift is the finger's first + // lift event: its pointerup comes before its touchend, and a gesture host + // may release on either; a move is either of its moves. + await opened.row.evaluate(node => { + let lastMove = Number.NaN; + let firstMoveHandled = Number.NaN; + let lifted = false; + const onMove = (event: Event) => { + lastMove = Math.max( + Number.isNaN(lastMove) ? event.timeStamp : lastMove, + event.timeStamp, + ); + // When the row's tracking spring could first have started: the host + // handles the move right after this listener. + if (Number.isNaN(firstMoveHandled)) { + firstMoveHandled = performance.now(); + } + }; + addEventListener('pointermove', onMove, {capture: true}); + addEventListener('touchmove', onMove, {capture: true}); + const onLift = (event: Event) => { + // The longest the tracking spring can have run in the animator's + // model when the host releases, on this lift event (right after this + // listener) or the one before it. + Object.assign(window, { + __liftElapsed: (performance.now() - firstMoveHandled) / 1000, + }); + if (lifted) { + return; + } + lifted = true; + Object.assign(window, {__liftGap: event.timeStamp - lastMove}); + const animation = node + .getAnimations() + .find(candidate => + (candidate.effect as KeyframeEffect) + .getKeyframes() + .some(frame => 'translate' in frame), + ); + const effect = animation?.effect as KeyframeEffect | undefined; + const duration = Number(effect?.getComputedTiming().duration ?? 0); + Object.assign(window, { + __trackingAtLift: (effect?.getKeyframes() ?? []).map(frame => ({ + t: (frame.computedOffset ?? 0) * duration, + value: parseFloat(String(frame.translate)), + })), + }); + }; + addEventListener('pointerup', onLift, {capture: true}); + addEventListener('touchend', onLift, {capture: true}); + }); + await opened.driver.play( + flick(opened.start, { + velocity: {x: -1000, y: 0}, + durationMs: 80, + hz: 120, + }), + ); + liftGap = await page.evaluate( + () => (window as unknown as {__liftGap: number}).__liftGap, + ); + } + expect( + liftGap, + 'the browser delivers the lift within the stillness interval', + ).toBeLessThanOrEqual(UIKIT_VELOCITY.stillness.value); + const {row} = opened as OpenRow; + const motion = await motionOf(row, -openWidth); + // The settle's keyframes: the points the animator computed on its curve. + // The browser draws straight lines between them, up to a quarter pixel + // inside the curve, so the initial velocity is read from these points + // (fitted to frames between them it reads about 5 px/s low). + const keyed = await row.evaluate(node => { + const animation = node.getAnimations()[0]; + const effect = animation.effect as KeyframeEffect; + const duration = Number(effect.getComputedTiming().duration); + return effect.getKeyframes().map(frame => ({ + t: (frame.computedOffset ?? 0) * duration, + value: parseFloat(String(frame.translate)), + })); + }); + await freezeTimers(page); + await holdAnimations(page, row); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(motion.ms + 50, 1000 / 120)) { + await seekAnimations(page, t); + samples.push({t, value: await xOf(row)}); + } + await releaseAnimations(page); + await thawTimers(page); + const target = -openWidth; + // The critically damped family: x(t) = target + (A + B·t)·e^(−ωt), with + // A the distance left at t = 0 and B = v0 + ω·A. Fit A and B to the + // keyframes by least squares. + let saa = 0; + let sab = 0; + let sbb = 0; + let sya = 0; + let syb = 0; + for (const {t, value} of keyed) { + const s = t / 1000; + const a = Math.exp(-OMEGA * s); + const b = s * a; + const y = value - target; + saa += a * a; + sab += a * b; + sbb += b * b; + sya += y * a; + syb += y * b; + } + const det = saa * sbb - sab * sab; + const d0 = (sya * sbb - syb * sab) / det; + const slope = (syb * saa - sya * sab) / det; + const v0 = slope - OMEGA * d0; + const fitted = (t: number) => { + const s = t / 1000; + return target + (d0 + slope * s) * Math.exp(-OMEGA * s); + }; + // What is on screen, the frames between keyframes too, follows that spring. + const result = compareCurve(samples, fitted, 0.75); + await writeEvidence( + 'curves/item-swipe-flick-settle', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {omega: OMEGA, v0, d0, target, comparison: result, keyed, samples}, + null, + 1, + ), + }, + testInfo, + ); + // The whole settle is that spring... + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.75, + ); + // ...starting with the velocity the row had, as UIKit hands the + // presentation velocity to the settle (from rest it would start at 0). + // How fast the row moves at the lift depends on when the pan began, which + // follows WebKit's real-time dispatch of the flick's events, so the hand-off + // is checked against the row's own tracking curve at the lift: the same + // critically damped family, toward the finger's last offset. On screen, + // the settle starts at a point of that curve with that point's velocity. + // Still waiting for the browser to start it, the curve is at its start on + // screen and the settle keeps the speed the curve has reached by the lift + // (the animator carries a flick's speed through that wait). + const tracking = await page.evaluate( + () => + (window as unknown as {__trackingAtLift: {t: number; value: number}[]}) + .__trackingAtLift, + ); + expect(tracking.length).toBeGreaterThan(1); + const trackingOmega = Math.sqrt( + swipeActions.tracking.stiffness / swipeActions.tracking.mass, + ); + const finger = tracking[tracking.length - 1].value; + let taa = 0; + let tab = 0; + let tbb = 0; + let tya = 0; + let tyb = 0; + for (const {t, value} of tracking) { + const s = t / 1000; + const a = Math.exp(-trackingOmega * s); + const b = s * a; + const y = value - finger; + taa += a * a; + tab += a * b; + tbb += b * b; + tya += y * a; + tyb += y * b; + } + const tdet = taa * tbb - tab * tab; + const ta = (tya * tbb - tyb * tab) / tdet; + const tb = (tyb * taa - tya * tab) / tdet; + const trackingAt = (s: number) => + finger + (ta + tb * s) * Math.exp(-trackingOmega * s); + const trackingSpeed = (s: number) => + Math.exp(-trackingOmega * s) * (tb - trackingOmega * (ta + tb * s)); + const startValue = target + d0; + let handOff: number; + if (Math.abs(startValue - trackingAt(0)) < 0.05) { + // Not started on screen: the speed the curve has reached in the + // animator's model by the release. The model runs it from the first move + // the browser has not started yet, so it has run no longer than the page + // measured from the first move to the last lift event (a busy machine can + // push that past the curve's first frames). + const elapsed = await page.evaluate( + () => (window as unknown as {__liftElapsed: number}).__liftElapsed, + ); + expect(Number.isFinite(elapsed)).toBe(true); + handOff = trackingSpeed(0); + for (let s = 0; s <= elapsed + 0.005; s += 0.0001) { + if (Math.abs(trackingSpeed(s) - v0) < Math.abs(handOff - v0)) { + handOff = trackingSpeed(s); + } + } + } else { + // On screen: the point of the curve where the settle starts. The + // tracking curve is read from its keyframes, which sit within a quarter + // pixel of it, so that point is known to within a quarter pixel: in time, + // a quarter pixel over the curve's speed there (right after a retarget + // the speed changes by about 3 px/s a millisecond). + let lo = 0; + let hi = tracking[tracking.length - 1].t / 1000; + const side = Math.sign(trackingAt(lo) - startValue); + for (let i = 0; i < 60; i += 1) { + const mid = (lo + hi) / 2; + if (Math.sign(trackingAt(mid) - startValue) === side) { + lo = mid; + } else { + hi = mid; + } + } + const at = (lo + hi) / 2; + const slack = 0.25 / Math.max(Math.abs(trackingSpeed(at)), 1); + handOff = trackingSpeed(at); + for (let s = Math.max(0, at - slack); s <= at + slack; s += slack / 50) { + if (Math.abs(trackingSpeed(s) - v0) < Math.abs(handOff - v0)) { + handOff = trackingSpeed(s); + } + } + } + expect(v0).toBeLessThan(0); + expect(Math.abs(v0 - handOff), `v0 ${v0}, row ${handOff}`).toBeLessThan(1); + expect(samples.at(-1)?.value).toBeCloseTo(target, 0); +}); + +test('under Reduce Motion a held release lands open with no travel: the row steps there at the bottom of an opacity dip', async ({ + page, +}) => { + // design:reduced-motion: the finger's own drag is direct manipulation, but + // what its release starts never travels or springs, and cross-fades (DR2). + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, row, entries, start} = await openRow(page); + const openWidth = await openWidthOf(entries); + await driver.play(heldDrag(start, -130)); + await expectCrossFade(page, row, -openWidth); +}); + +test('a close the main thread holds back plays from its start after the block, skipping no travel', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'WebKit starts an animation created just before a block at its last frame time (animator.ts); the iOS simulator rig covers Safari', + ); + const {driver, row, entries, start} = await openRow(page); + const openWidth = await openWidthOf(entries); + await driver.play(heldDrag(start, -130)); + await waitForRest(row); + // The task that closes the row then blocks for 800 ms, as a frame held back + // does (iOS holds them during a smooth scroll). + await page.evaluate(() => { + window.addEventListener( + 'keydown', + () => { + const until = performance.now() + 800; + while (performance.now() < until) { + // Busy: the frame that would start the close is held back. + } + }, + {once: true}, + ); + }); + await entries.getByRole('button').first().focus(); + await page.keyboard.press('Escape'); + const firstFrame = await row.evaluate( + async node => + new Promise(resolve => { + requestAnimationFrame(() => { + const style = getComputedStyle(node); + resolve( + (parseFloat(style.translate) || 0) + + new DOMMatrix(style.transform).e, + ); + }); + }), + ); + // The close starts on the frame that commits it, not at the keydown: its + // first frame is where the row rested, not most of the way home. + expect(firstFrame).toBeLessThan(-openWidth * 0.9); + await waitForRest(row); + expect(await xOf(row)).toBeCloseTo(0, 0); +}); + +test('a finger that lands on a settling row catches it where it is', async ({ + page, +}) => { + const {driver, row, start} = await openRow(page); + await driver.play( + flick(start, {velocity: {x: -1000, y: 0}, durationMs: 80, hz: 120}), + ); + await waitForAnimation(row, {minProgressMs: 40}); + // Watch every frame while a second touch lands on the moving row and + // drags it back toward closed. + const from = {x: start.x - 80, y: start.y}; + const [rects] = await Promise.all([ + sampleRects(row, 700), + driver.play( + drag(from, {x: from.x + 120, y: from.y}, {durationMs: 300, hz: 60}), + ), + ]); + const xs = rects.map(r => r.box?.x ?? NaN); + const steps = xs.slice(1).map((x, i) => Math.abs(x - xs[i])); + // The row is caught from where it was presented: no frame jumps, and none + // moves faster than the settle itself did (about 1000 px/s, 17 px a frame + // at 60 Hz, with a frame of slack). + expect(Math.max(...steps)).toBeLessThan(35); + await waitForRest(row); +}); + +test('the settle keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright (WebKit: the iOS simulator rig)', + ); + const {driver, row, rest} = await openRow(page); + // Toward the leading side (rightward): the marker chip sits at the row's + // top-left corner, which a leftward slide would carry out of the list's + // clip. + await markElement(row); + // The page blocks its main thread for 700 ms as soon as the settle has + // started on the compositor: the release's new animations resolve `ready` + // once the browser has started them, and the block follows in the next + // microtask, so the settle's own motion fills the judged window. A block + // started from the test, or on a timer after the lift, lands wherever a + // loaded machine runs it, sometimes late in the motion. + await page.evaluate(ms => { + const block = () => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + Object.assign(window, { + __blockAfterLift: { + from, + to: performance.timeOrigin + performance.now(), + }, + }); + }; + // The release's own animations: the ones that did not exist at the lift. + // The lift is the finger's first lift event: its pointerup comes before + // its touchend, and the gesture host releases on whichever it reads + // first (`spec:AST-094` FR43). + let lifted = false; + const onLift = () => { + if (lifted) { + return; + } + lifted = true; + const before = new Set(document.getAnimations()); + const watch = () => { + const fresh = document + .getAnimations() + .filter(animation => !before.has(animation)); + if (fresh.length === 0) { + requestAnimationFrame(watch); + return; + } + void Promise.all(fresh.map(async animation => animation.ready)).then( + block, + block, + ); + }; + setTimeout(watch, 0); + }; + addEventListener('pointerup', onLift, {capture: true}); + addEventListener('touchend', onLift, {capture: true}); + }, 700); + const cast = await startScreencast(page); + // 180 px of swipe, held, short of the commit point: the row settles back + // to the open width over about 100 px, a long enough motion to watch. + await driver.play( + heldDrag({x: rest.x + 24, y: rest.y + rest.height / 2}, 205), + ); + const block = (await ( + await page.waitForFunction( + () => + (window as unknown as {__blockAfterLift?: {from: number; to: number}}) + .__blockAfterLift, + ) + ).jsonValue()) as {from: number; to: number}; + const motion = await motionOf(row); + const settle = {from: motion.from, to: motion.from + motion.ms}; + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const proof = busyWindowProof( + tracked, + {from: block.from, to: Math.min(block.to, settle.to)}, + // Within 8 px of its end a critically damped settle moves less than a + // pixel a frame, finer than the screencast resolves. The judged part of + // the block is the settle's own motion, not a sliver: 16 frames at least. + {axis: 'x', settledWithin: 8, minJudgedFrames: 16}, + ); + await writeEvidence( + 'busy-main-thread/item-swipe-settle', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + settle, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + x: f.box?.x ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'item-swipe-settle-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: Math.min(block.to, settle.to) + 40, + columns: 8, + cellScale: 0.35, + data: t => ({x: tracked.find(f => f.t === t)?.box?.x ?? -1}), + }); + await film.write(testInfo, {block, settle}); + // The harness's verdict (no stall over two dropped frames, nearly every + // expected frame moved, the judged part long enough), in frames at the + // screencast's own cadence rather than its delivery rate. + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.frames).toBeGreaterThanOrEqual(proof.expectedFrames - 2); + expect(proof.changed).toBeGreaterThanOrEqual(proof.frames - 3); + expect(proof.monotonic).toBe(true); +}); + +test('the drag and the settle do no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, row, start} = await openRow(page); + let motion = {from: 0, ms: 0}; + let dragWindow = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + const begun = Date.now(); + await driver.play(heldDrag(start, -130)); + dragWindow = {from: begun + 250, to: begun + 450}; + motion = await motionOf(row); + await waitForRest(row); + }); + // The steady part of the drag (after the claim) and of the settle: their + // first and last frames may commit, and the row lands (one render) when + // the settle ends. + const during = countBetween( + trace, + motion.from + 50, + motion.from + motion.ms - 50, + ); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + const dragging = countBetween(trace, dragWindow.from, dragWindow.to); + expect(dragging.Layout ?? 0, JSON.stringify(dragging)).toBe(0); + expect(trace.longTasks).toEqual([]); +}); + +test( + 'storyboard: drag the row open and let it settle', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, row, start, rest} = await openRow(page); + await installTimerGate(page); + const clip = { + x: 0, + y: Math.max(0, rest.y - 8), + width: page.viewportSize()?.width ?? 402, + height: rest.height + 16, + }; + const measure = async () => ({x: await xOf(row)}); + const dragBoard = await touchStoryboard( + page, + driver, + drag( + start, + {x: start.x - 130, y: start.y}, + {durationMs: 200, hz: 30, restMs: 150}, + ), + { + name: 'item-swipe-drag', + clip, + measure, + columns: 4, + cellScale: 0.6, + }, + ); + await waitForAnimation(row); + await freezeTimers(page); + const {storyboard: settleBoard} = await animationStoryboard(page, { + name: 'item-swipe-settle', + clip, + scope: row, + measure, + columns: 6, + cellScale: 0.6, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await dragBoard.write(testInfo); + await settleBoard.write(testInfo); + // The trace is the assertion; the sheets are the evidence. + const dragXs = dragBoard.frames.map(f => f.data.x as number); + expect(dragXs.every((x, i) => i === 0 || x <= dragXs[i - 1] + 0.5)).toBe( + true, + ); + const settleXs = settleBoard.frames.map(f => f.data.x as number); + expect(settleXs.at(-1)).toBeLessThan(-60); + }, +); + +test( + 'storyboard: a short drag springs back', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, row, rest} = await openRow(page); + await installTimerGate(page); + const clip = { + x: 0, + y: Math.max(0, rest.y - 8), + width: page.viewportSize()?.width ?? 402, + height: rest.height + 16, + }; + // 30 px of swipe toward the leading side, come to rest: short of half + // the panel, so the row springs back. + await driver.play( + heldDrag({x: rest.x + 24, y: rest.y + rest.height / 2}, 55), + ); + await waitForAnimation(row); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'item-swipe-spring-back', + clip, + scope: row, + measure: async () => ({x: await xOf(row)}), + times: timeline((await motionOf(row, 0)).ms + 50, 1000 / 30), + columns: 6, + cellScale: 0.6, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + const xs = storyboard.frames.map(f => f.data.x as number); + expect(xs.every((x, i) => i === 0 || x <= xs[i - 1] + 0.5)).toBe(true); + expect(xs.at(-1)).toBeCloseTo(0, 0); + }, +); + +test( + 'storyboard: a flick opens the row and a finger catches it mid-settle', + {tag: '@storyboard'}, + async ({page, browserName}, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frames are Chromium-only in Playwright', + ); + const {driver, row, start} = await openRow(page); + await markElement(row, {mode: 'outline'}); + const cast = await startScreencast(page); + const began = Date.now(); + await driver.play( + flick(start, {velocity: {x: -1000, y: 0}, durationMs: 80, hz: 120}), + ); + await waitForAnimation(row, {minProgressMs: 60}); + const from = {x: start.x - 80, y: start.y}; + await driver.play( + drag(from, {x: from.x + 120, y: from.y}, {durationMs: 300, hz: 60}), + ); + await waitForRest(row); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const film = await castStoryboard(page, castFrames, { + name: 'item-swipe-flick-and-catch', + zero: began, + from: began, + to: Date.now(), + columns: 8, + cellScale: 0.35, + data: t => ({x: tracked.find(f => f.t === t)?.box?.x ?? -1}), + }); + await film.write(testInfo); + // The trace is the assertion: no frame jumps across the catch. + const xs = tracked + .filter(f => f.t >= began && f.box != null) + .map(f => f.box?.x as number); + const steps = xs.slice(1).map((x, i) => Math.abs(x - xs[i])); + expect(xs.length).toBeGreaterThan(10); + expect(Math.max(...steps)).toBeLessThan(35); + }, +); diff --git a/packages/core/src/Item/__tests__/ItemSwipePinchZoom.a11y.chromium.spec.ts b/packages/core/src/Item/__tests__/ItemSwipePinchZoom.a11y.chromium.spec.ts index d36a349d51929..15cd7143f080d 100644 --- a/packages/core/src/Item/__tests__/ItemSwipePinchZoom.a11y.chromium.spec.ts +++ b/packages/core/src/Item/__tests__/ItemSwipePinchZoom.a11y.chromium.spec.ts @@ -51,8 +51,8 @@ async function openStory(page: Page) { const rows = page.locator('.astryx-item'); const row = rows.first(); await expect(row.getByText('Message 1')).toBeVisible(); - // The gesture arrives with the lazily loaded swipe layer; its panel is the - // sign the row can be dragged. + // The gesture arrives with the swipe layer; its panel is the sign the row + // can be dragged. await expect(row.locator('[data-swipe-panel="leading"]')).toBeAttached(); const box = (await row.boundingBox()) as Box; const next = (await rows.nth(1).boundingBox()) as Box; @@ -63,7 +63,7 @@ async function openStory(page: Page) { const w = window as unknown as {__maxTravel: number}; w.__maxTravel = 0; const tick = () => { - const travel = Math.abs(new DOMMatrix(getComputedStyle(el).transform).e); + const travel = Math.abs(parseFloat(getComputedStyle(el).translate) || 0); w.__maxTravel = Math.max(w.__maxTravel, travel); requestAnimationFrame(tick); }; @@ -170,7 +170,8 @@ test('a one-finger sideways drag still swipes the row and leaves the zoom alone' type: 'touchStart', touchPoints: [{x: startX, y}], }); - for (const dx of [6, 14, 28, 44, 60]) { + // The swipe begins after 25 px and is the travel past them. + for (const dx of [6, 14, 28, 50, 70, 90]) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{x: startX + dx, y}], diff --git a/packages/core/src/Item/__tests__/emulateTouch.ts b/packages/core/src/Item/__tests__/emulateTouch.ts new file mode 100644 index 0000000000000..c172a3d6cb24c --- /dev/null +++ b/packages/core/src/Item/__tests__/emulateTouch.ts @@ -0,0 +1,23 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file emulateTouch.ts + * @input vitest's global stubs; the touch presentation resolver's query + * @output emulateTouch — jsdom answers as a device driven by a finger (the + * primary pointer coarse, TOUCH_QUERY matching), so the animator treats + * every change as a touch presentation's (under Reduce Motion it + * cross-fades). Undo with `vi.unstubAllGlobals()`. + * @position Test helper for the swipe-action unit tests (Item, List) + */ + +import {vi} from 'vitest'; +import {TOUCH_QUERY} from '../../utils/touchPresentation'; + +/** Answer as a touch device (a coarse primary pointer), until the globals are unstubbed. */ +export function emulateTouch(): void { + const matchMedia = window.matchMedia.bind(window); + vi.stubGlobal('matchMedia', (query: string) => { + const list = matchMedia(query); + return query === TOUCH_QUERY ? {...list, matches: true} : list; + }); +} diff --git a/packages/core/src/Item/swipeRelease.test.ts b/packages/core/src/Item/swipeRelease.test.ts new file mode 100644 index 0000000000000..982808917bbdb --- /dev/null +++ b/packages/core/src/Item/swipeRelease.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file swipeRelease.test.ts + * @input vitest; swipeRelease.ts and the UIKit constants table + * @output The release rule against the UIKit lab's releases; the commit + * point and the resistance past it; the swipe spring is the one UIKit + * reported + * @position Unit tests of packages/core/src/Item/swipeRelease.ts + */ + +import {describe, expect, it} from 'vitest'; +import { + dampingRatioOf, + naturalFrequency, + settlingDuration, + toSpring, +} from '../motion/spring'; +import {swipeActions} from '../motion/uikit/swipeActions'; +import {commitPoint, resistedTravel, resolveRelease} from './swipeRelease'; + +describe('resolveRelease', () => { + // The UIKit lab's releases (batch 10a: plain table, 402 pt rows, two + // trailing actions, a 156.67 pt open width; the full-swipe point far beyond + // them), as the swipe pan reported them when the finger lifted: the + // swipe's travel (past the 25 pt the pan needs to begin; negative when the + // finger came back past where it began) and its velocity in pt/s, positive + // toward open. A scripted "closing" release can still be opening when it + // lifts (the 36 pt one at +458), so the reported values are the inputs. + const OPEN_WIDTH = 156.67; + const FAR = 373; + const RELEASES: [travel: number, velocity: number, lands: string][] = [ + [35, -19, 'closed'], + [65, 490, 'open'], + [125, 1506, 'open'], + [5, -492, 'closed'], + [-55, -1532, 'closed'], + [95, -16, 'open'], + [125, 478, 'open'], + [185, 1565, 'open'], + [65, -468, 'closed'], + // [115, +135]: closed. Scripted as a 1500 pt/s closing flick, it lifted + // with the pan reporting a slow opening one: the one release of the 51 + // neither this rule nor projection explains. + [-67, -2545, 'closed'], + [-47.7, -1025, 'closed'], + [-3, -256, 'closed'], + [12.3, -22, 'closed'], + [30.3, 293, 'open'], + [64, 1058, 'open'], + [132.3, 1983, 'open'], + [-67, -2060, 'closed'], + [-10, -1087, 'closed'], + [35.7, 458, 'open'], + [50, -18, 'closed'], + [63, 286, 'open'], + [93, 1372, 'open'], + [170, 2031, 'open'], + [1.7, -2168, 'closed'], + [27.3, -996, 'closed'], + [69.3, -287, 'closed'], + [87.7, -5, 'open'], + [100.3, 148, 'open'], + [147.3, 1271, 'open'], + [190.3, 608, 'open'], + [39.3, -2052, 'closed'], + [65, -983, 'closed'], + [107, -308, 'closed'], + [125, -18, 'open'], + [143, 347, 'open'], + [185, 948, 'open'], + [245, 2082, 'open'], + [42.3, -1975, 'closed'], + [102.7, -991, 'closed'], + [144.3, -283, 'closed'], + [162.3, -19, 'open'], + [180.3, 271, 'open'], + [222.7, 1002, 'open'], + [282.3, 1971, 'open'], + ]; + + it.each(RELEASES)( + 'lands where UIKit did: %s pt of swipe at %s pt/s → %s', + (travel, velocity, lands) => { + expect( + resolveRelease({ + behavior: 'reveal', + commitAt: FAR, + openWidth: OPEN_WIDTH, + travel: Math.max(0, travel), + velocity, + }), + ).toBe(lands); + }, + ); + + it('goes the way a release moves from about 50 pt/s, and by where it is below that (batches 38c, 38d)', () => { + // 204.67 pt open width, released at 0.7 of it: past half. The speeds + // are the pan's at its end, as UIKit read them. + const base = { + behavior: 'reveal' as const, + commitAt: 325, + openWidth: 204.67, + travel: 143, + }; + for (const velocity of [-19.9, -21.2, -35.7, -40.7, 0, 56.1]) { + expect(resolveRelease({...base, velocity})).toBe('open'); + } + for (const velocity of [-56.1, -56.5, -68.5, -105, -200, -308]) { + expect(resolveRelease({...base, velocity})).toBe('closed'); + } + }); + + it('closes a release past the open width that is moving back, as UIKit did (batch 38)', () => { + // 204.67 pt open width; released about 300 pt out while coming back at + // 300 pt/s: UIKit closed the row, though it would coast to rest past half + // the open width. + expect( + resolveRelease({ + behavior: 'reveal', + commitAt: 325, + openWidth: 204.67, + travel: 305, + velocity: -300, + }), + ).toBe('closed'); + // The same release at rest, or still opening, stays open. + for (const velocity of [0, 300]) { + expect( + resolveRelease({ + behavior: 'reveal', + commitAt: 325, + openWidth: 204.67, + travel: 305, + velocity, + }), + ).toBe('open'); + } + }); + + it('fires under reveal on a release past the commit point, whatever the velocity, and never on a flick short of it', () => { + const base = {behavior: 'reveal' as const, commitAt: 192, openWidth: 144}; + expect(resolveRelease({...base, travel: 192, velocity: 0})).toBe('perform'); + expect(resolveRelease({...base, travel: 200, velocity: -2000})).toBe( + 'perform', + ); + expect(resolveRelease({...base, travel: 44, velocity: 3000})).toBe('open'); + expect(resolveRelease({...base, travel: 191, velocity: 3000})).toBe('open'); + }); + + it('fires under commit only past the commit point, and never rests', () => { + const base = {behavior: 'commit' as const, commitAt: 150, openWidth: 72}; + expect(resolveRelease({...base, travel: 150, velocity: 0})).toBe('perform'); + expect(resolveRelease({...base, travel: 160, velocity: -1000})).toBe( + 'perform', + ); + expect(resolveRelease({...base, travel: 100, velocity: 0})).toBe('closed'); + expect(resolveRelease({...base, travel: 40, velocity: 3000})).toBe( + 'closed', + ); + }); +}); + +describe('commitPoint and resistedTravel', () => { + it('commits past the panel, or past the fraction of the row when that is further', () => { + expect(commitPoint(72, 300)).toBe(150); + expect(commitPoint(144, 300)).toBe(192); + }); + + it('follows the finger up to the commit point and resists past it', () => { + expect(resistedTravel(150, 150)).toBe(150); + expect(resistedTravel(250, 150)).toBeCloseTo( + 150 + 100 * swipeActions.overdragResistance, + 10, + ); + }); +}); + +describe('the swipe spring', () => { + it('is the one UIKit reported: critically damped at 10 rad/s, settling in 0.9235 s', () => { + for (const description of [swipeActions.tracking, swipeActions.settle]) { + const spring = toSpring(description); + expect(dampingRatioOf(spring)).toBeCloseTo(1, 10); + expect(naturalFrequency(spring)).toBeCloseTo(10, 10); + // UIKit's settlingDuration for it, within the 0.02 % the solver's + // critically damped branch is documented to land. + expect(settlingDuration(spring)).toBeCloseTo(0.9235, 3); + } + }); +}); diff --git a/packages/core/src/Item/swipeRelease.ts b/packages/core/src/Item/swipeRelease.ts new file mode 100644 index 0000000000000..c6e364d9005af --- /dev/null +++ b/packages/core/src/Item/swipeRelease.ts @@ -0,0 +1,85 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file swipeRelease.ts + * @input The UIKit constants table (motion/uikit/swipeActions.ts) + * @output Exports resolveRelease (where a swipe-action release lands: closed, + * open, or the outermost entry fires), commitPoint and resistedTravel + * @position Pure pieces of Item's swipe actions, beside useSwipeAction; + * tested by swipeRelease.test.ts against the UIKit lab's releases + * + * A UIKit list row decides a release by its speed first. A release at + * `releaseVelocity` or faster goes the way it moves: it opens the row when + * it opens and closes it when it closes, from anywhere short of the commit + * point. A slower one goes by where it is, against `openFraction` of the + * open width. So a flick opens the row even from a short drag and closes it + * from anywhere, and a flick never fires an action: only a release past the + * commit point does. Travel and velocity here are logical: measured toward + * the side being uncovered, whatever the writing direction. + */ + +import {swipeActions} from '../motion/uikit/swipeActions'; + +/** What a swipe does (Item's `swipeBehavior`). */ +export type SwipeReleaseBehavior = 'reveal' | 'commit'; + +/** Where a release lands. */ +export type SwipeRelease = 'closed' | 'open' | 'perform'; + +/** + * Where a release lands. `travel` (px, ≥ 0) and `velocity` (px/s) are + * logical: positive toward the side being uncovered. A release past the + * commit point fires the outermost entry, whatever its velocity; under + * `commit` nothing else fires and nothing rests. Under `reveal` a release at + * `releaseVelocity` or faster opens the row if it is opening and closes it if + * it is closing; a slower one opens it past `openFraction` of its open width + * and closes it short of that. + */ +export function resolveRelease({ + travel, + velocity, + openWidth, + commitAt, + behavior, +}: { + travel: number; + velocity: number; + openWidth: number; + commitAt: number; + behavior: SwipeReleaseBehavior; +}): SwipeRelease { + if (travel >= commitAt) { + return 'perform'; + } + if (behavior === 'commit') { + return 'closed'; + } + if (velocity >= swipeActions.releaseVelocity) { + return 'open'; + } + if (velocity <= -swipeActions.releaseVelocity) { + return 'closed'; + } + return travel >= swipeActions.openFraction * openWidth ? 'open' : 'closed'; +} + +/** + * The commit point: `commitPastPanel` past the open width, or + * `commitFraction` of the row when that is further. + */ +export function commitPoint(openWidth: number, rowWidth: number): number { + return Math.max( + openWidth + swipeActions.commitPastPanel, + rowWidth * swipeActions.commitFraction, + ); +} + +/** + * The row's travel for the finger's: one for one up to the commit point, + * `overdragResistance` per pixel past it. + */ +export function resistedTravel(raw: number, commitAt: number): number { + return raw <= commitAt + ? raw + : commitAt + (raw - commitAt) * swipeActions.overdragResistance; +} diff --git a/packages/core/src/Item/useSwipeAction.test.ts b/packages/core/src/Item/useSwipeAction.test.ts new file mode 100644 index 0000000000000..87cd1bb38e547 --- /dev/null +++ b/packages/core/src/Item/useSwipeAction.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useSwipeAction.test.ts + * @input vitest; the pure geometry of useSwipeAction + * @output The geometry every moving part follows (the window never moves on + * screen, the track moves with the row, the entries stay pinned) for both + * sides in both directions + * @position Unit tests of packages/core/src/Item/useSwipeAction.ts; the hook + * itself is tested through Item.test.tsx and the interaction spec + */ + +import {describe, expect, it} from 'vitest'; +import {physicalSign, swipeParts, type SwipeLayout} from './useSwipeAction'; + +const LAYOUTS: SwipeLayout[] = [ + {inlineSign: 1, openWidth: 144, side: 'leading'}, + {inlineSign: 1, openWidth: 144, side: 'trailing'}, + {inlineSign: -1, openWidth: 144, side: 'leading'}, + {inlineSign: -1, openWidth: 144, side: 'trailing'}, +]; + +describe('swipeParts', () => { + it('puts a side on the physical edge its direction names', () => { + // Leading sits at the inline start: the left in LTR, the right in RTL. + expect(physicalSign('leading', 1)).toBe(1); + expect(physicalSign('trailing', 1)).toBe(-1); + expect(physicalSign('leading', -1)).toBe(-1); + expect(physicalSign('trailing', -1)).toBe(1); + }); + + it.each(LAYOUTS)( + 'moves the row toward the side and keeps the window, the track and the entries in step ($side, inline sign $inlineSign)', + layout => { + const sign = physicalSign(layout.side, layout.inlineSign); + const pinned = + swipeParts(0, layout).track + swipeParts(0, layout).entries; + for (const travel of [0, 1, 36, 72, 144, 200, 324]) { + const parts = swipeParts(travel, layout); + // The row slides away from the side's edge. + expect(parts.root).toBe(sign * travel); + // The window stays over the resting box. + expect(parts.root + parts.panel).toBe(0); + // The track moves with the row, so its part inside the window is + // exactly the vacated space. + expect(parts.track).toBe(parts.root); + // The entries stay where they were on screen: pinned at the outer + // edge, whatever the travel. + expect(parts.track + parts.entries).toBe(pinned); + } + // Pinned with the outer edge of the entries at the side's edge: the + // entries' far edge is the open width in from it. + expect(pinned).toBe(sign * layout.openWidth); + }, + ); + + it('is linear in the travel, so one spring on every part keeps them in step at every instant', () => { + const layout = LAYOUTS[0]; + const a = swipeParts(20, layout); + const b = swipeParts(80, layout); + const mid = swipeParts(50, layout); + for (const key of ['root', 'panel', 'track', 'entries'] as const) { + expect(mid[key]).toBeCloseTo((a[key] + b[key]) / 2, 10); + } + }); +}); diff --git a/packages/core/src/Item/useSwipeAction.ts b/packages/core/src/Item/useSwipeAction.ts index 7234a2e9e22c0..bd015b9e08568 100644 --- a/packages/core/src/Item/useSwipeAction.ts +++ b/packages/core/src/Item/useSwipeAction.ts @@ -4,88 +4,99 @@ /** * @file useSwipeAction.ts - * @input Pointer events on a row's root; the root element to translate - * @output Exports useSwipeAction — the gesture behind Item's `swipeActions` + * @input Per side, the panel parts that move (callback refs; the row root + * is their parent); the gesture foundation (PanGestureRecognizer) and the + * compositor animator; the UIKit constants table and swipeRelease.ts + * @output Exports useSwipeAction — the gesture and the motion behind Item's + * `swipeActions` — and swipeParts, where every moving part sits for a + * travel (where a release lands is swipeRelease.ts) * @position Internal to Item, through ItemSwipeLayer (the only module that - * imports it, loaded on demand); tested through Item.test.tsx + * imports it); tested through Item.test.tsx and useSwipeAction.test.ts * - * A native-list swipe: drag a row sideways and the side's panel is uncovered. - * Under `reveal` a release past half the panel leaves the row resting open - * with every entry tappable, and a long drag or a fling fires the outermost - * entry; under `commit` a release past the commit point slides the row out - * and fires the outermost entry, and nothing rests. After an entry fires the - * row springs back, or holds out when the entry has `hasRemoval`. A release - * short of the threshold springs back under both. + * A UIKit list swipe: drag a row sideways and the side's panel is uncovered. + * Under `reveal` a fast release goes the way it moves and a slow one rests + * the row open past half the open width, with every entry tappable; a + * release past the commit point fires the outermost entry. Under `commit` a + * release past the commit point slides the row out and fires the outermost + * entry, and nothing rests (swipeRelease.ts). The outermost entry fires + * once the row's content has left its box, at once when the release is + * already past that edge: UIKit calls the handler at the lift, and firing + * when the content is gone means a row its app removes at once never + * vanishes mid-slide. After an entry fires the row springs back, or holds out + * when the entry has `hasRemoval`. Any other release springs back. * - * Touch only, by pointer type rather than by breakpoint: a mouse has the - * row's own controls and a keyboard has the verb already; the gesture is an - * accelerator for a verb the row exposes elsewhere, never the only way to - * reach one. + * Touch only (the recognizer's pointer types), by pointer type rather than + * by breakpoint: a mouse has the row's own controls and a keyboard has the + * verb already; the gesture is an accelerator for a verb the row exposes + * elsewhere, never the only way to reach one. * - * The axis is decided once, at 10 px of travel: a drag more vertical than - * horizontal belongs to the scroller and is never claimed. The claim is told - * to the browser: `touch-action: pan-y` is not enough on iOS Safari, whose - * scroller takes any drag with a vertical component as a pan (a 4:1 drag got - * `pointercancel` 40 px in; the same drag after a one-second hold, which - * fails the native pan, ran to the end), so a non-passive `touchmove` - * listener cancels the default while the row owns the drag, which is the one - * signal WebKit honours. Before the axis is decided, and for a drag the - * scroller got, nothing is cancelled. + * The pan is the gesture foundation's (named `Item.swipe`, a debugging + * label). It claims the touch after 8 px of + * sideways travel (a drag more vertical than horizontal belongs to the + * scroller and fails it), toward a side with entries or on a row that is open + * or moving: a browser commits to scrolling a touch at about 10 px, so the + * row must claim before that. The swipe itself begins where UIKit's does, + * after 25 px, from 0, and in between the row carries on as it was. The pan + * reads each touch's pointer, as `spec:AST-057` says: a finger swipes the row + * whatever the primary pointer and a mouse never starts it, one rule on every + * device. The foundation tells the browser (`touch-action: pan-y + * pinch-zoom`, `touchmove` cancelled only while the pan owns the touch, the + * click the browser synthesizes swallowed) and leaves a pinch to the page: a + * finger that lands while another is down, on the row or anywhere else, never + * starts the pan, a second finger that lands before the claim fails it and + * leaves its touch to the browser's pinch, and once the row has claimed its + * finger a second finger leaves the swipe going (spec:AST-094 FR10). * - * A pinch is the browser's: the root's `touch-action` keeps `pinch-zoom`, and - * a second finger that lands anywhere before the axis is decided turns the - * touch into a pinch, so the row lets it go and cancels nothing. A finger - * that is not the first one down never starts a drag. Once the row owns a - * drag, a second finger does not break it. + * Motion is UIKit's, on the compositor. Every move of the finger starts a + * spring toward the finger's offset from the row's presentation value and + * velocity, so the row trails a moving finger and catches up when it stops; + * the release starts the same spring toward where the row lands, still from + * the presentation velocity. A finger that lands on a moving row catches it + * where it is. The row's root translates; each side's panel is a window over + * the row's resting box (translated back by the travel, so it stays put and + * clips to that box), a track inside it that moves with the row (so it clips + * the entries to exactly the space the row vacates), and each entry inside + * the track, translated back again so the entries stay pinned at the outer + * edge; the outermost entry's colour, painted beside it, fills the rest out + * to that edge. The entries move as one block with no wrapper around them + * (minimal DOM, `architecture:component-style-authoring` INV7): every entry + * plays the same curve. One clock, every curve an affine image of the + * root's: they cannot drift apart, nothing lays out, and they keep moving + * while the main thread is busy. A drag costs no React render; the + * claim, the arming and the landing each cost one. * - * The drag's travel is one custom property written on the root, coalesced to - * one write per frame; the root's transform reads it and the panels' - * transforms read its negation, so the row moves and the panel appears fixed - * in the space the row vacates. Settling (the spring back, the slide out) - * writes a duration the same elements read as their transition clock, so - * everything derived from the travel moves together. A drag costs no React - * render; the claim and the settle each cost one. + * The gesture reads each touch's pointer: a finger swipes the row on every + * device, whatever the primary pointer, and a mouse never does. * - * Directions are logical: `leading` is a drag toward the inline end and - * `trailing` a drag toward the inline start, read against the root's computed - * `direction` at the touch and written beside the travel as a sign the - * transforms multiply by, so under `dir="rtl"` the finger, the uncovered edge - * and the panel's logical inset agree. + * Under Reduce Motion (`design:reduced-motion`) the finger's own drag is + * direct manipulation and follows it one for one, but nothing a release or + * a close starts travels or springs: the row lands at the bottom of an + * opacity dip (DR2). + * + * Directions are logical: `leading` is uncovered by a drag toward the inline + * end and sits at the inline start, `trailing` the reverse, read against the + * root's computed `direction` when the pan begins, so under `dir="rtl"` the + * finger, the uncovered edge and the panel agree. */ import {useCallback, useEffect, useRef, useState} from 'react'; -import type { - PointerEvent as ReactPointerEvent, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent as ReactMouseEvent, -} from 'react'; +import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; +import { + PanGestureRecognizer, + getGestureHost, + useGestureRecognizer, +} from '../gestures'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {isRtlElement} from '../hooks/isRtlElement'; - -/** The travel, in physical px, written on the row root during a drag. */ -export const SWIPE_TRAVEL_PROPERTY = '--_item-swipe-travel'; -/** +1 when the inline end is to the right (LTR), -1 under RTL. */ -export const SWIPE_DIRECTION_PROPERTY = '--_item-swipe-dir'; -/** The clock a settle runs on; `0s` while the finger drives the row. */ -export const SWIPE_DURATION_PROPERTY = '--_item-swipe-duration'; - -/** Travel before the axis is decided. */ -const AXIS_LOCK_PX = 10; -/** The commit point: this far past the side's panel, or this fraction of the row, whichever is greater. */ -const COMMIT_PAST_PANEL_PX = 48; -const COMMIT_FRACTION = 0.5; -/** Under `reveal`, a release this far into the panel rests the row open. */ -const REST_FRACTION = 0.5; -/** Travel past the commit point moves the row this much per pixel of finger. */ -const OVERSHOOT_RESISTANCE = 0.35; -/** A release at this speed commits from FLING_MIN_PX, short of the commit point. */ -const FLING_VELOCITY_PX_PER_MS = 0.6; -const FLING_MIN_PX = 44; -/** How far past the row's edge a slide-out travels. */ -const SLIDE_OUT_OVERSHOOT_PX = 24; -/** The slide-out after a commit, and the spring back after a release. */ -export const SWIPE_SETTLE_MS = 200; -/** A click the browser synthesizes after a swipe is not a tap on the row. */ -const CLICK_SUPPRESS_MS = 400; +import { + createAnimator, + type Animator, + type MotionHandle, +} from '../motion/animator'; +import type {SpringDescription} from '../motion/spring'; +import {reducedMotionFade} from '../motion/reducedMotion'; +import {swipeActions as SWIPE} from '../motion/uikit/swipeActions'; +import {commitPoint, resistedTravel, resolveRelease} from './swipeRelease'; export type SwipeSide = 'leading' | 'trailing'; export type SwipeBehavior = 'reveal' | 'commit'; @@ -94,11 +105,11 @@ export type SwipePhase = | 'idle' /** The finger drives the row. */ | 'dragging' - /** Springing back or sliding out, on the settle clock. */ + /** Springing to where the release, a tap or a close sent it. */ | 'settling' /** Resting open with the side's panel tappable (`reveal` only). */ | 'resting' - /** Slid out after an entry with `hasRemoval` fired. */ + /** Out, after an entry with `hasRemoval` fired. */ | 'held'; export interface SwipeState { @@ -109,37 +120,34 @@ export interface SwipeState { isArmed: boolean; } +/** Callback refs for the elements of one side's panel that move. */ +export interface SwipePanelRefs { + /** The window over the row's resting box. */ + panel: (element: HTMLElement | null) => void; + /** The fill that moves with the row, clipped by the window. */ + track: (element: HTMLElement | null) => void; + /** + * Each entry, pinned at the outer edge inside the track: the ref for the + * entry with `key` (its React key), stable per key. + */ + entry: (key: string) => (element: HTMLElement | null) => void; +} + export interface UseSwipeActionOptions { - /** Off: the handlers do nothing and no row moves. */ - isEnabled: boolean; behavior: SwipeBehavior; - /** Which sides have entries. A drag toward a side without any is not claimed. */ + /** Which sides have entries. A drag toward a side without any never begins. */ sides: {leading: boolean; trailing: boolean}; - /** The natural width of a side's panel, read when a drag begins. */ - measurePanel: (side: SwipeSide) => number; /** * Fire the side's outermost entry. Returns whether the row holds out * afterwards (the entry's `hasRemoval`). */ fireOutermost: (side: SwipeSide) => boolean; - /** Skip the slide and spring animations. */ - isReducedMotion?: boolean; - /** The row root: the gesture's target and the element that translates. */ - rootRef: React.RefObject; -} - -export interface SwipeHandlers { - onPointerDown: (event: ReactPointerEvent) => void; - onPointerMove: (event: ReactPointerEvent) => void; - onPointerUp: (event: ReactPointerEvent) => void; - onPointerCancel: (event: ReactPointerEvent) => void; - onClickCapture: (event: ReactMouseEvent) => void; - onKeyDown: (event: ReactKeyboardEvent) => void; } export interface UseSwipeActionResult { state: SwipeState; - handlers: SwipeHandlers; + /** Attach each side's moving panel parts. */ + panelRefs: Record; /** * A resting panel's entry was tapped: fire it, then hold the row out * (`hasRemoval`) or close. @@ -147,39 +155,207 @@ export interface UseSwipeActionResult { activateEntry: (fire: () => boolean) => void; /** Close a resting row. */ close: () => void; - /** Whether a click arriving now is the one the browser synthesized after a drag. */ - shouldSuppressClick: () => boolean; + /** Escape closes a resting row with focus inside it. */ + onKeyDown: (event: ReactKeyboardEvent) => void; } -interface DragState { - pointerId: number; - startX: number; - startY: number; - /** The travel the row already had when the finger landed (a resting row). */ - baseTravel: number; - /** null until the axis is decided; false once it went to the scroller. */ - claimed: boolean | null; +// ============================================================================= +// Geometry (pure) +// ============================================================================= + +export interface SwipeLayout { side: SwipeSide; - panelWidth: number; - commitPx: number; - lastX: number; - lastTime: number; - prevX: number; - prevTime: number; - /** Logical travel toward the side, before resistance. */ - travel: number; + /** +1 when the inline end is to the right (LTR), −1 under RTL. */ inlineSign: 1 | -1; + /** The side's entries at their natural widths together: how far the row opens. */ + openWidth: number; +} + +/** +1 when the side's panel is on the physical left, −1 on the right. */ +export function physicalSign(side: SwipeSide, inlineSign: 1 | -1): 1 | -1 { + return side === 'leading' ? inlineSign : inlineSign === 1 ? -1 : 1; +} + +/** + * Where every moving part sits for a travel (logical px toward the side, + * ≥ 0), as a physical translation: the root moves by the travel, the panel + * window cancels it (fixed over the resting box), the track moves with the + * row, and the entries cancel the track (pinned at the outer edge, where the + * track's painted part uncovers them). + */ +export function swipeParts( + travel: number, + {side, inlineSign, openWidth}: SwipeLayout, +): {root: number; panel: number; track: number; entries: number} { + const sign = physicalSign(side, inlineSign); + const x = sign * travel; + return {root: x, panel: -x, track: x, entries: sign * openWidth - x}; } +/** The longest a slide-out takes to leave the row's box (ms): beyond it, never. */ +const SLIDE_OUT_HORIZON_MS = 3000; + +/** + * When `animator`, on the curve it now plays, first carries the row to + * `edge` (physical px) or past it, in ms after `start`: 0 when it is there + * already, null when it never gets there. The animator reads its own curve + * back analytically. + */ +function timeToPass( + animator: Animator, + start: number, + edge: number, +): number | null { + const sign = Math.sign(edge) || 1; + const reached = (t: number) => + animator.get('x', start + t) * sign >= Math.abs(edge); + if (reached(0)) { + return 0; + } + for (let t = 1; t <= SLIDE_OUT_HORIZON_MS; t += 1) { + if (reached(t)) { + return t; + } + } + return null; +} + +/** + * What started a settle: a touch drag ending (its release, or the browser + * taking the touch), or anything else (Escape, a pointer outside, a tapped + * entry, a tap on the content, a scroll). + */ +type SettleCause = 'release' | 'other'; + +// ============================================================================= +// The hook +// ============================================================================= + const IDLE_STATE: SwipeState = {isArmed: false, phase: 'idle', side: 'leading'}; -/** The logical sign of a side: +1 toward the inline end, -1 toward the start. */ -function signOf(side: SwipeSide): number { - return side === 'trailing' ? -1 : 1; +/** Below this the row is at rest, closed. */ +const AT_REST_PX = 0.5; + +interface SideAnimators { + panel: Animator; + track: Animator; + entries: EntryAnimators; +} + +/** + * A side's entries, each its own moving element on the same motion, so they + * move as one block with no wrapper around them. + */ +interface EntryAnimators { + /** The callback ref for the entry with `key`, stable per key. */ + ref: (key: string) => (element: HTMLElement | null) => void; + /** The entries' animators, in the order they mounted. */ + members: () => Animator[]; + /** Where the entries are (they share it); at rest before any has mounted. */ + get: (property: 'x' | 'opacity', time?: number) => number; + set: Animator['set']; + animate: (...args: Parameters) => void; + play: (...args: Parameters) => void; + /** Their natural widths together: how far the row opens. */ + width: () => number; +} + +function createEntries(): EntryAnimators { + const byKey = new Map(); + const refs = new Map void>(); + const any = (): Animator | undefined => byKey.values().next().value; + return { + ref: key => { + let ref = refs.get(key); + if (ref == null) { + ref = element => { + const current = byKey.get(key); + if (element == null) { + current?.dispose(); + byKey.delete(key); + } else if (current != null) { + current.attach(element); + } else { + // A new entry joins the block where the others are. + const animator = createAnimator(null); + const other = any(); + if (other != null) { + animator.set({x: other.get('x'), opacity: other.get('opacity')}); + } + byKey.set(key, animator); + animator.attach(element); + } + }; + refs.set(key, ref); + } + return ref; + }, + members: () => [...byKey.values()], + get: (property, time) => + any()?.get(property, time) ?? (property === 'opacity' ? 1 : 0), + set: values => { + for (const animator of byKey.values()) { + animator.set(values); + } + }, + animate: (...args) => { + for (const animator of byKey.values()) { + animator.animate(...args); + } + }, + play: (...args) => { + for (const animator of byKey.values()) { + animator.play(...args); + } + }, + width: () => { + let width = 0; + for (const animator of byKey.values()) { + width += animator.element?.offsetWidth ?? 0; + } + return width; + }, + }; } -function now(): number { - return typeof performance === 'undefined' ? Date.now() : performance.now(); +interface Animators { + root: Animator; + leading: SideAnimators; + trailing: SideAnimators; +} + +interface Drag { + layout: SwipeLayout; + rowWidth: number; + commitAt: number; + /** Past UIKit's threshold: the row follows the finger. */ + started: boolean; + /** Which way the finger crossed the threshold: +1 toward the side, -1 away. */ + startSign: number; + /** The travel the row had when the swipe began (logical, ≥ 0). */ + baseTravel: number; + /** The finger's travel along the side, before resistance. */ + raw: number; + isArmed: boolean; +} + +function createSide(): SideAnimators { + return { + entries: createEntries(), + panel: createAnimator(null), + track: createAnimator(null), + }; +} + +function allOf(animators: Animators): Animator[] { + return [ + animators.root, + ...[animators.leading, animators.trailing].flatMap(side => [ + side.panel, + side.track, + ...side.entries.members(), + ]), + ]; } /** @@ -202,226 +378,321 @@ function focusRowContent(root: HTMLElement): void { } export function useSwipeAction({ - isEnabled, behavior, sides, - measurePanel, fireOutermost, - isReducedMotion = false, - rootRef, }: UseSwipeActionOptions): UseSwipeActionResult { - const dragRef = useRef(null); - const travelRef = useRef(0); - const inlineSignRef = useRef<1 | -1>(1); - const suppressClickUntilRef = useRef(0); - const settleTimerRef = useRef | null>(null); - const frameRef = useRef(null); - const pendingRef = useRef(null); - /** Removes the document listener that watches for a pinch's second finger. */ - const stopPinchWatchRef = useRef<(() => void) | null>(null); const [state, setState] = useState(IDLE_STATE); const stateRef = useRef(state); - stateRef.current = state; - - // The latest callbacks, read by handlers and timers that outlive a render. - const measurePanelRef = useRef(measurePanel); - const fireOutermostRef = useRef(fireOutermost); - const sidesRef = useRef(sides); - const behaviorRef = useRef(behavior); - const reducedMotionRef = useRef(isReducedMotion); - useEffect(() => { - measurePanelRef.current = measurePanel; - fireOutermostRef.current = fireOutermost; - sidesRef.current = sides; - behaviorRef.current = behavior; - reducedMotionRef.current = isReducedMotion; + // Synchronizes the gesture system with the committed state: its recognizer + // callbacks and the document listeners run outside React and read it here. + // Idempotent: it rewrites the ref with the committed value. + useIsomorphicLayoutEffect(() => { + stateRef.current = state; + }); + const setSwipeState = useCallback((next: SwipeState) => { + stateRef.current = next; + setState(next); + }, []); + + // The latest options, read by listeners and completions that outlive a render. + // Synchronizes the gesture system (recognizer callbacks, animation + // completions, the slide-out's fire timer) with the committed options. + const latestRef = useRef({behavior, fireOutermost, sides}); + useIsomorphicLayoutEffect(() => { + latestRef.current = {behavior, fireOutermost, sides}; }); + const [animators] = useState(() => ({ + leading: createSide(), + root: createAnimator(null), + trailing: createSide(), + })); + /** The layout of the side that moved last: where the row is, and how to move it. */ + const layoutRef = useRef(null); + const dragRef = useRef(null); + /** + * Counts the row's motions: a timer started for one motion acts only if + * no other motion has started since (a finger caught the row, a close). + */ + const motionRef = useRef(0); + /** The root's motion in progress, when a spring drives it. */ + const rootMotionRef = useRef(null); + /** That motion once it has started on screen (its `started` resolved). */ + const startedMotionRef = useRef(null); + const fireTimerRef = useRef | null>(null); + // Releases the browser timer a slide-out set for its fire when the row + // leaves, and drops a fire still waiting for its slide-out to start. useEffect( () => () => { - if (settleTimerRef.current != null) { - clearTimeout(settleTimerRef.current); - } - if (frameRef.current != null) { - cancelAnimationFrame(frameRef.current); + motionRef.current += 1; + if (fireTimerRef.current != null) { + clearTimeout(fireTimerRef.current); } - stopPinchWatchRef.current?.(); - stopPinchWatchRef.current = null; }, [], ); - const stopWatchingForPinch = useCallback(() => { - stopPinchWatchRef.current?.(); - stopPinchWatchRef.current = null; - }, []); + // The row root: the gesture's target and the element that translates. A + // panel is its child, and a panel's ref is set before this hook's layout + // effects run, while a ref on the root itself is set after them. + const rootRef = useRef(null); + // The parts attach as they mount (a side's panel exists only while it has + // entries); attaching writes where the part is now. + const [panelRefs] = useState>(() => { + const refsFor = (side: SideAnimators): SwipePanelRefs => ({ + entry: key => side.entries.ref(key), + panel: element => { + if (element?.parentElement != null) { + rootRef.current = element.parentElement; + } + side.panel.attach(element); + }, + track: element => side.track.attach(element), + }); + return { + leading: refsFor(animators.leading), + trailing: refsFor(animators.trailing), + }; + }); + useIsomorphicLayoutEffect(() => { + const root = rootRef.current; + animators.root.attach(root); + return () => { + for (const animator of allOf(animators)) { + animator.dispose(); + } + // The root outlives the layer: give it back its own translation. + root?.style.removeProperty('translate'); + }; + }, [animators, rootRef]); + + /** The row's travel on screen now, logical, on `layout`'s side. */ + const travelNow = useCallback( + (layout: SwipeLayout): number => + Math.max( + 0, + animators.root.get('x') * physicalSign(layout.side, layout.inlineSign), + ), + [animators], + ); /** - * Until the axis is decided, a second finger anywhere on the page makes - * the touch a pinch: the row gives it up and cancels nothing, so the - * browser zooms. The finger can land off the row, so the document listens. + * Spring every part of the side to `travel` on one clock, from the + * presentation values and velocities. Resolves with the root's completion. */ - const watchForPinch = useCallback( - (drag: DragState) => { - stopWatchingForPinch(); - const onAnyPointerDown = (event: PointerEvent) => { - if ( - event.pointerType === 'touch' && - event.pointerId !== drag.pointerId && - drag.claimed === null - ) { - drag.claimed = false; - stopWatchingForPinch(); + const springTo = useCallback( + async ( + layout: SwipeLayout, + travel: number, + spring: SpringDescription, + // A settle a touch drag's release started: under Reduce Motion it + // cross-fades whatever the pointer (design:reduced-motion DR2). + touchRelease = false, + ): Promise => { + layoutRef.current = layout; + motionRef.current += 1; + const parts = swipeParts(travel, layout); + const side = animators[layout.side]; + // One clock for every part. From rest no start is pinned: every call + // from rest in this task starts when the browser commits it, together, + // so a frame held back (iOS during a smooth scroll) delays the settle + // instead of skipping into it. A call that continues motion on screen + // (the next move's spring, the release, a catch: the root's motion has + // started and not ended) starts at its own call time unless it is given + // one, and the parts would fall out of step by the time each call + // takes: they share one start (`at`). + const previous = rootMotionRef.current; + const isMoving = + previous != null && + startedMotionRef.current === previous && + (previous.velocity('x') !== 0 || + previous.value('x') !== previous.value('x', Infinity)); + const options = isMoving + ? {at: performance.now(), touchRelease} + : {touchRelease}; + const handle = animators.root.animate({x: parts.root}, {spring}, options); + rootMotionRef.current = handle; + // On screen from its start: a call from rest waits for the browser's + // commit, so until then the next call is from rest too. + void handle.started.then(() => { + if (rootMotionRef.current === handle) { + startedMotionRef.current = handle; } - }; - document.addEventListener('pointerdown', onAnyPointerDown, { - capture: true, - passive: true, }); - stopPinchWatchRef.current = () => { - document.removeEventListener('pointerdown', onAnyPointerDown, { - capture: true, - }); - }; + side.panel.animate({x: parts.panel}, {spring}, options); + side.track.animate({x: parts.track}, {spring}, options); + side.entries.animate({x: parts.entries}, {spring}, options); + return handle.finished; }, - [stopWatchingForPinch], + [animators], ); - /** Write the travel (physical px) the transforms read. */ - const writeTravel = useCallback( - (travel: number) => { - const root = rootRef.current; - if (!root) { - return; - } - travelRef.current = travel; - root.style.setProperty(SWIPE_TRAVEL_PROPERTY, `${travel}px`); - }, - [rootRef], - ); - - const writeDuration = useCallback( - (ms: number) => { - const root = rootRef.current; - if (!root) { - return; - } - if (ms > 0) { - root.style.setProperty(SWIPE_DURATION_PROPERTY, `${ms}ms`); - } else { - root.style.setProperty(SWIPE_DURATION_PROPERTY, '0s'); - } - }, - [rootRef], - ); - - const clearInlineProperties = useCallback(() => { - const root = rootRef.current; - if (!root) { - return; - } - root.style.removeProperty(SWIPE_TRAVEL_PROPERTY); - root.style.removeProperty(SWIPE_DIRECTION_PROPERTY); - root.style.removeProperty(SWIPE_DURATION_PROPERTY); - travelRef.current = 0; - }, [rootRef]); - - const cancelPendingPaint = useCallback(() => { - if (frameRef.current != null) { - cancelAnimationFrame(frameRef.current); - } - frameRef.current = null; - pendingRef.current = null; - }, []); - - /** One write per frame while the finger drives the row. */ - const paintOnNextFrame = useCallback( - (travel: number) => { - pendingRef.current = travel; - if (frameRef.current != null) { - return; - } - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - const next = pendingRef.current; - pendingRef.current = null; - if (next != null) { - writeTravel(next); - } - }); + /** + * Every part of the side at `travel` now: the row following the finger one + * for one under Reduce Motion. + */ + const moveTo = useCallback( + (layout: SwipeLayout, travel: number) => { + layoutRef.current = layout; + motionRef.current += 1; + rootMotionRef.current = null; + const parts = swipeParts(travel, layout); + const side = animators[layout.side]; + animators.root.set({x: parts.root}); + side.panel.set({x: parts.panel}); + side.track.set({x: parts.track}); + side.entries.set({x: parts.entries}); }, - [writeTravel], + [animators], ); /** - * Move the row to `travel` on the settle clock, then land in `phase`. A - * zero clock (reduced motion) lands at once. + * Settle the row at `travel` and land in `phase`. The landing is skipped + * when something interrupts the settle (a finger catches the row, another + * settle starts). */ const settle = useCallback( ( - side: SwipeSide, + layout: SwipeLayout, travel: number, phase: 'idle' | 'resting' | 'held', - afterSlide?: () => boolean, + afterSettle?: () => boolean, + cause: SettleCause = 'other', ) => { - cancelPendingPaint(); - const duration = reducedMotionRef.current ? 0 : SWIPE_SETTLE_MS; - setState({isArmed: false, phase: 'settling', side}); - writeDuration(duration); - writeTravel(travel); - if (settleTimerRef.current != null) { - clearTimeout(settleTimerRef.current); - } - settleTimerRef.current = setTimeout(() => { - settleTimerRef.current = null; - writeDuration(0); + setSwipeState({isArmed: false, phase: 'settling', side: layout.side}); + // Under Reduce Motion the animator cross-fades or lands at once + // (design:reduced-motion); what a release started always cross-fades. + const touchRelease = cause === 'release'; + const motion = springTo(layout, travel, SWIPE.settle, touchRelease); + void motion.then(finished => { + if (!finished) { + return; + } // A settle that fires an entry may start the spring back itself; the // landing below is then that settle's to make. - if (afterSlide?.() === true) { + if (afterSettle?.() === true) { return; } - if (phase === 'idle') { - clearInlineProperties(); - } - setState(phase === 'idle' ? IDLE_STATE : {isArmed: false, phase, side}); - }, duration); + setSwipeState( + phase === 'idle' + ? IDLE_STATE + : {isArmed: false, phase, side: layout.side}, + ); + }); }, - [cancelPendingPaint, clearInlineProperties, writeDuration, writeTravel], + [setSwipeState, springTo], ); - /** - * The outermost entry fires. Under both models the row first travels to - * its slide-out, fires, then holds out (`hasRemoval`) or springs back. - */ + /** How far out a row goes when an entry fires and the row leaves. */ + const outTravel = useCallback( + () => (rootRef.current?.clientWidth ?? 0) + SWIPE.slideOutOvershoot, + [rootRef], + ); + + /** The outermost entry fires as the row slides out; then it holds out or springs back. */ const slideOutAndFire = useCallback( - (side: SwipeSide, inlineSign: 1 | -1) => { - const root = rootRef.current; - const width = root?.clientWidth ?? 0; - const out = signOf(side) * inlineSign * (width + SLIDE_OUT_OVERSHOOT_PX); - settle(side, out, 'held', () => { - const holdsOut = fireOutermostRef.current(side); + (layout: SwipeLayout, cause: SettleCause) => { + const fire = () => { + const holdsOut = latestRef.current.fireOutermost(layout.side); if (holdsOut) { return false; } - settle(side, 0, 'idle'); + settle(layout, 0, 'idle', undefined, cause); return true; + }; + // UIKit calls the handler at the lift. The row fires once its content + // has left its box, so a row its app removes at once never vanishes + // mid-slide; at once when the release is already past that edge. + const sign = physicalSign(layout.side, layout.inlineSign); + const edge = sign * (rootRef.current?.clientWidth ?? 0); + settle(layout, outTravel(), 'held', undefined, cause); + const motion = motionRef.current; + const handle = rootMotionRef.current; + const fade = reducedMotionFade(rootRef.current, { + touchRelease: cause === 'release', + }); + // One pending fire at a time: a new slide-out replaces an older one's. + if (fireTimerRef.current != null) { + clearTimeout(fireTimerRef.current); + fireTimerRef.current = null; + } + let timer: ReturnType | null = null; + const fireIfStill = () => { + if (fireTimerRef.current === timer) { + fireTimerRef.current = null; + } + if (motionRef.current === motion) { + fire(); + } + }; + if ( + fade == null && + timeToPass(animators.root, performance.now(), edge) === 0 + ) { + fireIfStill(); + return; + } + // Timed from when the slide-out starts on screen: one from rest waits + // for the browser's first frame (longer on a busy page), and until then + // the row is where it was. Under Reduce Motion the row never travels: + // it lands out of its box at the bottom of the cross-fade, and fires + // there; otherwise when the curve the animator plays carries it past + // the edge, read back from the animator itself. + void handle?.started.then(startedAt => { + if (motionRef.current !== motion) { + return; + } + const delay = + fade != null + ? fade.duration / 2 + : timeToPass(animators.root, startedAt, edge); + if (delay == null) { + return; + } + const wait = Math.max(0, startedAt + delay - performance.now()); + if (wait === 0) { + fireIfStill(); + return; + } + timer = setTimeout(fireIfStill, wait); + fireTimerRef.current = timer; }); }, - [rootRef, settle], + [animators, outTravel, rootRef, settle], ); const close = useCallback(() => { - const current = stateRef.current; - if (current.phase !== 'resting') { + const layout = layoutRef.current; + if (stateRef.current.phase !== 'resting' || layout == null) { return; } const root = rootRef.current; if (root != null && root.contains(document.activeElement)) { focusRowContent(root); } - settle(current.side, 0, 'idle'); + settle(layout, 0, 'idle'); }, [rootRef, settle]); + const activateEntry = useCallback( + (fire: () => boolean) => { + const layout = layoutRef.current; + if (stateRef.current.phase !== 'resting' || layout == null) { + return; + } + const root = rootRef.current; + if (root != null && root.contains(document.activeElement)) { + focusRowContent(root); + } + if (fire()) { + settle(layout, outTravel(), 'held'); + } else { + settle(layout, 0, 'idle'); + } + }, + [outTravel, rootRef, settle], + ); + // A resting row closes on a pointer landing anywhere outside it, which is // also how a drag on a neighbour closes it: one row rests open at a time // with no shared state. @@ -445,257 +716,206 @@ export function useSwipeAction({ }; }, [close, rootRef, state.phase]); - // The browser's half of the claim (see the header). React's own touch - // listeners are passive, so this one is attached by hand; it is removed - // with the element. - useEffect(() => { + // --------------------------------------------------------------------------- + // The pan + + /** The side a drag from a closed row uncovers: toward the inline end is leading. */ + const sideFor = (dx: number, inlineSign: 1 | -1): SwipeSide => + dx * inlineSign > 0 ? 'leading' : 'trailing'; + + // Two thresholds. The touch is claimed from the browser at `claim` px of + // sideways travel, before a browser commits to scrolling it; the swipe + // itself begins where UIKit's does, at `hysteresis` px, and starts from 0 + // there. In between the row does whatever it was doing (resting, + // settling) and the browser does nothing. + const begin = (pan: PanGestureRecognizer) => { const root = rootRef.current; - if (root == null || !isEnabled) { - return undefined; + if (root == null) { + return; } - const onTouchMove = (event: TouchEvent) => { - if (dragRef.current?.claimed === true && event.cancelable) { - event.preventDefault(); - } + const inlineSign: 1 | -1 = isRtlElement(root) ? -1 : 1; + const x = animators.root.get('x'); + // An open or moving row continues on its side (closing it or going + // further); a closed one takes the side the finger moves toward. + const side: SwipeSide = + Math.abs(x) > AT_REST_PX + ? x * inlineSign > 0 + ? 'leading' + : 'trailing' + : sideFor(pan.totalTranslation().x, inlineSign); + + const layout: SwipeLayout = { + inlineSign, + openWidth: animators[side].entries.width(), + side, }; - root.addEventListener('touchmove', onTouchMove, {passive: false}); - return () => { - root.removeEventListener('touchmove', onTouchMove); + const rowWidth = root.clientWidth; + // The entries keep their place against the track only for the width they + // were placed with; a first drag (or new entries) puts them in place now, + // while the track still hides them. + const sideAnimators = animators[side]; + const expected = swipeParts(travelNow(layout), layout).entries; + if (Math.abs(sideAnimators.entries.get('x') - expected) > AT_REST_PX) { + sideAnimators.entries.set({x: expected}); + } + dragRef.current = { + baseTravel: 0, + commitAt: commitPoint(layout.openWidth, rowWidth), + isArmed: false, + layout, + raw: 0, + rowWidth, + started: false, + startSign: 1, }; - }, [isEnabled, rootRef]); + track(pan); + }; - const activateEntry = useCallback( - (fire: () => boolean) => { - const current = stateRef.current; - if (current.phase !== 'resting') { + const track = (pan: PanGestureRecognizer) => { + const drag = dragRef.current; + if (drag == null) { + return; + } + const {layout} = drag; + // The finger's travel toward the side since it landed. + const along = + pan.totalTranslation().x * physicalSign(layout.side, layout.inlineSign); + // UIKit's dead zone: the swipe counts from where the finger crosses it. + const threshold = SWIPE.hysteresis; + if (!drag.started) { + if (Math.abs(along) < threshold) { return; } - const root = rootRef.current; - if (root != null && root.contains(document.activeElement)) { - focusRowContent(root); - } - const holdsOut = fire(); - if (holdsOut) { - const width = root?.clientWidth ?? 0; - const out = - signOf(current.side) * - inlineSignRef.current * - (width + SLIDE_OUT_OVERSHOOT_PX); - settle(current.side, out, 'held'); - } else { - settle(current.side, 0, 'idle'); - } - }, - [rootRef, settle], - ); + // The swipe begins: the row is caught where it is, and the tracking + // spring takes over from its presentation value and velocity. + drag.started = true; + drag.startSign = Math.sign(along); + drag.baseTravel = travelNow(layout); + drag.isArmed = drag.baseTravel >= drag.commitAt; + setSwipeState({ + isArmed: drag.isArmed, + phase: 'dragging', + side: layout.side, + }); + } + // The threshold's travel is not part of the swipe, as in UIKit: the + // swipe counts from where the finger crossed it. + const swipe = along - drag.startSign * threshold; + const raw = Math.max(0, drag.baseTravel + swipe); + drag.raw = raw; + const isArmed = raw >= drag.commitAt; + if (isArmed !== drag.isArmed) { + drag.isArmed = isArmed; + setSwipeState({isArmed, phase: 'dragging', side: layout.side}); + } + const travel = resistedTravel(raw, drag.commitAt); + // Under Reduce Motion every animated change fades, so the row follows the + // finger one for one instead: the finger's own drag is direct + // manipulation (design:reduced-motion). + if (animators.root.reducedMotion() === 'off') { + void springTo(layout, travel, SWIPE.tracking); + } else { + moveTo(layout, travel); + } + }; - const onPointerDown = useCallback( - (event: ReactPointerEvent) => { - const current = stateRef.current; - const inFlight = dragRef.current; - if (inFlight != null) { - // A second finger before the axis is decided: a pinch, not a swipe. - if ( - event.pointerType === 'touch' && - event.pointerId !== inFlight.pointerId && - inFlight.claimed === null - ) { - inFlight.claimed = false; - stopWatchingForPinch(); - } - return; - } - if ( - !isEnabled || - event.pointerType !== 'touch' || - // Another finger is already down: this one is part of a pinch. - event.isPrimary === false || - current.phase === 'settling' || - current.phase === 'held' - ) { - return; - } - const root = rootRef.current; - const inlineSign: 1 | -1 = isRtlElement(root) ? -1 : 1; - inlineSignRef.current = inlineSign; - const startTime = now(); - const drag: DragState = { - // Logical: the physical travel through the inline sign. - baseTravel: travelRef.current * inlineSign, - claimed: null, - commitPx: 0, - inlineSign, - lastTime: startTime, - lastX: event.clientX, - panelWidth: 0, - pointerId: event.pointerId, - prevTime: startTime, - prevX: event.clientX, - side: current.phase === 'resting' ? current.side : 'leading', - startX: event.clientX, - startY: event.clientY, - travel: 0, - }; - dragRef.current = drag; - watchForPinch(drag); - }, - [isEnabled, rootRef, stopWatchingForPinch, watchForPinch], - ); + const release = (pan: PanGestureRecognizer) => { + const drag = dragRef.current; + dragRef.current = null; + if (drag == null) { + return; + } + // A claimed touch that never became a swipe changed nothing: the row + // carries on as it is, a settle under it landing as it would have. + if (!drag.started) { + return; + } + const {layout} = drag; + const velocity = + pan.velocity().x * physicalSign(layout.side, layout.inlineSign); + const landing = resolveRelease({ + behavior: latestRef.current.behavior, + commitAt: drag.commitAt, + openWidth: layout.openWidth, + travel: drag.raw, + velocity, + }); + if (landing === 'perform') { + slideOutAndFire(layout, 'release'); + } else if (landing === 'open') { + settle(layout, layout.openWidth, 'resting', undefined, 'release'); + } else { + settle(layout, 0, 'idle', undefined, 'release'); + } + }; - const onPointerMove = useCallback( - (event: ReactPointerEvent) => { - const drag = dragRef.current; - if ( - drag == null || - drag.pointerId !== event.pointerId || - drag.claimed === false - ) { - return; - } - // Logical travel: positive toward the inline end. - const deltaX = (event.clientX - drag.startX) * drag.inlineSign; - const deltaY = event.clientY - drag.startY; - if (drag.claimed === null) { - if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < AXIS_LOCK_PX) { - return; - } - // The axis is decided now, whichever way: a later finger is the - // swipe's to ignore or the scroller's to use. - stopWatchingForPinch(); - // More horizontal than vertical is the row's, the rest the scroller's. - if (Math.abs(deltaX) <= Math.abs(deltaY)) { - drag.claimed = false; - return; - } - const resting = stateRef.current.phase === 'resting'; - // From rest, the drag continues the open side (closing it or going - // further); from closed, the drag's direction names the side. - const side: SwipeSide = resting - ? drag.side - : deltaX > 0 - ? 'leading' - : 'trailing'; - if (!sidesRef.current[side]) { - drag.claimed = false; - return; - } - drag.claimed = true; - drag.side = side; - drag.panelWidth = measurePanelRef.current(side); - const width = rootRef.current?.clientWidth ?? 0; - drag.commitPx = Math.max( - drag.panelWidth + COMMIT_PAST_PANEL_PX, - width * COMMIT_FRACTION, - ); - if (rootRef.current != null) { - rootRef.current.style.setProperty( - SWIPE_DIRECTION_PROPERTY, - String(drag.inlineSign), - ); - } - writeDuration(0); - setState({isArmed: false, phase: 'dragging', side}); - try { - rootRef.current?.setPointerCapture(event.pointerId); - } catch { - // Pointer capture is a nicety (keeps the drag when the finger - // leaves the row); a platform without it still drags. - } - } - const sign = signOf(drag.side); - // Travel toward the side, counted from where the row already was. - const raw = Math.max(0, deltaX * sign + drag.baseTravel * sign); - const offset = - raw <= drag.commitPx - ? raw - : drag.commitPx + (raw - drag.commitPx) * OVERSHOOT_RESISTANCE; - const isArmed = raw >= drag.commitPx; - if (isArmed !== drag.travel >= drag.commitPx) { - setState({isArmed, phase: 'dragging', side: drag.side}); - } - drag.travel = raw; - drag.prevX = drag.lastX; - drag.prevTime = drag.lastTime; - drag.lastX = event.clientX; - drag.lastTime = now(); - paintOnNextFrame(sign * drag.inlineSign * offset); - }, - [paintOnNextFrame, rootRef, stopWatchingForPinch, writeDuration], - ); + const cancel = () => { + const drag = dragRef.current; + dragRef.current = null; + if (drag == null) { + return; + } + if (!drag.started) { + return; + } + settle(drag.layout, 0, 'idle', undefined, 'release'); + }; - const endGesture = useCallback( - (event: ReactPointerEvent, cancelled: boolean) => { - const drag = dragRef.current; - if (drag == null || drag.pointerId !== event.pointerId) { - return; - } - dragRef.current = null; - stopWatchingForPinch(); - if (drag.claimed !== true) { - return; - } - suppressClickUntilRef.current = now() + CLICK_SUPPRESS_MS; - try { - rootRef.current?.releasePointerCapture(event.pointerId); - } catch { - // See setPointerCapture above. + useGestureRecognizer( + rootRef, + () => + new PanGestureRecognizer({ + allowedPointerTypes: ['touch'], + direction: 'horizontal', + hysteresis: SWIPE.claim, + maximumNumberOfTouches: 1, + name: 'Item.swipe', + shouldBegin: pan => { + // A finger that is not the only one down is part of a pinch, even + // when the first finger is off the row: the page zooms + // (spec:AST-094 FR10). + const doc = rootRef.current?.ownerDocument ?? document; + if (getGestureHost(doc).fingersDown() > 1) { + return false; + } + if (stateRef.current.phase === 'held') { + return false; + } + if (Math.abs(animators.root.get('x')) > AT_REST_PX) { + return true; + } + const root = rootRef.current; + const inlineSign: 1 | -1 = isRtlElement(root) ? -1 : 1; + return latestRef.current.sides[ + sideFor(pan.totalTranslation().x, inlineSign) + ]; + }, + }), + pan => { + switch (pan.state) { + case 'began': + begin(pan); + break; + case 'changed': + track(pan); + break; + case 'ended': + release(pan); + break; + case 'cancelled': + cancel(); + break; + case 'possible': + case 'failed': + break; } - const {side, inlineSign} = drag; - const restTravel = signOf(side) * inlineSign * drag.panelWidth; - if (cancelled) { - settle(side, 0, 'idle'); - return; - } - const elapsed = Math.max(1, drag.lastTime - drag.prevTime); - const velocity = - (signOf(side) * inlineSign * (drag.lastX - drag.prevX)) / elapsed; - const flung = - velocity >= FLING_VELOCITY_PX_PER_MS && drag.travel >= FLING_MIN_PX; - if (drag.travel >= drag.commitPx || flung) { - slideOutAndFire(side, inlineSign); - return; - } - if ( - behaviorRef.current === 'reveal' && - drag.travel >= drag.panelWidth * REST_FRACTION - ) { - settle(side, restTravel, 'resting'); - return; - } - settle(side, 0, 'idle'); }, - [rootRef, settle, slideOutAndFire, stopWatchingForPinch], + // `spec:AST-057` reads each event's pointer: a finger swipes the row on + // every device and a mouse never does, so the host's fine-pointer gate + // (`spec:AST-094` FR19) does not apply to it. + {perEventPointer: true}, ); - const onPointerUp = useCallback( - (event: ReactPointerEvent) => endGesture(event, false), - [endGesture], - ); - const onPointerCancel = useCallback( - (event: ReactPointerEvent) => endGesture(event, true), - [endGesture], - ); - const shouldSuppressClick = useCallback( - () => now() < suppressClickUntilRef.current, - [], - ); - const onClickCapture = useCallback( - (event: ReactMouseEvent) => { - // A tap on a resting panel's entry is a tap, however soon after the - // drag that opened it; the synthesized click lands on the row's content. - if ( - !shouldSuppressClick() || - (event.target instanceof Element && - event.target.closest('[data-swipe-panel]') != null) - ) { - return; - } - suppressClickUntilRef.current = 0; - event.preventDefault(); - event.stopPropagation(); - }, - [shouldSuppressClick], - ); const onKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.key === 'Escape' && stateRef.current.phase === 'resting') { @@ -706,18 +926,5 @@ export function useSwipeAction({ [close], ); - return { - activateEntry, - close, - handlers: { - onClickCapture, - onKeyDown, - onPointerCancel, - onPointerDown, - onPointerMove, - onPointerUp, - }, - shouldSuppressClick, - state, - }; + return {activateEntry, close, onKeyDown, panelRefs, state}; } diff --git a/packages/core/src/List/List.test.tsx b/packages/core/src/List/List.test.tsx index 8ed1f09f40b38..ae53311dfbc5c 100644 --- a/packages/core/src/List/List.test.tsx +++ b/packages/core/src/List/List.test.tsx @@ -9,10 +9,11 @@ * SYNC: When modified, update this header */ -import {describe, it, expect, vi} from 'vitest'; -import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {afterEach, beforeEach, describe, it, expect, vi} from 'vitest'; +import {cleanup, render, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {rulesDeclaredFor} from '../__tests__/pressState'; +import {emulateTouch} from '../Item/__tests__/emulateTouch'; import {List} from './List'; import {ListItem} from './ListItem'; import {ListMarkerScope} from './ListContext'; @@ -1013,6 +1014,14 @@ describe('List', () => { }); describe('List and swipe actions', () => { + // The suite answers as a phone (a coarse primary pointer). + beforeEach(() => { + emulateTouch(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('clips the rows in the inline axis with clip, never hidden, so a row dragged aside paints no further than the list', () => { render( @@ -1026,7 +1035,7 @@ describe('List and swipe actions', () => { expect(rules.some(r => /overflow[-a-z]*: hidden/.test(r))).toBe(false); }); - it('passes swipeActions and swipeBehavior through to the row unchanged', async () => { + it('passes swipeActions and swipeBehavior through to the row unchanged', () => { render( { const row = screen.getByTestId('row'); expect(row.tagName).toBe('LI'); expect(row.parentElement).toBe(screen.getByRole('list')); - // The panel arrives with the lazily loaded swipe layer. - const panel = await waitFor(() => { - const found = row.querySelector('[data-swipe-panel="trailing"]'); - expect(found).not.toBeNull(); - return found; - }); + // The panel is there at the first render: the layer comes with the row. + const panel = row.querySelector('[data-swipe-panel="trailing"]'); + expect(panel).not.toBeNull(); // Commit: presentational. expect(panel).toHaveAttribute('aria-hidden', 'true'); }); diff --git a/packages/core/src/__tests__/internalModulesNotExperimental.test.ts b/packages/core/src/__tests__/internalModulesNotExperimental.test.ts new file mode 100644 index 0000000000000..85fe3a821d9f4 --- /dev/null +++ b/packages/core/src/__tests__/internalModulesNotExperimental.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file internalModulesNotExperimental.test.ts + * @input vitest, the source of core's internal motion, gesture and keyboard + * modules, and of the package-private utility module (touchPresentation) + * @output Fails when one of them declares `@experimental`: those modules are + * internal and never exported (spec:AST-017 FR21, DEC-27), and the tag + * belongs only to a deliberately experimental public API + * @position Testing; a repository guard over src/motion, src/gestures, + * src/keyboard and src/utils/touchPresentation.ts. The rest of src/utils + * backs the public `@astryxdesign/core/utils` entry, where an experimental + * API may carry the tag + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +const SRC = path.resolve(__dirname, '..'); +const INTERNAL_DIRS = ['motion', 'gestures', 'keyboard']; +/** Package-private files in a directory that also backs a public entry. */ +const INTERNAL_FILES = ['utils/touchPresentation.ts']; + +/** Every source file under `dir`, tests and declarations left out. */ +function sourcesUnder(dir: string): string[] { + const files: string[] = []; + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__') { + files.push(...sourcesUnder(full)); + } + } else if ( + /\.(ts|tsx|mjs|js)$/.test(entry.name) && + !/\.(test|spec)\.[cm]?[jt]sx?$/.test(entry.name) && + !entry.name.endsWith('.d.ts') + ) { + files.push(full); + } + } + return files; +} + +/** The internal files the guard reads, relative to src. */ +function checkedFiles(): string[] { + const files: string[] = []; + for (const dir of INTERNAL_DIRS) { + const root = path.join(SRC, dir); + if (fs.existsSync(root)) { + files.push(...sourcesUnder(root)); + } + } + files.push(...INTERNAL_FILES.map(file => path.join(SRC, file))); + return files.map(file => path.relative(SRC, file)); +} + +describe('internal modules', () => { + it('declare no @experimental: only an experimental public API carries it', () => { + const offenders: string[] = []; + for (const file of checkedFiles()) { + fs.readFileSync(path.join(SRC, file), 'utf8') + .split('\n') + .forEach((line, index) => { + if (/@experimental\b/.test(line)) { + offenders.push(`${file}:${index + 1}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it('checks the directories and files it names', () => { + expect(fs.existsSync(path.join(SRC, 'motion'))).toBe(true); + expect(fs.existsSync(path.join(SRC, 'gestures'))).toBe(true); + expect(sourcesUnder(path.join(SRC, 'motion')).length).toBeGreaterThan(5); + for (const file of INTERNAL_FILES) { + expect(fs.existsSync(path.join(SRC, file)), file).toBe(true); + } + }); + + it('leaves the public utils entry alone: of src/utils, only its package-private files are checked', () => { + const utils = checkedFiles().filter(file => + file.startsWith('utils' + path.sep), + ); + expect(utils).toEqual([path.join('utils', 'touchPresentation.ts')]); + }); +}); diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index 3acc3edc83c66..ea4fbb22527f3 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -58,6 +58,7 @@ import type { TouchSample, } from './types'; import {DEFAULT_ENVIRONMENT} from './types'; +import {internals} from './recognizerInternals'; export interface GestureClock { now(): number; @@ -119,10 +120,15 @@ interface Timer { callback: () => void; } +/** + * The arena that decides which recognizer gets a touch. + */ export class GestureArena { readonly environment: GestureEnvironment; private readonly clock: GestureClock; private readonly options: GestureArenaOptions; + /** The touches whose move the arena is delivering right now. */ + private movingIds: ReadonlySet = new Set(); private readonly records = new Map(); /** Recognizers involved in the current interaction, in resolution order. */ private active: GestureRecognizer[] = []; @@ -134,6 +140,10 @@ export class GestureArena { /** The latest time processed: input never moves the clock backwards. */ private latestTime = Number.NEGATIVE_INFINITY; private depth = 0; + /** Open `deferResolution` calls: what their stops unblock waits. */ + private deferDepth = 0; + /** A deferred stop left a resolution for the next pass. */ + private owesResolution = false; constructor(options: GestureArenaOptions = {}) { this.options = options; @@ -153,11 +163,25 @@ export class GestureArena { return this.records.get(id)?.isClaimed === true; } + /** @internal The view-level consumer of this touch, if any. */ + consumerOf(id: number): TouchConsumer | null { + return this.records.get(id)?.consumer ?? null; + } + /** The live touch with this id. */ getTouch(id: number): GestureTouch | undefined { return this.records.get(id)?.touch; } + /** + * The recognizers that took touch `id` when it began: each was enabled, + * accepted its pointer, and its `shouldReceiveTouch` agreed. Empty for a + * touch the arena does not track. + */ + participantsOf(id: number): ReadonlyArray { + return this.records.get(id)?.participants ?? []; + } + /** Every touch the arena tracks, lifted ones awaiting a decision included. */ get touches(): ReadonlyArray { return [...this.records.values()].map(record => record.touch); @@ -198,14 +222,15 @@ export class GestureArena { target: input.target, scroll: input.scroll ?? null, takenByBrowser: false, + ...(input.isFeedbackOnly === true ? {isFeedbackOnly: true} : {}), }; const participants: GestureRecognizer[] = []; for (const [depth, recognizer] of input.recognizers.entries()) { - if (!recognizer.wantsTouch(touch)) { + if (!internals(recognizer).wantsTouch(touch)) { continue; } participants.push(recognizer); - recognizer.arena = this; + internals(recognizer).arena = this; if (!this.active.includes(recognizer)) { // Resolution follows `active`, so a recognizer joins ahead of any // that sits further out on this touch's path: when two recognize @@ -241,7 +266,7 @@ export class GestureArena { for (const recognizer of [...this.active]) { const touches = newTouches.get(recognizer); if (touches != null) { - recognizer.deliverBegan(touches, context); + internals(recognizer).deliverBegan(touches, context); } } // A recognizer already tracking a gesture hears of a touch that began @@ -253,7 +278,7 @@ export class GestureArena { .map(record => record.touch) .filter(touch => own == null || !own.includes(touch)); if (elsewhere.length > 0 && recognizer.numberOfTouches > 0) { - recognizer.deliverBeganElsewhere(elsewhere, context); + internals(recognizer).deliverBeganElsewhere(elsewhere, context); } } for (const record of created) { @@ -262,6 +287,41 @@ export class GestureArena { }); } + /** + * A contact began where nothing takes part (plain page content). It is + * never tracked and stays the browser's; recognizers already tracking a + * gesture hear of it, as of any touch off their hit path. + */ + untrackedBegan(input: TouchBeganInput): void { + if (!this.active.some(recognizer => recognizer.numberOfTouches > 0)) { + return; + } + this.run(input.time, () => { + const time = this.clampTime(input.time); + const touch: GestureTouch = { + id: input.id, + pointerType: input.pointerType, + phase: 'began', + x: input.x, + y: input.y, + time, + startX: input.x, + startY: input.y, + startTime: time, + samples: [{x: input.x, y: input.y, time}], + target: input.target, + scroll: input.scroll ?? null, + takenByBrowser: false, + }; + const context = this.context(); + for (const recognizer of [...this.active]) { + if (recognizer.numberOfTouches > 0) { + internals(recognizer).deliverBeganElsewhere([touch], context); + } + } + }); + } + /** Contacts moved. */ moved(inputs: ReadonlyArray): void { if (inputs.length === 0) { @@ -273,8 +333,14 @@ export class GestureArena { return; } const context = this.context(); - for (const recognizer of [...this.active]) { - recognizer.deliverMoved(changed, context); + const previous = this.movingIds; + this.movingIds = new Set(changed.map(touch => touch.id)); + try { + for (const recognizer of [...this.active]) { + internals(recognizer).deliverMoved(changed, context); + } + } finally { + this.movingIds = previous; } for (const touch of changed) { const record = this.records.get(touch.id); @@ -298,7 +364,7 @@ export class GestureArena { const changed = this.update(inputs, 'ended'); const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverEnded(changed, context); + internals(recognizer).deliverEnded(changed, context); } // A lift can decide a waiting recognition (its requirement lifted // undecided and failed): settle those first, so each click's answer is @@ -325,7 +391,7 @@ export class GestureArena { } const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverCancelled(touches, context); + internals(recognizer).deliverCancelled(touches, context); } for (const touch of touches) { const record = this.records.get(touch.id); @@ -360,7 +426,7 @@ export class GestureArena { } const context = this.context(); for (const recognizer of [...this.active]) { - recognizer.deliverTakenByBrowser(touches, context); + internals(recognizer).deliverTakenByBrowser(touches, context); } for (const touch of touches) { const record = this.records.get(touch.id); @@ -387,7 +453,10 @@ export class GestureArena { this.fail(recognizer); return; } - if (recognizer.pending != null && (next === 'began' || next === 'ended')) { + if ( + internals(recognizer).pending != null && + (next === 'began' || next === 'ended') + ) { // Already waiting on a requirement; the recognition stands as asked. return; } @@ -421,6 +490,38 @@ export class GestureArena { } } + /** + * Runs `body`, code outside input that stops recognizers (an Effect that + * turns one off or detaches it), without resolving what those stops + * unblock: the recognitions waiting on them, the touches they held from + * a view, the resets. A pass a microtask later resolves all of it, at the + * last event's time, so the listeners and consumers it reaches hear it + * outside the caller's frame and read the recognized state + * (react-component-runtime INV2). + */ + deferResolution(body: () => void): void { + this.deferDepth += 1; + try { + body(); + } finally { + this.deferDepth -= 1; + } + if (this.deferDepth === 0 && this.owesResolution) { + queueMicrotask(() => { + if (this.owesResolution && this.deferDepth === 0) { + this.run(this.lastEventTime(), () => {}); + } + }); + } + } + + /** The last event's time, or now before any event. */ + private lastEventTime(): number { + return Number.isFinite(this.latestTime) + ? this.latestTime + : this.clock.now(); + } + /** Cancel an active recognizer, or fail a possible one. */ stop(recognizer: GestureRecognizer): void { // A cancel from code between events happens at the last event's time: @@ -444,13 +545,19 @@ export class GestureArena { recognizer: GestureRecognizer, next: 'began' | 'ended', ): void { - if (!recognizer.shouldBegin() || this.isVetoedByConsumer(recognizer)) { + if ( + !internals(recognizer).shouldBegin() || + this.isVetoedByConsumer(recognizer) + ) { this.fail(recognizer); return; } let isWaiting = false; for (const other of this.active) { - if (other === recognizer || !recognizer.requiresFailureOf(other)) { + if ( + other === recognizer || + !internals(recognizer).requiresFailureOf(other) + ) { continue; } if (other.state === 'failed') { @@ -466,7 +573,7 @@ export class GestureArena { } } if (isWaiting) { - recognizer.pending = next; + internals(recognizer).pending = next; this.claim(recognizer); return; } @@ -490,14 +597,16 @@ export class GestureArena { continue; } if ( - other.requiresFailureOf(recognizer) || + internals(other).requiresFailureOf(recognizer) || this.conflicts(recognizer, other) ) { this.fail(other); } } this.claim(recognizer); - if (recognizer.cancelsTouchesInView) { + // A watcher takes nothing from the view either: the press and the click + // under it stay (spec:AST-094 FR11, FR19; FR15 exempts watchers). + if (recognizer.claimsTouches && recognizer.cancelsTouchesInView) { this.cancelViewTouchesOf(recognizer); } } @@ -507,7 +616,14 @@ export class GestureArena { winner: GestureRecognizer, other: GestureRecognizer, ): boolean { - if (!sharesTouches(winner, other) || winner.allowsSimultaneous(other)) { + if ( + !sharesTouches(winner, other) || + internals(winner).allowsSimultaneous(other) || + // A watcher (claimsTouches false) prevents nothing and nothing + // prevents it. + !winner.claimsTouches || + !other.claimsTouches + ) { return false; } return winner.canPrevent(other) && other.canBePrevented(winner); @@ -515,7 +631,7 @@ export class GestureArena { /** UIControl's rule: a control may veto an overlapping ancestor recognizer. */ private isVetoedByConsumer(recognizer: GestureRecognizer): boolean { - for (const id of recognizer.seenTouchIds) { + for (const id of internals(recognizer).seenTouchIds) { const consumer = this.records.get(id)?.consumer; if (consumer?.allowsAncestorRecognizer?.(recognizer) === false) { return true; @@ -532,7 +648,8 @@ export class GestureArena { this.apply(recognizer, 'cancelled'); return; } - recognizer.pending = null; + internals(recognizer).pending = null; + // apply() gives back any claim it held (releaseClaim), waiting or not. this.apply(recognizer, 'failed'); this.reviewPending(); } @@ -541,12 +658,19 @@ export class GestureArena { * A recognition that claimed its touches and then fails or is cancelled (a * waiting recognition that fails, a gesture a second finger or the app * cancels) gives them back to the browser unless another recognition owns - * them, so a finger still resting there can pinch-zoom the page. + * them, so a finger still resting there can pinch-zoom the page. A gesture + * that cancels itself on a finger's move (a long press past its tolerance) + * keeps that finger until it lifts: the drag goes on, and the page never + * takes it over midway (`spec:AST-094` FR14), as UIKit's scroll view never + * takes a touch another recognizer had. */ - private releaseClaim(recognizer: GestureRecognizer): void { + private releaseClaim( + recognizer: GestureRecognizer, + keep: ReadonlySet = new Set(), + ): void { for (const touch of recognizer.trackedTouches) { const record = this.records.get(touch.id); - if (record == null || !record.isClaimed) { + if (record == null || !record.isClaimed || keep.has(touch.id)) { continue; } // A recognized discrete gesture (`ended`, a swipe) still owns its @@ -557,7 +681,7 @@ export class GestureArena { other.claimsTouches && (isActive(other.state) || other.state === 'ended' || - other.pending != null) && + internals(other).pending != null) && other.trackedTouches.some(tracked => tracked.id === touch.id), ); if (!isOwned) { @@ -568,7 +692,7 @@ export class GestureArena { private apply(recognizer: GestureRecognizer, next: GestureState): void { const from = recognizer.state; - recognizer.applyState(next); + internals(recognizer).applyState(next); if (recognizer.state === next && from !== next) { this.options.onTransition?.(recognizer, from, next); } else if (next === 'changed' && from === 'changed') { @@ -577,7 +701,9 @@ export class GestureArena { if (isTerminal(next)) { this.clearTimer(recognizer); } - if (next === 'cancelled' || next === 'failed') { + if (next === 'cancelled') { + this.releaseClaim(recognizer, this.movingIds); + } else if (next === 'failed') { this.releaseClaim(recognizer); } } @@ -588,25 +714,29 @@ export class GestureArena { * settle in one pass. */ private reviewPending(): void { + if (this.deferDepth > 0) { + this.owesResolution = true; + return; + } let changed = true; while (changed) { changed = false; for (const recognizer of [...this.active]) { - const next = recognizer.pending; + const next = internals(recognizer).pending; if (next == null || recognizer.state !== 'possible') { continue; } const blocked = this.active.some( other => other !== recognizer && - recognizer.requiresFailureOf(other) && + internals(recognizer).requiresFailureOf(other) && other.state === 'possible' && other.isInvolved(), ); if (blocked) { continue; } - recognizer.pending = null; + internals(recognizer).pending = null; this.attemptRecognition(recognizer, next); changed = true; } @@ -735,7 +865,7 @@ export class GestureArena { recognizer.delaysTouchesBegan && recognizer.state === 'possible' && recognizer.isInvolved() && - recognizer.seenTouchIds.has(record.touch.id), + internals(recognizer).seenTouchIds.has(record.touch.id), ); } @@ -745,12 +875,12 @@ export class GestureArena { recognizer.delaysTouchesEnded && recognizer.state === 'possible' && recognizer.isInvolved() && - recognizer.seenTouchIds.has(record.touch.id), + internals(recognizer).seenTouchIds.has(record.touch.id), ); } private cancelViewTouchesOf(recognizer: GestureRecognizer): void { - for (const id of recognizer.seenTouchIds) { + for (const id of internals(recognizer).seenTouchIds) { const record = this.records.get(id); if (record == null) { continue; @@ -831,13 +961,26 @@ export class GestureArena { private run(time: number, body: () => void): void { this.depth += 1; try { + if (this.depth === 1 && this.deferDepth === 0 && this.owesResolution) { + // A deferred stop resolves before anything newer: input that comes + // ahead of its microtask (a nested dispatch) sees settled state. + this.owesResolution = false; + this.currentTime = this.clampTime(this.lastEventTime()); + this.settle(); + } this.flushTimers(time); this.currentTime = this.clampTime(time); body(); // Re-entrant calls settle with the outermost one: settling mid-delivery - // would reset recognizers that have not heard the event yet. + // would reset recognizers that have not heard the event yet. Inside + // `deferResolution`, the next pass settles. if (this.depth === 1) { - this.settle(); + if (this.deferDepth > 0) { + this.owesResolution = true; + } else { + this.owesResolution = false; + this.settle(); + } } } finally { this.depth -= 1; @@ -897,7 +1040,7 @@ export class GestureArena { } this.active = this.active.filter(other => other !== recognizer); const from = recognizer.state; - recognizer.resetToPossible(); + internals(recognizer).resetToPossible(); didReset = true; if (from !== 'possible') { this.options.onTransition?.(recognizer, from, 'possible'); @@ -957,8 +1100,8 @@ export class GestureArena { } function sharesTouches(a: GestureRecognizer, b: GestureRecognizer): boolean { - for (const id of a.seenTouchIds) { - if (b.seenTouchIds.has(id)) { + for (const id of internals(a).seenTouchIds) { + if (internals(b).seenTouchIds.has(id)) { return true; } } diff --git a/packages/core/src/gestures/GestureHost.ts b/packages/core/src/gestures/GestureHost.ts new file mode 100644 index 0000000000000..43d202d1be880 --- /dev/null +++ b/packages/core/src/gestures/GestureHost.ts @@ -0,0 +1,2962 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file GestureHost.ts + * @input DOM touch, pointer, click, contextmenu, scroll and focus events of + * one document; elements with recognizers and consumers attached + * @output Exports GestureHost (the DOM side of the gesture system) and + * getGestureHost (one host per document) + * @position Browser adapter of packages/core/src/gestures: turns real events + * into GestureArena input, and carries out what the arena decides + * + * ONLY WHERE THE PRIMARY POINTER IS COARSE + * + * - Recognizers attach, and the host registers listeners, only while the + * primary pointer is coarse (`spec:AST-094` FR19, FR24; coarsePointer.ts). + * Where it is fine, an attach is recorded but nothing is installed: no + * document, window or element listener, no `touch-action` or other style, + * nothing delivered, so a finger on a touchscreen laptop gets the + * browser's defaults. The host follows the query: turning coarse installs + * it and styles every registration; turning fine cancels every touch in + * flight and removes it all. A development warning names the first attach + * under a fine pointer. + * - The one carve-out is `perEventPointer` (AttachOptions). A per-event + * recognizer gets its listeners and its element's style on any pointer. A + * per-event press tracker, where the pointer is fine, gets only passive + * `touchstart` and `pointerdown` listeners on its own element and no + * style; the host's document and window listeners are added at a finger's + * press on it and removed once that press's contacts end. + * + * INPUT CHANNELS + * + * - Fingers begin and end with Touch Events: they are the only cancelable + * channel (a pointer event's preventDefault cannot stop a scroll or a + * click), they keep arriving after the browser takes a pan (which a + * watcher following the scroll to the lift needs, `spec:AST-094` FR20, + * DEC-7), and every event lists all the fingers at once. A + * finger's movement is sampled from ONE channel: its `pointermove`s, once + * its pointerdown has paired with its touchstart (by target and + * position), since Chromium sends no `touchmove` until a finger has + * travelled about 16 px while a tap fails and a pan begins at 10; touch + * events sample only a finger no pointer event reported. The other + * channel keeps its control duties but feeds no sample. + * - A finger's events go to the element it landed on even after that + * element leaves the document, and then never reach the document. Each + * tracked finger's target therefore gets its own listeners, and a finger + * the events say is no longer down is cancelled, so a re-rendered or + * removed target never strands a gesture. + * - Mice and pens come from Pointer Events, with coalesced samples for + * velocity. A pen on an iPad also produces `stylus` touches; those are left + * to the pointer path. + * - `pointercancel` on a finger is the browser saying it took that finger + * for its own pan or zoom; so is a `touchmove` that can no longer be + * cancelled where `touch-action` let the browser pan, and a scroll above + * the touch. + * - Where the primary pointer is fine the host listens for nothing + * (`spec:AST-094` FR19, FR24) except an action group + * (`AttachOptions.actionGroup`, `family:action-groups` FR5): one passive + * `pointerdown` listener on the group, and page listeners (the shared + * input stream's) only while its press is held, removed at the release. + * Only the group's own recognizers and consumer take that press. + * + * FINGERS + * + * - The host keeps one record of where each finger on the screen landed + * (`fingersDown()`). Finger scrolls (fingerScroll.ts) read it, and a + * scroll lock's page hold (pageHold.ts) asks which touches a recognizer + * owns; both live in their own modules, so a page that needs neither + * never ships them. + * + * TOUCHES ON MOVING ELEMENTS + * + * - iOS WebKit hit-tests an element a compositor animation moves where the + * animation started. There, at touch-down, the host resolves the moving + * elements (motion/presentation.ts: one style write each) and asks the + * engine again, so a contact goes to what is drawn under it; press + * trackers read `presentedRect`, and a retargeted tap clicks what it + * touched once, with the engine's own click cancelled or swallowed. + * + * WHAT THE BROWSER IS TOLD + * + * - Each element gets the `touch-action` its recognizers leave the browser + * (touchAction.ts), never wider than the element's own value (a caller's + * explicit `touch-action` stays in force), set inline when they attach and + * restored when they detach. + * - While a finger or a pen is down, text selection and the touch callout + * are off only where the hold does something (`spec:AST-094` FR41): on the + * elements of recognizers that bring a `selectionLock`, and on a non-text + * input under the finger inside them (WebKit's own style makes every + * input selectable); restored when the contact ends. Only where the + * primary pointer is coarse: a mouse never takes this lock, nor a finger + * or a pen where the primary pointer is fine. + * - Each element with a recognizer that can claim a touch while it moves (a + * pan, a swipe, a long press that then drags, a pinch, a rotation) gets a + * NON-passive `touchmove` listener that cancels the event only while a + * recognizer owns one of its touches (a pan claims when it begins, at + * UIKit's 10 pt, `spec:AST-094` FR17). Nothing else is ever cancelled, so a + * touch nobody claims scrolls exactly as it would without the system. The + * known cost: a scroll that BEGINS on such an element waits for a busy main + * thread (measured in iOS Safari; a passive listener would hand every + * diagonal to the scroll instead). Elements whose recognizers only claim + * at the lift (taps), recognizers that never claim (`claimsTouches` false) + * and press consumers get no blocking listener, and the listener a + * finger's target gets at touch-down is passive for them too, so a scroll + * starting on them never waits. + * - The browser took a finger on `pointercancel`, on its first uncancelable + * `touchmove` where it could pan, or on a scroll above it once the finger + * has travelled the slop; a scroll under a finger that has not moved is + * the page's own and leaves the touch alone. + * - A lift whose click must not happen cancels its `touchend` (no click, no + * compatibility mouse events) and arms the shared click guard + * (utils/clickGuard.ts, the menu press model's too) for the browser's next + * click; a click held for a recognizer that may still recognize is + * replayed if they all fail. Keyboard and assistive-technology clicks + * (`detail === 0`) always pass. + * - A `contextmenu` while a recognizer owns a touch is cancelled, so a long + * press never also opens the browser's menu. + * - When the app loses focus (a window blur, a hidden page), every touch in + * flight is cancelled, as in UIKit; a finger the browser keeps alive lifts + * on nothing: its `touchend` is cancelled (`spec:AST-094` FR28). + * + * SSR-safe: nothing touches `window` or `document` until something attaches. + * + * SYNC: When modified, update GestureHost.test.ts and README.md. + */ + +import {isRtlElement} from '../hooks/isRtlElement'; +import { + presentedElements, + presentedOffsetOf, + resolvePresented, + watchPresentedElements, +} from '../motion/presentation'; +import {UIKIT_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; +import { + browserClock, + GestureArena, + type ArenaBeganInput, + type ClickDisposition, + type GestureArenaOptions, + type GestureClock, +} from './GestureArena'; +import type {GestureRecognizer, SelectionLock} from './GestureRecognizer'; +import type {PressBounds} from './PressTracker'; +import type {HoverGestureRecognizer} from './HoverGestureRecognizer'; +import type {PanGestureRecognizer} from './PanGestureRecognizer'; +import {createNativeScrollProbe, parentAcrossShadow} from './nativeScroll'; +import { + gestureHostStyle, + intersectTouchAction, + type GestureHostStyle, +} from './touchAction'; +import type { + GestureEnvironment, + GestureTouch, + PointerKind, + TouchBeganInput, + TouchConsumer, + TouchInput, + TouchSample, +} from './types'; +import {internals} from './recognizerInternals'; +import { + armClickGuard, + disarmClickGuard, + withoutClickGuard, +} from '../utils/clickGuard'; +import {subscribeInput} from '../utils/inputStream'; +import {locksSelectionFor} from '../utils/selectionLock'; +import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer'; +import {gestureHosts} from './hostRegistry'; +import { + markTouchAction, + TOUCH_ACTION_ATTRIBUTE, + unmarkTouchAction, +} from './touchActionRule'; + +interface Registration { + readonly recognizers: GestureRecognizer[]; + readonly hovers: HoverGestureRecognizer[]; + consumer: TouchConsumer | null; + /** Whether the element carries the host's shared blocking touchmove handler. */ + hasTouchMoveListener: boolean; + /** Inline values the host replaced, to restore on detach. */ + readonly savedStyle: Map; + /** + * What the element's own stylesheet gives it, read in a frame for the + * derived value the host marked then: `null` where the host's rule is in + * force (the author sets nothing else); the host never allows more than + * the author does. `null` before the read. + */ + authorTouchAction: {derived: string; author: string | null} | null; + manageStyle: boolean; + /** An action group (`family:action-groups` FR5). */ + actionGroup: boolean; + /** The attachments that asked for the action group; it lasts while one does. */ + actionGroupAsks: number; + /** The group's passive `pointerdown` listener under a fine pointer. */ + groupDownListener: EventListener | null; +} + +export interface GestureHostOptions { + /** The arena's clock; tests pass a manual one. */ + clock?: GestureClock; + /** + * Where fingers come from: Touch Events where the browser has them + * (`auto`), or Pointer Events only. + */ + inputMode?: 'auto' | 'pointer-events'; + /** Every recognizer transition, for debugging and tests. */ + onTransition?: GestureArenaOptions['onTransition']; + /** + * Send a contact to what is drawn while a compositor animation moves an + * element (motion/presentation.ts): `auto` where the engine hit-tests such + * an element where its animation started (iOS WebKit); `always` or + * `never` for tests. @default 'auto' + */ + presentedHitTesting?: 'auto' | 'always' | 'never'; + /** + * Whether the primary pointer is coarse (a phone, a tablet). Where it is + * fine, the host registers no listener at all (`spec:AST-094` FR19, FR24), + * except an action group's own (`AttachOptions.actionGroup`). + * @default `(pointer: coarse)` on the document's window + */ + isPrimaryPointerCoarse?: () => boolean; +} + +export interface AttachOptions { + /** Let the host set touch-action (and long-press callout) styles. @default true */ + manageStyle?: boolean; + /** + * The recognizer, or the press tracker, reads each event's pointer itself, + * so it attaches even where the primary pointer is fine, with no warning. + * A recognizer gets the host's listeners and its element's style there, as + * `spec:AST-057`'s row swipe does. A press tracker there gets only passive + * `touchstart` and `pointerdown` listeners on its own element, and no + * style; the host's document and window listeners come for a finger's + * press on it and go when the press ends. It paints that press's feedback + * only (an FR19 exception for press feedback only, pending in + * `spec:AST-094`'s amendment): no control event, nothing kept from the + * browser, which still pans, and no mouse or pen press. Everything else + * stays inactive there. @default false + */ + perEventPointer?: boolean; + /** + * The element is an action group, sibling actions a held pointer slides + * across (`family:action-groups` FR5). Where the primary pointer is fine, + * the host still serves it, and nothing else: one passive `pointerdown` + * listener on the element, and page listeners only while a press is held + * (`spec:AST-094` FR19, FR24). @default false + */ + actionGroup?: boolean; +} + +const STYLE_PROPERTIES: Record = { + touchAction: 'touch-action', + WebkitTouchCallout: '-webkit-touch-callout', + WebkitUserSelect: '-webkit-user-select', + userSelect: 'user-select', +}; + +/** + * Run `task` in the next frame, before it paints: a read there styles what + * the frame must style anyway, and its writes are what the frame shows. + */ +function inNextFrame(view: Window | null, task: () => void): void { + if (view != null && typeof view.requestAnimationFrame === 'function') { + view.requestAnimationFrame(task); + } else { + setTimeout(task, 0); + } +} + +/** An armed press tracker's own element listeners: they never cancel. */ +const PASSIVE_CAPTURE = {capture: true, passive: true} as const; + +/** + * Whether a recognizer can take a touch while it moves, and so needs the + * blocking `touchmove` listener that stops the browser's pan. A tap claims + * only at the lift (it fails once the finger travels), so it needs none. + */ +function claimsWhileMoving(recognizer: GestureRecognizer): boolean { + return ( + recognizer.claimsTouches && + internals(recognizer).acceptsTouchInput && + internals(recognizer).family !== 'tap' + ); +} + +/** + * A hover recognizer, told apart by its family rather than its class, so a + * page without one never ships HoverGestureRecognizer. + */ +function isHoverRecognizer( + recognizer: GestureRecognizer, +): recognizer is HoverGestureRecognizer { + return internals(recognizer).family === 'hover'; +} + +/** + * 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. + */ +function isPanRecognizer( + recognizer: GestureRecognizer, +): recognizer is PanGestureRecognizer { + return internals(recognizer).family === 'pan'; +} + +type StylusTouch = Touch & {touchType?: string}; + +interface PlacedContact { + readonly x: number; + readonly y: number; + readonly target: unknown; +} + +/** A touchstart and a pointerdown describe one finger: same target, same spot. */ +function isSamePlace(a: PlacedContact, b: PlacedContact): boolean { + return ( + a.target === b.target && + Math.abs(a.x - b.x) <= 1 && + Math.abs(a.y - b.y) <= 1 + ); +} + +function isPointerKind(type: string): type is PointerKind { + return type === 'touch' || type === 'mouse' || type === 'pen'; +} + +/** + * What an event, or one of its touches, landed on. Inside an open shadow root + * the document's listeners see the shadow host, in `event.target` and in a + * touch's `target` alike, while the composed path starts at the element + * itself. A touch whose target is not the event's (another finger) keeps its + * own. + */ +function landedOn( + event: Event, + retargeted: EventTarget | null = event.target, +): EventTarget | null { + if (retargeted !== event.target) { + return retargeted; + } + return event.composedPath()[0] ?? retargeted; +} + +/** + * Whether `node` is `ancestor` or below it along the flat tree: across an open + * shadow root to its host, which `Node.contains` does not follow. + */ +function containsAlongFlatTree(ancestor: Node, node: Node): boolean { + for (let at: Node | null = node; at != null; at = parentAcrossShadow(at)) { + if (at === ancestor) { + return true; + } + } + return false; +} + +/** + * An event's time on the arena's clock (`performance.now()`). Some engines + * and DOM emulators stamp events in epoch milliseconds instead. + */ +function eventTime(event: Event): number { + const time = event.timeStamp; + if ( + time > 1e12 && + typeof performance !== 'undefined' && + performance.timeOrigin > 0 + ) { + return time - performance.timeOrigin; + } + return time; +} + +/** A touch list as an array; a synthetic event may carry none. */ +function touchesOf(list: TouchList | null | undefined): Touch[] { + return list == null ? [] : Array.from(list); +} + +export class GestureHost { + readonly arena: GestureArena; + private readonly doc: Document; + private readonly registry = new Map(); + /** Stops following the animator's moving elements (iOS WebKit only). */ + private stopWatchingMovers: (() => void) | null = null; + /** Whether the document carries the blocking touchmove for a mover. */ + private isBlockingForMovers = false; + /** Elements whose caller touch-action waits for a read after paint. */ + private readonly pendingCallerReads = new Set(); + private isCallerReadScheduled = false; + /** The selection lock each contact holds, by host contact id. */ + private readonly lockedBy = new Map void>(); + private readonly presentedHitTesting: 'auto' | 'always' | 'never'; + /** + * Contacts sent to what is drawn instead of what the engine hit, by host + * contact id, with where they landed (for their click). + */ + private readonly retargeted = new Map< + number, + {x: number; y: number; target: EventTarget} + >(); + /** Touch.identifier → arena id, for live fingers. */ + private readonly touchIds = new Map(); + /** + * Fingers cancelled because the app lost focus, kept until the browser + * ends them: a browser that keeps such a touch alive must not let its + * lift click (`spec:AST-094` FR28: nothing acts). + */ + private readonly cancelledFingers = new Set(); + /** Touch.identifier → the element the finger landed on. */ + private readonly touchTargets = new Map(); + /** Finger targets with listeners of their own, and how many fingers each. */ + /** + * Finger targets with listeners of their own: how many fingers each, and + * how many of those a recognizer could claim while they move (only those + * need the blocking `touchmove` listener). + */ + private readonly watched = new Map< + EventTarget, + {count: number; blocking: number; claiming: number} + >(); + /** Fingers whose target listener is the blocking one. */ + private readonly blockingFingers = new Set(); + /** + * Fingers a participant could claim (a recognizer that claims touches, or + * a press consumer): only their target's `touchend` may need cancelling, + * so a touch only watchers follow gets a passive one (`spec:AST-094` + * FR20). + */ + private readonly claimingFingers = new Set(); + /** Touch events already handled (the document's and the target's listeners both see one). */ + private readonly processed = new WeakSet(); + /** pointerId → arena id, for live mouse and pen contacts. */ + private readonly pointerIds = new Map(); + /** Fingers whose first uncancelable `touchmove` was already judged. */ + private readonly judgedUncancelable = new Set(); + /** + * A finger's pointerId → its arena id, once its pointerdown and its + * touchstart are paired. Chromium sends no `touchmove` until a finger has + * travelled its slop (about 16 px), but sends every `pointermove`, so a + * paired finger is sampled from its pointer events (`pointerFed`); and its + * `pointercancel` is the browser's claim of exactly that finger. + */ + private readonly fingerPointers = new Map(); + /** Finger pointerdowns still waiting for their touchstart. */ + private readonly pendingPointers = new Map(); + /** Fingers still waiting for their pointerdown, by arena id. */ + private readonly unpairedTouches = new Map(); + /** The last sample fed per contact: a contact's time never runs back. */ + private readonly isPrimaryPointerCoarse: () => boolean; + /** Whether the host follows `(pointer: coarse)` (no read was passed). */ + private readonly followsPointer: boolean; + private readonly lastFed = new Map(); + /** + * Fingers whose samples come from their pointer events. A finger the + * browser reports on both channels is sampled from ONE: the pointer + * channel, where its pointerdown paired with its touchstart; touch events + * sample only a finger no pointer event reported (no Pointer Events). The + * other channel keeps its control duties (cancelling, claiming, the + * browser's takeover) but never feeds a sample, so the two copies of one + * move cannot both reach a velocity, however far apart they arrive. + */ + private readonly pointerFed = new Set(); + private readonly hovered = new Map>(); + /** When a finger was last down or lifted, and where it landed. */ + private lastTouch: {readonly target: Node; readonly time: number} | null = + null; + /** The last scroll a finger drove, and when. */ + /** Scrollers a recognizer is scrolling itself this frame (ownScroll). */ + private readonly ownedScrolls = new Map(); + /** Numbers each ownScroll call: only the newest one's timer clears it. */ + private ownScrollCalls = 0; + private lastScroll: {readonly scroller: Node; readonly time: number} | null = + null; + /** Predicates that hold a touch's target away from every participant. */ + private readonly touchGates = new Set<(target: Node) => boolean>(); + /** Who keeps the listeners installed without a registration (retain). */ + private retainers = 0; + /** Told after every touch event that changed the fingers on the screen. */ + private readonly fingerListeners = new Set<() => void>(); + /** + * Where each finger now on the screen landed, from the last touch event's + * `touches` (every finger, whether or not a recognizer tracks it). + */ + private fingerTargets: ReadonlyArray = []; + private nextId = 1; + private isInstalled = false; + /** + * Whether the primary pointer is coarse (`spec:AST-094` FR19), read when + * something first attaches and kept current while anything is attached. + */ + private isCoarse = false; + private stopWatchingPointer: (() => void) | null = null; + private hasWarnedFinePointer = false; + /** Recognizers attached with `perEventPointer`: active on any pointer. */ + private readonly perEventRecognizers = new Set(); + /** The input stream subscriptions while installed. */ + private unsubscribers: (() => void)[] = []; + /** Press consumers attached with `perEventPointer`: active on any pointer. */ + private readonly perEventConsumers = new Set(); + /** + * Where the primary pointer is fine: the elements of enabled + * perEventPointer press trackers, which listen for a finger's press + * themselves, and whether such a press is in progress (the host's document + * and window listeners last only that long). + */ + private readonly armedElements = new Map void>(); + private isArmedPress = false; + /** + * Whether the installed listeners include the cancelable ones (the + * document's touchend, contextmenu and the window's click): only while a + * participant that can claim is attached (`spec:AST-094` FR20). + */ + private isClaimingInstalled = false; + /** The input stream subscriptions syncClaimingListeners keeps. */ + private claimingUnsubscribers: (() => void)[] = []; + private readonly inputMode: 'auto' | 'pointer-events'; + private usesTouchEvents = true; + + constructor(doc: Document, options: GestureHostOptions = {}) { + this.doc = doc; + this.inputMode = options.inputMode ?? 'auto'; + this.presentedHitTesting = options.presentedHitTesting ?? 'auto'; + const environment: GestureEnvironment = { + viewportSize: () => ({ + width: doc.defaultView?.innerWidth ?? 0, + height: doc.defaultView?.innerHeight ?? 0, + }), + // The writing direction through core's one helper. + isRtl: view => + view instanceof Element && isRtlElement(view as HTMLElement), + // A screen-edge pan tells its edge zone from a wider view by its size + // (spec:AST-094 FR18). + viewSize: view => { + if (!(view instanceof Element)) { + return null; + } + const {width, height} = view.getBoundingClientRect(); + return {width, height}; + }, + }; + this.arena = new GestureArena({ + clock: options.clock ?? browserClock, + environment, + onDeferredClick: (touch, allowed) => this.replayClick(touch, allowed), + onTransition: options.onTransition, + }); + const view = doc.defaultView; + // A test may pass its own read; otherwise the one coarse-pointer gate, + // followed as it changes. + this.followsPointer = options.isPrimaryPointerCoarse == null; + this.isPrimaryPointerCoarse = + options.isPrimaryPointerCoarse ?? (() => isCoarsePointer(view)); + } + + // --- Attachment ------------------------------------------------------------ + + /** Attach a recognizer to an element. Returns the detach function. */ + attach( + element: Element, + recognizer: GestureRecognizer, + options: AttachOptions = {}, + ): () => void { + const registration = this.registrationFor(element); + registration.manageStyle = options.manageStyle ?? registration.manageStyle; + // An action group while any attachment asks, whatever their order. + const asksForGroup = options.actionGroup === true; + if (asksForGroup) { + registration.actionGroupAsks += 1; + registration.actionGroup = true; + } + recognizer.view = element; + if (options.perEventPointer === true) { + this.perEventRecognizers.add(recognizer); + } else if (!this.isCoarse) { + this.warnFinePointer(); + } + if (isHoverRecognizer(recognizer)) { + registration.hovers.push(recognizer); + } else { + registration.recognizers.push(recognizer); + } + recognizer.onEnabledChange = () => { + this.refresh(element, registration); + this.syncListeners(); + }; + this.refresh(element, registration); + this.syncListeners(); + return () => { + recognizer.onEnabledChange = null; + this.perEventRecognizers.delete(recognizer); + if (asksForGroup) { + registration.actionGroupAsks -= 1; + registration.actionGroup = registration.actionGroupAsks > 0; + } + const index = registration.recognizers.indexOf(recognizer); + if (index >= 0) { + registration.recognizers.splice(index, 1); + } + if (isHoverRecognizer(recognizer)) { + const hoverIndex = registration.hovers.indexOf(recognizer); + if (hoverIndex >= 0) { + registration.hovers.splice(hoverIndex, 1); + } + recognizer.hoverExited(); + } + // A recognizer whose view goes away mid-gesture is cancelled, as in + // UIKit when a view leaves the window. + if (recognizer.state !== 'possible' || recognizer.isInvolved()) { + recognizer.cancel(); + } + this.refresh(element, registration); + this.cleanup(element, registration); + // A registration that stays may have lost its last enabled member. + this.syncListeners(); + }; + } + + /** Attach the view-level touch consumer of an element (a pressable control). */ + attachConsumer( + element: Element, + consumer: TouchConsumer, + options: Pick = {}, + ): () => void { + const registration = this.registrationFor(element); + registration.consumer = consumer; + if (options.perEventPointer === true) { + this.perEventConsumers.add(consumer); + } else if (!this.isCoarse) { + this.warnFinePointer(); + } + this.refresh(element, registration); + this.syncListeners(); + return () => { + this.perEventConsumers.delete(consumer); + if (registration.consumer === consumer) { + registration.consumer = null; + } + // A keepsTouch consumer's blocking touchmove goes with it, even while + // the element keeps its recognizers. + this.refresh(element, registration); + this.cleanup(element, registration); + // A registration that stays may have lost its last enabled member. + this.syncListeners(); + }; + } + + /** + * @internal The document this host serves. + */ + get document(): Document { + return this.doc; + } + + /** + * @internal Whether a recognizer owns the touch with this Touch Events + * `identifier`, so the browser must not pan it (pageHold.ts asks). + */ + isTouchClaimed(identifier: number): boolean { + const id = this.touchIds.get(identifier); + return id != null && this.arena.isClaimed(id); + } + + /** + * An element's box where it is drawn: the box the engine reports, moved + * by the compositor animations of it and its ancestors on engines that + * report and hit-test those where they started (iOS WebKit). Elsewhere + * `getBoundingClientRect` already reports the drawn box. Press trackers + * read their bounds through it. + */ + presentedRect(element: Element): PressBounds { + const box = element.getBoundingClientRect(); + if (!this.hitTestsLayout()) { + return box; + } + const {x, y} = presentedOffsetOf(element); + if (x === 0 && y === 0) { + return box; + } + return { + left: box.left + x, + top: box.top + y, + right: box.right + x, + bottom: box.bottom + y, + }; + } + + /** Whether this engine hit-tests a compositor-moved element at layout. */ + private hitTestsLayout(): boolean { + return ( + this.presentedHitTesting === 'always' || + (this.presentedHitTesting === 'auto' && isIosWebKit(this.doc.defaultView)) + ); + } + + /** + * Where a contact lands among what is drawn. iOS WebKit hit-tests an + * element that a compositor animation moves where the animation started: + * a finger on a settling sheet lands on whatever sat under the sheet's + * start (the scrim, which closes it), and a finger where the sheet was + * lands on the sheet. UIKit keeps an interruptible animation hit-testable + * where it is drawn (UIViewPropertyAnimator). + */ + private presentedTarget( + native: EventTarget | null, + x: number, + y: number, + ): EventTarget | null { + if (!this.hitTestsLayout()) { + return native; + } + const movers = presentedElements().filter(element => { + if (!element.isConnected || element.ownerDocument !== this.doc) { + return false; + } + const {x: dx, y: dy} = presentedOffsetOf(element); + return Math.abs(dx) >= 0.5 || Math.abs(dy) >= 0.5; + }); + if (movers.length === 0) { + return native; + } + // One style write per mover makes the engine hit-test it where it is + // drawn now; the hit test below flushes them. The engine then gets + // z-order, pointer-events and clipping right, which no box math would. + resolvePresented(movers); + let hit = this.doc.elementFromPoint(x, y); + // The document answers a shadow host for anything inside its open root: + // descend to what is drawn there. + while (hit?.shadowRoot != null) { + const inner = hit.shadowRoot.elementFromPoint(x, y); + if (inner == null || inner === hit) { + break; + } + hit = inner; + } + if (hit == null || hit === native) { + return native; + } + const found = hit; + const node = native instanceof Node ? native : null; + const isAboutMover = movers.some( + mover => + containsAlongFlatTree(mover, found) || + (node != null && containsAlongFlatTree(mover, node)), + ); + return isAboutMover ? found : native; + } + + /** The browser took these touches (a scroll): no click of ours for them. */ + private browserTook(ids: ReadonlyArray, time: number): void { + for (const id of ids) { + this.retargeted.delete(id); + } + this.arena.browserTookOver(ids, time); + } + + /** + * The click a retargeted tap earned, on what was drawn under the finger. + * The engine's own click would hit the start position, so the caller + * cancels it (or the click guard drops it, a late one included) and this one + * is the tap's single activation. + */ + private clickRetargeted(target: EventTarget, x: number, y: number): void { + const view = this.doc.defaultView; + if (!(target instanceof Element) || !target.isConnected || view == null) { + return; + } + armClickGuard(this.doc); + if ( + target instanceof view.HTMLElement && + target.matches('input, textarea, select, [contenteditable]') + ) { + target.focus(); + } + withoutClickGuard(() => + target.dispatchEvent( + new view.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + detail: 1, + clientX: x, + clientY: y, + }), + ), + ); + } + + /** + * Re-derive an element's styles and the host's listeners after its + * recognizers' options or its consumer's enabled state changed. + */ + update(element: Element): void { + if (!this.followsPointer) { + // A read passed in (a test's) is not followed: an update reads it again. + this.isCoarse = this.isPrimaryPointerCoarse(); + } + const registration = this.registry.get(element); + if (registration != null) { + this.refresh(element, registration); + } + this.syncListeners(); + } + + /** + * The Pointer Events `pointerId` of a contact the arena tracks: a mouse's + * or a pen's own, and for a finger driven by touch events the pointer its + * touch was paired with (null before the pairing, or without Pointer + * Events). It hands a held finger to code that follows pointers, as a + * menu does when a long press turns into a press-drag selection. + */ + pointerIdOf(touch: GestureTouch | number): number | null { + const id = typeof touch === 'number' ? touch : touch.id; + for (const [pointerId, arenaId] of this.fingerPointers) { + if (arenaId === id) { + return pointerId; + } + } + for (const [pointerId, arenaId] of this.pointerIds) { + if (arenaId === id) { + return pointerId; + } + } + return null; + } + + /** + * Whether a finger set a scroller containing `node` moving within the last + * `window` ms. A touch there now brakes the scroll rather than pressing + * whatever is under it: a native list highlights nothing for one. + */ + isScrollBraking( + node: Node, + window: number = UIKIT_PRESS.scrollBrake.value, + ): boolean { + const last = this.lastScroll; + return ( + last != null && + this.arena.now() - last.time < window && + containsAlongFlatTree(last.scroller, node) + ); + } + + /** + * @internal Keep the host's listeners installed until the returned + * function runs, with no recognizer attached (fingerScroll.ts reads the + * finger record meanwhile). + */ + retain(): () => void { + this.retainers += 1; + // A watch is not an attach: no warning where the pointer is fine. + this.watchPointer(); + this.syncListeners(); + let isRetained = true; + return () => { + if (!isRetained) { + return; + } + isRetained = false; + this.retainers -= 1; + this.uninstallIfIdle(); + }; + } + + /** + * @internal Call `listener` after each touch event the host hears, once + * the finger record is up to date. Returns the unsubscribe. + */ + onFingersChange(listener: () => void): () => void { + this.fingerListeners.add(listener); + return () => { + this.fingerListeners.delete(listener); + }; + } + + /** @internal Whether a finger that landed inside `node` is down now. */ + isFingerDownIn(node: Node): boolean { + // Along the flat tree: a finger on content inside an open shadow root is + // in the scroller around that root. + return this.fingerTargets.some(target => + containsAlongFlatTree(node, target), + ); + } + + private registrationFor(element: Element): Registration { + this.watchPointer(); + let registration = this.registry.get(element); + if (registration == null) { + registration = { + recognizers: [], + hovers: [], + consumer: null, + hasTouchMoveListener: false, + savedStyle: new Map(), + authorTouchAction: null, + manageStyle: true, + actionGroup: false, + actionGroupAsks: 0, + groupDownListener: null, + }; + this.registry.set(element, registration); + } + return registration; + } + + /** + * Whether `recognizer` takes input now: where the primary pointer is + * coarse, or with `perEventPointer` (FR19; `spec:AST-057`). + */ + private isActive(recognizer: GestureRecognizer): boolean { + return this.isCoarse || this.perEventRecognizers.has(recognizer); + } + + /** + * Whether a press consumer takes input now (FR19; its per-event opt-in, + * an FR19 exception for press feedback pending in AST-094's amendment). + */ + private isConsumerActive(consumer: TouchConsumer): boolean { + return this.isCoarse || this.perEventConsumers.has(consumer); + } + + /** Whether anything attached takes input now. */ + private needsInput(): boolean { + return ( + this.isCoarse || + this.perEventRecognizers.size > 0 || + this.perEventConsumers.size > 0 + ); + } + + private refresh(element: Element, registration: Registration): void { + if (registration.manageStyle && element instanceof HTMLElement) { + this.applyStyle(element, registration); + } + // Where the primary pointer is fine, no touch listener but a + // perEventPointer recognizer's (FR19). + const needsListener = + registration.recognizers.some( + recognizer => + this.isActive(recognizer) && + recognizer.isEnabled && + claimsWhileMoving(recognizer), + ) || + // A kept press holds its touch only where the pointer is coarse; a + // per-event tracker elsewhere gives press feedback only (FR19). + (registration.consumer?.keepsTouch != null && + registration.consumer.isEnabled?.() !== false && + this.isCoarse); + // The blocking listener sits on the element itself (FR19: a scroll that + // starts elsewhere never waits on it), but every element shares the + // host's one handler, which routes through the arena. + if (needsListener && !registration.hasTouchMoveListener) { + element.addEventListener('touchmove', this.onBlockingTouchMove, { + passive: false, + }); + registration.hasTouchMoveListener = true; + } else if (!needsListener && registration.hasTouchMoveListener) { + element.removeEventListener('touchmove', this.onBlockingTouchMove); + registration.hasTouchMoveListener = false; + } + // An action group under a fine pointer: its one idle listener. + const needsGroupListener = + registration.actionGroup && + !this.isPrimaryPointerCoarse() && + (registration.recognizers.some(r => r.isEnabled) || + (registration.consumer != null && + registration.consumer.isEnabled?.() !== false)); + if (needsGroupListener && registration.groupDownListener == null) { + const listener: EventListener = event => + this.beginGroupPress(event as PointerEvent, registration); + registration.groupDownListener = listener; + element.addEventListener('pointerdown', listener, {passive: true}); + } else if (!needsGroupListener && registration.groupDownListener != null) { + element.removeEventListener( + 'pointerdown', + registration.groupDownListener, + ); + registration.groupDownListener = null; + } + } + + private applyStyle(element: HTMLElement, registration: Registration): void { + // Where the primary pointer is fine, the browser's own styles, but a + // perEventPointer recognizer's (FR19). + // A disabled recognizer leaves the browser everything (FR24). + const active = registration.recognizers.filter( + recognizer => recognizer.isEnabled && this.isActive(recognizer), + ); + let style: GestureHostStyle = + active.length > 0 ? gestureHostStyle(active) : {}; + // A control that keeps its touch (a stepper's hold-repeat) takes taps in + // quick succession: where the pointer is coarse, the browser's double-tap + // zoom goes, as for a tap recognizer (`spec:AST-094` FR18); where it is + // fine, the browser keeps its own value (FR19). + if ( + style.touchAction == null && + this.isCoarse && + registration.consumer?.keepsTouch != null + ) { + style = {...style, touchAction: 'manipulation'}; + } + for (const key of Object.keys( + STYLE_PROPERTIES, + ) as (keyof GestureHostStyle)[]) { + const property = STYLE_PROPERTIES[key]; + let value = style[key]; + if (key === 'touchAction') { + value = this.touchActionToWrite(element, registration, value); + } + if (value != null) { + if (!registration.savedStyle.has(property)) { + registration.savedStyle.set( + property, + element.style.getPropertyValue(property), + ); + } + element.style.setProperty(property, value); + } else if (registration.savedStyle.has(property)) { + const saved = registration.savedStyle.get(property) ?? ''; + registration.savedStyle.delete(property); + if (saved === '') { + element.style.removeProperty(property); + } else { + element.style.setProperty(property, saved); + } + } + } + } + + /** + * The touch-action to write inline, if any. The author's value (a + * trigger's own `touch-action: none`) stays in force and the host never + * allows more (spec:AST-094 FR18, DEC-8): + * - an inline value is the style's own, read with no recalc, and the host + * writes what both allow, inline; + * - otherwise the element is marked with the derived value, under a rule + * any author rule outranks, as the recognizer attaches: a subtree that + * mounts with it is styled once, never restyled for it in a later frame. + * The next frame reads what is in force, styles clean, and the host + * writes inline only what both allow where that is narrower than an + * author rule's value. + */ + private touchActionToWrite( + element: HTMLElement, + registration: Registration, + derived: string | undefined, + ): string | undefined { + if (derived == null) { + unmarkTouchAction(element); + registration.authorTouchAction = null; + return undefined; + } + const property = STYLE_PROPERTIES.touchAction; + const inline = registration.savedStyle.has(property) + ? (registration.savedStyle.get(property) ?? '') + : element.style.getPropertyValue(property); + if (inline !== '') { + unmarkTouchAction(element); + return intersectTouchAction(derived, inline); + } + if (!markTouchAction(element, derived)) { + // No document or shadow root to hold the rule (a detached element): + // nothing is styled there, and the next frame looks again. + this.readCallerTouchActionInNextFrame(element); + return undefined; + } + const read = registration.authorTouchAction; + if (read == null || read.derived !== derived) { + // The rule is in force until the read: the derived value, or the + // author's where the author sets one. + this.readCallerTouchActionInNextFrame(element); + return undefined; + } + if (read.author == null) { + return undefined; + } + const both = intersectTouchAction(derived, read.author); + return both === read.author ? undefined : both; + } + + /** + * Read the touch-action in force on each marked element in the next frame, + * then apply its style again. Batched: one style read serves every pending + * element, and the writes follow the reads. The host has written no + * touch-action inline on them, so a value other than the mark is an author + * rule's. + */ + private readCallerTouchActionInNextFrame(element: HTMLElement): void { + this.pendingCallerReads.add(element); + if (this.isCallerReadScheduled) { + return; + } + this.isCallerReadScheduled = true; + inNextFrame(this.doc.defaultView, () => { + this.isCallerReadScheduled = false; + const pending: [HTMLElement, Registration][] = []; + for (const candidate of this.pendingCallerReads) { + const registration = this.registry.get(candidate); + if (registration != null) { + pending.push([candidate, registration]); + } + } + this.pendingCallerReads.clear(); + const view = this.doc.defaultView; + for (const [candidate, registration] of pending) { + const derived = candidate.getAttribute(TOUCH_ACTION_ATTRIBUTE); + if (derived == null || view == null) { + continue; + } + const inForce = view.getComputedStyle(candidate).touchAction || 'auto'; + registration.authorTouchAction = { + derived, + author: inForce === derived ? null : inForce, + }; + } + for (const [candidate, registration] of pending) { + this.applyStyle(candidate, registration); + } + }); + } + + private cleanup(element: Element, registration: Registration): void { + if ( + registration.recognizers.length > 0 || + registration.hovers.length > 0 || + registration.consumer != null + ) { + // What stays may take nothing (only disabled recognizers): FR24. + this.syncListeners(); + return; + } + if (registration.hasTouchMoveListener) { + element.removeEventListener('touchmove', this.onBlockingTouchMove); + } + if (registration.groupDownListener != null) { + element.removeEventListener( + 'pointerdown', + registration.groupDownListener, + ); + registration.groupDownListener = null; + } + this.registry.delete(element); + this.uninstallIfIdle(); + } + + /** + * Listen only while something could take a touch now (`spec:AST-094` + * FR19, FR24): an enabled recognizer or consumer that takes input (where + * the primary pointer is coarse, or one attached with `perEventPointer`), + * or a finger-scroll watch. + * Otherwise no document listener is registered, so a desktop page and a + * disabled control pay nothing. + */ + private syncListeners(): void { + this.syncArmed(); + if (this.shouldListen()) { + this.install(); + this.syncClaimingListeners(); + } else { + this.uninstall(); + } + this.syncMoverBlocking(); + } + + /** + * Safari honours a blocking `touchmove` only where it was registered before + * the touch began, on the engine's own target, and on iOS WebKit a finger + * on an element a compositor animation moves lands, for the engine, on + * whatever sits under the motion's start (see presentedTarget). So while an + * element a recognizer that claims moving touches lives in (or around) is + * moving, the document carries the blocking listener (`spec:AST-094` + * FR19), and it comes off when the motion ends: only then does a scroll + * starting anywhere wait on the main thread. + */ + private syncMoverBlocking(): void { + const isWatching = this.isInstalled && this.hitTestsLayout(); + if (isWatching && this.stopWatchingMovers == null) { + this.stopWatchingMovers = watchPresentedElements(() => + this.syncMoverBlocking(), + ); + } else if (!isWatching && this.stopWatchingMovers != null) { + this.stopWatchingMovers(); + this.stopWatchingMovers = null; + } + const needed = + isWatching && + presentedElements().some(mover => + this.hasClaimingRecognizerAround(mover), + ); + if (needed === this.isBlockingForMovers) { + return; + } + this.isBlockingForMovers = needed; + if (needed) { + this.doc.addEventListener('touchmove', this.onBlockingTouchMove, { + passive: false, + }); + } else { + this.doc.removeEventListener('touchmove', this.onBlockingTouchMove); + } + } + + /** Whether a recognizer that claims moving touches lives in or around `mover`. */ + private hasClaimingRecognizerAround(mover: Element): boolean { + if (!mover.isConnected || mover.ownerDocument !== this.doc) { + return false; + } + for (const [element, registration] of this.registry) { + if ( + (mover.contains(element) || element.contains(mover)) && + registration.recognizers.some( + recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer), + ) + ) { + return true; + } + } + return false; + } + + /** + * Where the primary pointer is fine, an enabled perEventPointer press + * tracker's element listens for a finger's press itself (passive, on the + * element only): the host's document and window listeners are added at + * that press's start and removed at its end, so an idle tracker leaves + * none (spec:AST-094 FR19's exception, for a press's feedback only). + */ + private syncArmed(): void { + const wanted = new Set(); + if (!this.isCoarse) { + for (const [element, registration] of this.registry) { + const consumer = registration.consumer; + if ( + consumer != null && + this.perEventConsumers.has(consumer) && + consumer.isEnabled?.() !== false + ) { + wanted.add(element); + } + } + } + for (const [element, release] of this.armedElements) { + if (!wanted.has(element)) { + release(); + this.armedElements.delete(element); + } + } + for (const element of wanted) { + if (this.armedElements.has(element)) { + continue; + } + element.addEventListener( + 'touchstart', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + element.addEventListener( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + this.armedElements.set(element, () => { + element.removeEventListener( + 'touchstart', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + element.removeEventListener( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + }); + } + if (wanted.size === 0) { + this.isArmedPress = false; + } + } + + /** A finger's press on an armed element: listen for this press. */ + private readonly onArmedPress = (event: Event): void => { + if ( + this.isInstalled || + (event.type === 'pointerdown' && + (event as PointerEvent).pointerType !== 'touch') + ) { + return; + } + this.isArmedPress = true; + this.install(); + // The document's capture phase is over for this event: hand it on. + if (event.type === 'touchstart') { + this.onTouchStart(event as TouchEvent); + } else { + this.onPointerDown(event as PointerEvent); + } + }; + + /** After a contact ends: an armed press that is over stops listening. */ + private releaseArmedPress(): void { + if (!this.isArmedPress) { + return; + } + queueMicrotask(() => { + if (this.isArmedPress && this.arena.touches.length === 0) { + this.isArmedPress = false; + this.syncListeners(); + } + }); + } + + /** With nothing attached, stop listening and stop following the pointer. */ + private uninstallIfIdle(): void { + this.syncListeners(); + if (this.registry.size === 0 && this.retainers === 0) { + this.uninstall(); + this.stopWatchingPointer?.(); + this.stopWatchingPointer = null; + } + } + + /** Read the primary pointer, and follow it while anything is attached. */ + private watchPointer(): void { + if (this.stopWatchingPointer != null) { + return; + } + const view = this.doc.defaultView; + this.isCoarse = this.isPrimaryPointerCoarse(); + this.stopWatchingPointer = this.followsPointer + ? watchCoarsePointer(view, isCoarse => this.pointerChanged(isCoarse)) + : () => {}; + } + + /** + * The primary pointer turned coarse or fine (a tablet docking a mouse): + * install or remove everything, as if each attach happened now. + */ + private pointerChanged(isCoarse: boolean): void { + if (isCoarse === this.isCoarse) { + return; + } + this.isCoarse = isCoarse; + if (!isCoarse) { + this.cancelEverything(this.arena.now()); + } + for (const [element, registration] of this.registry) { + this.refresh(element, registration); + } + this.syncListeners(); + } + + /** Something attached where the primary pointer is fine: say so once. */ + private warnFinePointer(): void { + if (process.env.NODE_ENV !== 'production' && !this.hasWarnedFinePointer) { + this.hasWarnedFinePointer = true; + console.warn( + 'Astryx: a gesture recognizer or press tracker was attached where ' + + 'the primary pointer is fine. It stays inactive until the pointer ' + + 'is coarse (spec:AST-094 FR19), so attach touch work only under a ' + + 'touch presentation, or pass perEventPointer for a recognizer or ' + + "press tracker that reads each event's pointer itself (spec:AST-057).", + ); + } + } + + /** + * 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). + */ + private hasClaimer(): boolean { + for (const registration of this.registry.values()) { + const consumer = registration.consumer; + if ( + (consumer != null && + consumer.isEnabled?.() !== false && + this.isConsumerActive(consumer)) || + registration.recognizers.some( + recognizer => + recognizer.isEnabled && + recognizer.claimsTouches && + this.isActive(recognizer), + ) + ) { + return true; + } + } + return false; + } + + /** + * Keeps the touchend cancelable, and the contextmenu subscription on, + * only while a claimer is attached; with only watchers the touchend is + * passive and the contextmenu is off. Clicks are the click guard's + * (utils/clickGuard.ts), whose one listener a window gets only once a + * gesture first arms it. + */ + private syncClaimingListeners(): void { + if (!this.isInstalled) { + return; + } + const isClaiming = this.hasClaimer(); + if (isClaiming === this.isClaimingInstalled) { + return; + } + const view = this.doc.defaultView; + for (const unsubscribe of this.claimingUnsubscribers.splice(0)) { + unsubscribe(); + } + if (view != null) { + if (this.inputMode === 'auto') { + this.claimingUnsubscribers.push( + subscribeInput(view, 'touchend', this.onTouchEnd, { + cancels: isClaiming, + }), + ); + } + if (isClaiming) { + this.claimingUnsubscribers.push( + subscribeInput(view, 'contextmenu', this.onContextMenu, { + cancels: true, + }), + ); + } + } + this.isClaimingInstalled = isClaiming; + } + + private shouldListen(): boolean { + if (!this.needsInput()) { + return false; + } + // A retainer (a finger-scroll watch) listens only where the pointer is + // coarse: beside a per-event participant under a fine one it would keep + // every page listener installed for its whole life (FR24). + if (this.isCoarse && this.retainers > 0) { + return true; + } + let isArmed = false; + for (const registration of this.registry.values()) { + const consumer = registration.consumer; + if ( + consumer != null && + consumer.isEnabled?.() !== false && + this.isConsumerActive(consumer) + ) { + if (this.isCoarse) { + return true; + } + // A perEventPointer press tracker where the pointer is fine: only + // while a press it armed is in progress (syncArmed). + isArmed = true; + } + if ( + registration.recognizers.some( + recognizer => recognizer.isEnabled && this.isActive(recognizer), + ) || + (this.isCoarse && registration.hovers.some(r => r.isEnabled)) + ) { + return true; + } + } + return isArmed && this.isArmedPress; + } + + // --- Listeners ------------------------------------------------------------- + + private install(): void { + this.watchPointer(); + if (!this.needsInput()) { + return; + } + if (this.isInstalled) { + return; + } + const view = this.doc.defaultView; + this.isInstalled = true; + this.usesTouchEvents = + this.inputMode === 'auto' && view != null && 'ontouchstart' in view; + if (view == null) { + // A document without a window receives no input. + return; + } + // The window's capture phase, through the shared input stream + // (utils/inputStream.ts): ahead of any document listener, so an app's + // window-capture stopPropagation, or a document-capture + // stopImmediatePropagation registered first, no longer hides a touch + // from the recognizers. A recognizer that must claim mid-gesture still + // needs the touch to reach its element, where its blocking touchmove + // sits. Only the listeners that cancel are non-passive, and only while a + // claimer is attached (syncClaimingListeners); none of them is a + // touchstart or touchmove, so none of them holds a scroll. + this.unsubscribers = [ + // Pointer Events only: a finger comes from its pointer events alone. A + // browser that has Touch Events still sends them for the same finger, + // and reading both would track it twice (a long press needs one touch). + ...(this.inputMode === 'auto' + ? [ + subscribeInput(view, 'touchstart', this.onTouchStart), + subscribeInput(view, 'touchmove', this.onTouchMove), + subscribeInput(view, 'touchcancel', this.onTouchCancel), + ] + : []), + subscribeInput(view, 'pointerdown', this.onPointerDown), + subscribeInput(view, 'pointermove', this.onPointerMove), + subscribeInput(view, 'pointerup', this.onPointerUp), + subscribeInput(view, 'pointercancel', this.onPointerCancel), + subscribeInput(view, 'pointerout', this.onPointerOut), + subscribeInput(view, 'scroll', this.onScroll), + subscribeInput(view, 'visibilitychange', this.onVisibilityChange), + // The window's own blur; an element's blur (a Node's) reaches the + // window's capture phase too and means nothing here. + subscribeInput(view, 'blur', event => { + if (!(event.target instanceof view.Node)) { + this.onBlur(); + } + }), + ]; + // Passive until a claimer is attached (syncClaimingListeners). + this.isClaimingInstalled = false; + this.claimingUnsubscribers = + this.inputMode === 'auto' + ? [subscribeInput(view, 'touchend', this.onTouchEnd)] + : []; + } + + private uninstall(): void { + if (!this.isInstalled) { + return; + } + this.isInstalled = false; + // Unheard from here on: no fingers to keep. + this.fingerTargets = []; + this.isClaimingInstalled = false; + for (const unsubscribe of [ + ...this.unsubscribers.splice(0), + ...this.claimingUnsubscribers.splice(0), + ]) { + unsubscribe(); + } + this.cancelEverything(this.arena.now()); + this.lastTouch = null; + this.lastScroll = null; + } + + // --- Hit path -------------------------------------------------------------- + + /** + * The recognizers on the path from `target` to the document, innermost + * first, and (for a contact) its consumer: the innermost element with an + * attached consumer. + */ + private participantsAt( + target: unknown, + pointerType: PointerKind | null, + ): { + recognizers: GestureRecognizer[]; + consumer: TouchConsumer | null; + consumerElement: Element | null; + hovers: HoverGestureRecognizer[]; + } { + const recognizers: GestureRecognizer[] = []; + const hovers: HoverGestureRecognizer[] = []; + let consumer: TouchConsumer | null = null; + let consumerElement: Element | null = null; + if ( + target instanceof Node && + [...this.touchGates].some(gate => gate(target)) + ) { + return {recognizers, consumer, consumerElement, hovers}; + } + let hasConsumerOwner = pointerType == null; + for ( + let node: Node | null = target instanceof Node ? target : null; + node != null; + node = parentAcrossShadow(node) + ) { + if (!(node instanceof Element)) { + continue; + } + const registration = this.registry.get(node); + if (registration != null) { + // Where the primary pointer is fine, only perEventPointer + // recognizers take part (FR19; `spec:AST-057`). + recognizers.push( + ...registration.recognizers.filter(recognizer => + this.isActive(recognizer), + ), + ); + if (this.isCoarse) { + hovers.push(...registration.hovers); + } + } + if (hasConsumerOwner || pointerType == null) { + continue; + } + // Where the primary pointer is fine, only a perEventPointer consumer + // takes part (FR19); an inactive one is as if absent. + if ( + registration?.consumer != null && + this.isConsumerActive(registration.consumer) && + // Where the pointer is fine its press feedback is for a finger only: + // a mouse or a pen there keeps the desktop's own handling (FR19). + (this.isCoarse || pointerType === 'touch') + ) { + consumer = registration.consumer; + consumerElement = node; + hasConsumerOwner = true; + continue; + } + } + return {recognizers, consumer, consumerElement, hovers}; + } + + /** + * Turn text selection and the touch callout off, for as long as this + * finger or pen is down, only on the elements whose hold does something: + * a long press's (a menu, a hold-repeat), those of recognizers created + * with a `selectionLock` (a slider's thumb, a switch's drag, a tab bar's + * zero-delay lens) and that of a press tracker created with one (a + * stepper's hold-repeat). Anything whose hold does nothing, a plain + * press-tracked control or row included, keeps the browser's long-press + * selection (`spec:AST-094` FR41). Restored when the contact ends. Callers + * lock only where the primary pointer is coarse (locksSelectionFor), so + * desktop selection is untouched, and only once the arena has admitted + * the contact: `recognizers` and `consumer` are the ones that took it. + */ + private lockSelection( + id: number, + recognizers: ReadonlyArray, + target: EventTarget | null, + consumer: TouchConsumer | null, + consumerElement: Element | null, + ): void { + // Each recognizer whose hold does something brings its lock; the host + // never imports one (a component without such a hold does not bundle + // it). Only one that took this contact locks for it: a recognizer that + // declined it (its pointer, or its own shouldReceiveTouch) has no hold + // here. + const byLock = new Map>(); + for (const recognizer of recognizers) { + const lock = recognizer.selectionLock; + if (lock != null && recognizer.view instanceof Element) { + const elements = byLock.get(lock) ?? new Set(); + elements.add(recognizer.view); + byLock.set(lock, elements); + } + } + const consumerLock = consumer?.selectionLock; + if (consumerLock != null && consumerElement != null) { + const elements = byLock.get(consumerLock) ?? new Set(); + elements.add(consumerElement); + byLock.set(consumerLock, elements); + } + if (byLock.size > 0) { + const undo = [...byLock].map(([lock, elements]) => + lock(elements, target), + ); + this.lockedBy.set(id, () => { + for (const release of undo) { + release(); + } + }); + } + } + + /** Undo `lockSelection` for a contact that ended; idempotent. */ + private unlockSelection(id: number): void { + this.lockedBy.get(id)?.(); + this.lockedBy.delete(id); + } + + // --- Touch events ---------------------------------------------------------- + + /** + * Hold touches away from every participant while `gate(target)` answers + * true (a presentation running in a layer: its content takes no touch + * until it settles). Returns the release. + */ + addTouchGate(gate: (target: Node) => boolean): () => void { + this.touchGates.add(gate); + return () => { + this.touchGates.delete(gate); + }; + } + + /** + * How many fingers are on the screen: the length of `touches` in the last + * touch event the host's listeners heard (`touchstart`, `touchmove`, + * `touchend`, `touchcancel`), whatever its target, so a finger on an + * element with no recognizer counts too. 0 before any, and while no + * listener is installed. A recognizer that must not begin under a second + * finger reads it in `shouldBegin`. + */ + fingersDown(): number { + return this.fingerTargets.length; + } + + /** Whether this event is new to the host (and mark it handled). */ + private takeEvent(event: Event): boolean { + if (this.processed.has(event)) { + return false; + } + this.processed.add(event); + return true; + } + + private readonly onTouchStart = (event: TouchEvent): void => { + 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. + if (!this.usesTouchEvents || !this.takeEvent(event)) { + return; + } + if (this.fingerTargets.length > 1) { + this.failUnclaimedLoneFingerPans(); + } + const time = eventTime(event); + const inputs: ArenaBeganInput[] = []; + const untracked: TouchBeganInput[] = []; + const stale: number[] = []; + // Locked once the arena knows which recognizers took each finger. + const toLock: { + id: number; + target: EventTarget | null; + consumerElement: Element | null; + }[] = []; + for (const touch of touchesOf(event.changedTouches)) { + if ((touch as StylusTouch).touchType === 'stylus') { + continue; + } + // A finger number still tracked here began again: the touch it named + // ended where no listener of ours could hear it. + this.cancelledFingers.delete(touch.identifier); + const previous = this.forgetFinger(touch.identifier); + if (previous != null) { + stale.push(previous); + } + const native = + touch.target instanceof Node + ? landedOn(event, touch.target) + : landedOn(event); + const target = this.presentedTarget(native, touch.clientX, touch.clientY); + const {recognizers, consumer, consumerElement} = this.participantsAt( + target, + 'touch', + ); + if (recognizers.length === 0 && consumer == null) { + // Nothing here takes part: the touch stays the page's, but a + // recognizer tracking another finger hears it began (a second + // finger beside an open menu ends its press, spec:AST-094 FR28). + untracked.push({ + id: this.nextId++, + x: touch.clientX, + y: touch.clientY, + time, + pointerType: 'touch', + target, + }); + continue; + } + const id = this.nextId++; + this.touchIds.set(touch.identifier, id); + if (locksSelectionFor('touch', this.doc.defaultView)) { + toLock.push({id, target, consumerElement}); + } + // The finger's events keep going to what the engine hit. + this.watchTarget( + touch.identifier, + native, + recognizers.some(claimsWhileMoving) || + (this.isCoarse && consumer?.keepsTouch != null), + consumer != null || + recognizers.some(recognizer => recognizer.claimsTouches), + ); + this.lastFed.set(id, {x: touch.clientX, y: touch.clientY, time}); + this.pairTouch(id, {x: touch.clientX, y: touch.clientY, target: native}); + if (target !== native && target != null) { + this.retargeted.set(id, {x: touch.clientX, y: touch.clientY, target}); + } + inputs.push({ + id, + x: touch.clientX, + y: touch.clientY, + time, + pointerType: 'touch', + target, + scroll: createNativeScrollProbe(target), + recognizers, + consumer, + // Where the pointer is fine only per-event participants hear it, and + // a press tracker among them gives press feedback only (FR19). + ...(this.isCoarse ? {} : {isFeedbackOnly: true}), + }); + } + if (stale.length > 0) { + this.arena.cancelled(stale, time); + } + this.arena.began(inputs); + for (const {id, target, consumerElement} of toLock) { + this.lockSelection( + id, + this.arena.participantsOf(id), + target, + this.arena.consumerOf(id) ?? null, + consumerElement, + ); + } + for (const input of untracked) { + this.arena.untrackedBegan(input); + } + // Stamped after the consumers judged the touch: the brake asks whether + // an EARLIER finger is still driving a scroll. + const landed = landedOn(event); + if (landed instanceof Node) { + this.lastTouch = {target: landed, time}; + } + }; + + private readonly onTouchMove = (event: TouchEvent): void => { + 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. + if (!this.usesTouchEvents || !this.takeEvent(event)) { + return; + } + this.dropFingersNotDown(event); + const time = eventTime(event); + const ids: number[] = []; + const inputs: TouchInput[] = []; + for (const touch of touchesOf(event.changedTouches)) { + const id = this.touchIds.get(touch.identifier); + if (id == null) { + continue; + } + ids.push(id); + inputs.push({id, x: touch.clientX, y: touch.clientY, time}); + } + if (inputs.length === 0) { + return; + } + if (!event.cancelable) { + // The browser no longer asks the page about these fingers. WebKit + // stops asking when it takes the touch to pan (its pointercancel comes + // with it); Chromium also stops after one unconsumed touchmove, even + // when `touch-action` keeps it from panning the way the finger went (a + // horizontal drag under pan-y). So a finger's FIRST uncancelable move + // counts as the browser's claim only if the browser could pan along + // the finger's main axis since touch-down; later ones add nothing, and + // pointercancel or a scroll still report any real pan. + const placed = new Map(inputs.map(input => [input.id, input])); + const taken = ids.filter(id => { + const touch = this.arena.getTouch(id); + if ( + touch == null || + touch.takenByBrowser || + this.arena.isClaimed(id) || + touch.scroll?.allowsNativePan === false || + this.judgedUncancelable.has(id) + ) { + return false; + } + this.judgedUncancelable.add(id); + const input = placed.get(id); + if (input == null || touch.scroll == null) { + return true; + } + const dx = input.x - touch.startX; + const dy = input.y - touch.startY; + return Math.abs(dx) >= Math.abs(dy) + ? touch.scroll.canScroll(dx, 0) + : touch.scroll.canScroll(0, dy); + }); + if (taken.length > 0) { + this.browserTook(taken, time); + } + } + // A finger its pointer events sample is fed from there, never from here. + this.feedMoves(inputs.filter(input => !this.pointerFed.has(input.id))); + }; + + /** The one blocking `touchmove` handler every claiming element shares. */ + private readonly onBlockingTouchMove = (event: Event): void => { + this.cancelIfClaimed(event as TouchEvent); + }; + + /** + * Cancel a `touchmove` while a recognizer owns one of its fingers. A touch + * no recognizer owns keeps its moves (`spec:AST-094` FR19): nothing is + * cancelled before a recognizer begins, or for a touch it does not track, + * unless a recognizer holds that finger already (a scroll-edge pan pulling + * away from an edge every scroller under it is at; FR19, DEC-18). + */ + /** + * Under `inputMode: 'pointer-events'` a finger comes from its pointer + * events, so its touches carry no id of ours: the live finger it is, the + * nearest pointer-fed finger on the same target. Its touchmoves can then + * be cancelled once a recognizer claims it (`spec:AST-094` FR19, FR21). + */ + private pointerFingerAt(touch: Touch): number | undefined { + let best: GestureTouch | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const finger of this.arena.touches) { + if ( + finger.pointerType !== 'touch' || + finger.phase === 'ended' || + finger.phase === 'cancelled' || + !(finger.target instanceof Node) || + !(touch.target instanceof Node) || + // Along the flat tree, both ways: the finger's target can be inside + // an open shadow root while its touches reach the host retargeted. + !( + containsAlongFlatTree(finger.target, touch.target) || + containsAlongFlatTree(touch.target, finger.target) + ) + ) { + continue; + } + const d = Math.hypot(finger.x - touch.clientX, finger.y - touch.clientY); + if (d < bestDistance) { + best = finger; + bestDistance = d; + } + } + return best?.id; + } + + private cancelIfClaimed(event: TouchEvent): void { + if (!event.cancelable || event.defaultPrevented) { + return; + } + // Only the touches this move reports: another finger a recognizer owns + // does not make this one its own. + const moved = touchesOf(event.changedTouches); + for (const touch of moved) { + const id = + this.touchIds.get(touch.identifier) ?? + // Where fingers come from pointer events (inputMode 'pointer-events', + // or 'auto' with no ontouchstart), the touch is matched to its pointer. + (this.usesTouchEvents ? undefined : this.pointerFingerAt(touch)); + if ( + id != null && + (this.arena.isClaimed(id) || + (this.isCoarse && + this.arena.consumerOf(id)?.keepsTouch?.(id) === true) || + // A recognizer that holds the finger before it begins: a + // scroll-edge pan pulling away from an edge (FR19, DEC-18), only + // while this is the one finger on the screen: with a second one + // down anywhere, even off the recognizer's element, the moves stay + // the browser's, for its pinch (FR10). + (event.touches.length === 1 && + this.arena + .participantsOf(id) + .some(recognizer => internals(recognizer).keepsTouch(id)))) + ) { + event.preventDefault(); + return; + } + } + if (moved.some(touch => (touch as StylusTouch).touchType === 'stylus')) { + for (const id of this.pointerIds.values()) { + if (this.arena.isClaimed(id)) { + // A pen whose pointer a recognizer owns: its stylus touches too, + // never a finger no recognizer owns. + event.preventDefault(); + return; + } + } + } + } + + private readonly onTouchEnd = (event: TouchEvent): void => { + this.releaseArmedPress(); + 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. + if (!this.usesTouchEvents || !this.takeEvent(event)) { + return; + } + this.dropFingersNotDown(event); + const time = eventTime(event); + const inputs: TouchInput[] = []; + const retargetedEnds: { + id: number; + landed: {x: number; y: number; target: EventTarget}; + x: number; + y: number; + }[] = []; + let isRefused = false; + for (const touch of touchesOf(event.changedTouches)) { + if (this.cancelledFingers.delete(touch.identifier)) { + isRefused = true; + continue; + } + const tracked = this.touchIds.get(touch.identifier); + const landed = tracked == null ? undefined : this.retargeted.get(tracked); + const id = this.forgetFinger(touch.identifier); + if (id == null) { + continue; + } + inputs.push({id, x: touch.clientX, y: touch.clientY, time}); + if (landed != null) { + retargetedEnds.push({id, landed, x: touch.clientX, y: touch.clientY}); + } + } + // The lift is when momentum starts: the clock the brake wants most. + const landed = landedOn(event); + if (landed instanceof Node) { + this.lastTouch = {target: landed, time}; + } + const dispositions = + inputs.length > 0 + ? this.arena.ended(inputs) + : new Map(); + let cancelsClick = isRefused || needsSuppression(dispositions); + // A tap sent to what is drawn: the engine's click would hit the layout + // position, so it is cancelled and the tap clicks what it touched. + for (const end of retargetedEnds) { + if (this.clickRetargetedTap(end.landed, end, dispositions.get(end.id))) { + cancelsClick = true; + } + } + if (cancelsClick) { + if (event.cancelable) { + event.preventDefault(); + } + armClickGuard(this.doc); + } + }; + + /** + * Clicks what a retargeted contact touched if it ended as a tap the + * engine would click: nothing claimed it, the browser did not take it + * (a taken contact is no longer retargeted), and the finger stayed within + * a tap's movement. Returns whether it clicked. + */ + private clickRetargetedTap( + landed: {x: number; y: number; target: EventTarget} | undefined, + end: {x: number; y: number}, + disposition: ClickDisposition | undefined, + ): boolean { + if (landed == null || disposition !== 'allow') { + return false; + } + const moved = Math.hypot(end.x - landed.x, end.y - landed.y); + if (moved > UIKIT_TAP.allowableMovement.value) { + return false; + } + this.clickRetargeted(landed.target, end.x, end.y); + return true; + } + + private readonly onTouchCancel = (event: TouchEvent): void => { + this.releaseArmedPress(); + 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. + if (!this.usesTouchEvents || !this.takeEvent(event)) { + return; + } + const listed = touchesOf(event.changedTouches); + // A cancel that names no finger is the system taking them all. + const identifiers = + listed.length > 0 + ? listed.map(touch => touch.identifier) + : [...this.touchIds.keys()]; + if (listed.length === 0) { + this.cancelledFingers.clear(); + } + for (const identifier of identifiers) { + this.cancelledFingers.delete(identifier); + } + const ids: number[] = []; + for (const identifier of identifiers) { + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + if (ids.length > 0) { + this.arena.cancelled(ids, eventTime(event)); + } + }; + + /** The target's own listeners, for a target that left the document. */ + private readonly onTargetTouchMove = (event: Event): void => { + this.onTouchMove(event as TouchEvent); + this.cancelIfClaimed(event as TouchEvent); + }; + + /** The same for a finger nothing could claim while it moves: never blocks. */ + private readonly onTargetTouchMovePassive = (event: Event): void => { + this.onTouchMove(event as TouchEvent); + }; + + private readonly onTargetTouchEnd = (event: Event): void => { + this.onTouchEnd(event as TouchEvent); + }; + + /** The same, registered passively for a touch only watchers follow. */ + private readonly onTargetTouchEndPassive = (event: Event): void => { + this.onTouchEnd(event as TouchEvent); + }; + + private readonly onTargetTouchCancel = (event: Event): void => { + this.onTouchCancel(event as TouchEvent); + }; + + /** + * Cancel tracked fingers the event says are no longer down. `touches` + * lists every finger on the screen; one missing from it (and not lifting + * in this very event) ended where we could not see it. + */ + private dropFingersNotDown(event: TouchEvent): void { + if ( + event.touches == null || + (this.touchIds.size === 0 && this.cancelledFingers.size === 0) + ) { + return; + } + const down = new Set(); + for (const touch of touchesOf(event.touches)) { + down.add(touch.identifier); + } + for (const touch of touchesOf(event.changedTouches)) { + down.add(touch.identifier); + } + for (const identifier of [...this.cancelledFingers]) { + if (!down.has(identifier)) { + this.cancelledFingers.delete(identifier); + } + } + const gone = [...this.touchIds.keys()].filter( + identifier => !down.has(identifier), + ); + const ids: number[] = []; + for (const identifier of gone) { + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + if (ids.length > 0) { + this.arena.cancelled(ids, eventTime(event)); + } + } + + private watchTarget( + identifier: number, + target: EventTarget | null, + isBlocking: boolean, + isClaiming: boolean, + ): void { + if (target == null || typeof target.addEventListener !== 'function') { + return; + } + this.touchTargets.set(identifier, target); + const state = this.watched.get(target); + if (state == null) { + this.watched.set(target, { + count: 1, + blocking: isBlocking ? 1 : 0, + claiming: isClaiming ? 1 : 0, + }); + if (isBlocking) { + this.blockingFingers.add(identifier); + } + if (isClaiming) { + this.claimingFingers.add(identifier); + } + target.addEventListener( + 'touchmove', + isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + {passive: !isBlocking}, + ); + target.addEventListener( + 'touchend', + isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + {passive: !isClaiming}, + ); + target.addEventListener('touchcancel', this.onTargetTouchCancel, { + passive: true, + }); + return; + } + const wasBlocking = state.blocking > 0; + const wasClaiming = state.claiming > 0; + state.count += 1; + if (isBlocking) { + state.blocking += 1; + this.blockingFingers.add(identifier); + } + if (isClaiming) { + state.claiming += 1; + this.claimingFingers.add(identifier); + } + this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0); + } + + private unwatchTarget(identifier: number): void { + const target = this.touchTargets.get(identifier); + if (target == null) { + return; + } + this.touchTargets.delete(identifier); + const state = this.watched.get(target); + if (state == null) { + return; + } + const wasBlocking = state.blocking > 0; + const wasClaiming = state.claiming > 0; + state.count -= 1; + if (this.blockingFingers.delete(identifier)) { + state.blocking -= 1; + } + if (this.claimingFingers.delete(identifier)) { + state.claiming -= 1; + } + if (state.count > 0) { + this.swapTargetTouchMove(target, wasBlocking, state.blocking > 0); + this.swapTargetTouchEnd(target, wasClaiming, state.claiming > 0); + return; + } + this.watched.delete(target); + target.removeEventListener( + 'touchmove', + wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + ); + target.removeEventListener( + 'touchend', + wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + ); + target.removeEventListener('touchcancel', this.onTargetTouchCancel); + } + + /** + * Keeps one `touchmove` listener on a watched target: the blocking one + * while a finger on it could be claimed as it moves, a passive one + * otherwise, so a scroll starting there never waits on the main thread + * (`spec:AST-094` FR19). + */ + private swapTargetTouchMove( + target: EventTarget, + wasBlocking: boolean, + isBlocking: boolean, + ): void { + if (wasBlocking === isBlocking) { + return; + } + target.removeEventListener( + 'touchmove', + wasBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + ); + target.addEventListener( + 'touchmove', + isBlocking ? this.onTargetTouchMove : this.onTargetTouchMovePassive, + {passive: !isBlocking}, + ); + } + + /** + * Keeps one `touchend` listener on a watched target: the cancelable one + * while a finger on it has a participant that could claim (a lift whose + * click must not happen cancels its `touchend`), a passive one while only + * watchers follow its fingers (`spec:AST-094` FR20). + */ + private swapTargetTouchEnd( + target: EventTarget, + wasClaiming: boolean, + isClaiming: boolean, + ): void { + if (wasClaiming === isClaiming) { + return; + } + target.removeEventListener( + 'touchend', + wasClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + ); + target.addEventListener( + 'touchend', + isClaiming ? this.onTargetTouchEnd : this.onTargetTouchEndPassive, + {passive: !isClaiming}, + ); + } + + /** Stop tracking a finger by its Touch.identifier; returns its arena id. */ + private forgetFinger(identifier: number): number | undefined { + const id = this.touchIds.get(identifier); + this.touchIds.delete(identifier); + this.unwatchTarget(identifier); + if (id != null) { + this.forgetContact(id); + } + return id; + } + + private pairTouch(id: number, place: PlacedContact): void { + for (const [pointerId, pending] of this.pendingPointers) { + if (isSamePlace(pending, place)) { + this.pendingPointers.delete(pointerId); + this.fingerPointers.set(pointerId, id); + this.pointerFed.add(id); + return; + } + } + this.unpairedTouches.set(id, place); + } + + private pairPointer(pointerId: number, place: PlacedContact): void { + for (const [id, unpaired] of this.unpairedTouches) { + if (isSamePlace(unpaired, place)) { + this.unpairedTouches.delete(id); + this.fingerPointers.set(pointerId, id); + this.pointerFed.add(id); + return; + } + } + this.pendingPointers.set(pointerId, place); + } + + /** Feed moves from a contact's sampling channel, in time order. */ + private feedMoves(inputs: ReadonlyArray): void { + const fresh = inputs.filter(input => { + const last = this.lastFed.get(input.id); + if (last != null && input.time < last.time) { + return false; + } + this.lastFed.set(input.id, {x: input.x, y: input.y, time: input.time}); + return true; + }); + if (fresh.length > 0) { + this.arena.moved(fresh); + } + } + + /** Forget a contact's pairing and echo state. */ + private forgetContact(id: number): void { + this.unlockSelection(id); + this.retargeted.delete(id); + this.lastFed.delete(id); + this.pointerFed.delete(id); + this.judgedUncancelable.delete(id); + this.unpairedTouches.delete(id); + for (const [pointerId, paired] of this.fingerPointers) { + if (paired === id) { + this.fingerPointers.delete(pointerId); + } + } + } + + // --- Pointer events -------------------------------------------------------- + + private readonly onPointerDown = (event: PointerEvent): void => { + // A new press: whatever click is still owed to the last one is not this. + disarmClickGuard(this.doc); + if (!isPointerKind(event.pointerType)) { + return; + } + this.exitHover(event.pointerId); + if (event.pointerType === 'touch' && this.usesTouchEvents) { + this.pairPointer(event.pointerId, { + x: event.clientX, + y: event.clientY, + target: landedOn(event), + }); + return; + } + if (event.pointerType === 'mouse' && event.button !== 0) { + return; + } + const landed = landedOn(event); + const target = this.presentedTarget(landed, event.clientX, event.clientY); + const {recognizers, consumer, consumerElement} = this.participantsAt( + target, + event.pointerType, + ); + if (recognizers.length === 0 && consumer == null) { + return; + } + const id = this.nextId++; + this.pointerIds.set(event.pointerId, id); + if (target !== landed && target != null) { + this.retargeted.set(id, {x: event.clientX, y: event.clientY, target}); + } + this.arena.began([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + pointerType: event.pointerType, + target, + scroll: createNativeScrollProbe(target), + recognizers, + consumer, + ...(this.isCoarse ? {} : {isFeedbackOnly: true}), + }, + ]); + if (locksSelectionFor(event.pointerType, this.doc.defaultView)) { + // The presented target: what the pen is on, where the engine's own + // hit test may still see the motion's start. + this.lockSelection( + id, + this.arena.participantsOf(id), + target, + this.arena.consumerOf(id) ?? null, + consumerElement, + ); + } + }; + + /** The pointer of the group press being served under a fine pointer. */ + private groupPressPointer: number | null = null; + /** The input stream subscriptions one group press holds until it lifts. */ + private groupPressUnsubscribers: (() => void)[] = []; + + /** + * A press on an action group under a fine pointer (`family:action-groups` + * FR5): the group's recognizers and consumer take it, nothing else on the + * path does (`spec:AST-094` FR19), and the page listens only until it + * lifts (FR24). Where the primary pointer is coarse the host's own + * listeners serve the group, as every other recognizer. + */ + private beginGroupPress( + event: PointerEvent, + registration: Registration, + ): void { + if ( + this.isInstalled || + this.groupPressPointer != null || + !isPointerKind(event.pointerType) || + (event.pointerType === 'mouse' && event.button !== 0) + ) { + return; + } + const recognizers = registration.recognizers.filter( + r => r.isEnabled && internals(r).acceptsTouchInput, + ); + const consumer = + registration.consumer != null && + registration.consumer.isEnabled?.() !== false + ? registration.consumer + : null; + if (recognizers.length === 0 && consumer == null) { + return; + } + disarmClickGuard(this.doc); + const id = this.nextId++; + this.pointerIds.set(event.pointerId, id); + this.groupPressPointer = event.pointerId; + this.listenForGroupPress(true); + this.arena.began([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + pointerType: event.pointerType, + target: event.target, + scroll: createNativeScrollProbe(event.target), + recognizers, + consumer, + }, + ]); + } + + /** + * The input one group press needs while it is held, through the shared + * input stream (utils/inputStream.ts) like every host listener: its + * pointer's moves and lift, and the window's own blur. The click a lift + * owes is the click guard's (utils/clickGuard.ts), which hears every click + * through its own one listener per window. + */ + private listenForGroupPress(isListening: boolean): void { + for (const unsubscribe of this.groupPressUnsubscribers.splice(0)) { + unsubscribe(); + } + const view = this.doc.defaultView; + if (!isListening || view == null) { + return; + } + this.groupPressUnsubscribers.push( + subscribeInput(view, 'pointermove', this.onGroupPointer), + subscribeInput(view, 'pointerup', this.onGroupPointer), + subscribeInput(view, 'pointercancel', this.onGroupPointer), + // The window's own blur; 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)) { + this.onGroupBlur(); + } + }), + ); + } + + /** + * A group press's own events, finger, pen or mouse alike, through the + * pointer path: under a fine pointer the host reads no Touch Events, so + * the touch pairing the installed host does for a finger never applies. + */ + private readonly onGroupPointer = (event: PointerEvent): void => { + if (event.pointerId !== this.groupPressPointer) { + return; + } + const id = this.pointerIds.get(event.pointerId); + if (event.type === 'pointermove') { + if (id != null) { + this.arena.moved([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + history: coalescedHistory(event), + }, + ]); + } + return; + } + this.pointerIds.delete(event.pointerId); + if (id != null) { + if (event.type === 'pointerup') { + const dispositions = this.arena.ended([ + {id, x: event.clientX, y: event.clientY, time: eventTime(event)}, + ]); + if (needsSuppression(dispositions)) { + armClickGuard(this.doc); + } + } else { + this.arena.cancelled([id], eventTime(event)); + } + } + this.endGroupPress(); + }; + + private readonly onGroupBlur = (): void => { + const pointer = this.groupPressPointer; + const id = pointer == null ? undefined : this.pointerIds.get(pointer); + if (pointer != null && id != null) { + this.pointerIds.delete(pointer); + this.arena.cancelled([id], this.arena.now()); + } + this.endGroupPress(); + }; + + /** The press lifted: its subscriptions go now. */ + private endGroupPress(): void { + this.groupPressPointer = null; + this.listenForGroupPress(false); + } + + private readonly onPointerMove = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && this.usesTouchEvents) { + const id = this.fingerPointers.get(event.pointerId); + if (id != null) { + this.feedMoves([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + history: coalescedHistory(event), + }, + ]); + } + return; + } + const id = this.pointerIds.get(event.pointerId); + if (id != null) { + this.arena.moved([ + { + id, + x: event.clientX, + y: event.clientY, + time: eventTime(event), + history: coalescedHistory(event), + }, + ]); + return; + } + if (event.pointerType !== 'touch' && event.buttons === 0) { + this.updateHover(event); + } + }; + + private readonly onPointerUp = (event: PointerEvent): void => { + this.releaseArmedPress(); + if (event.pointerType === 'touch' && this.usesTouchEvents) { + // The lift itself is the touchend's: it alone can cancel the click. + this.fingerPointers.delete(event.pointerId); + this.pendingPointers.delete(event.pointerId); + return; + } + const id = this.pointerIds.get(event.pointerId); + if (id == null) { + return; + } + this.pointerIds.delete(event.pointerId); + this.unlockSelection(id); + const dispositions = this.arena.ended([ + {id, x: event.clientX, y: event.clientY, time: eventTime(event)}, + ]); + // A retargeted tap's own click arms the click guard for the engine's. + const landed = this.retargeted.get(id); + this.retargeted.delete(id); + const clicked = this.clickRetargetedTap( + landed, + {x: event.clientX, y: event.clientY}, + dispositions.get(id), + ); + if (!clicked && needsSuppression(dispositions)) { + armClickGuard(this.doc); + } + }; + + private readonly onPointerCancel = (event: PointerEvent): void => { + this.releaseArmedPress(); + if (event.pointerType === 'touch' && this.usesTouchEvents) { + const id = + this.fingerPointers.get(event.pointerId) ?? + this.touchForCancelledPointer(event); + this.fingerPointers.delete(event.pointerId); + this.pendingPointers.delete(event.pointerId); + if (id != null) { + // The pointer channel ends here while the finger keeps moving (the + // browser scrolls with it): its touch events, now the only channel, + // sample it, as a watcher following the scroll to the lift needs. + this.pointerFed.delete(id); + this.browserTook([id], eventTime(event)); + } + return; + } + const id = this.pointerIds.get(event.pointerId); + if (id != null) { + this.pointerIds.delete(event.pointerId); + this.unlockSelection(id); + this.retargeted.delete(id); + this.arena.cancelled([id], eventTime(event)); + } + }; + + /** + * Which live finger a touch `pointercancel` is about. The pointer keeps + * the target the finger landed on (implicit capture), as the Touch does; + * among fingers on that target, the nearest. WebKit also numbers the + * pointer like the Touch, which settles what position cannot. + */ + private touchForCancelledPointer(event: PointerEvent): number | null { + let best: GestureTouch | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const touch of this.arena.touches) { + if ( + touch.pointerType !== 'touch' || + touch.phase === 'ended' || + touch.phase === 'cancelled' || + touch.target !== landedOn(event) + ) { + continue; + } + const d = Math.hypot(touch.x - event.clientX, touch.y - event.clientY); + if (d < bestDistance) { + best = touch; + bestDistance = d; + } + } + if (best != null) { + return best.id; + } + return this.touchIds.get(event.pointerId) ?? null; + } + + // --- Hover ------------------------------------------------------------------- + + private updateHover(event: PointerEvent): void { + if (!isPointerKind(event.pointerType)) { + return; + } + const {hovers} = this.participantsAt(landedOn(event), null); + const next = new Set(hovers.filter(hover => hover.isEnabled)); + const previous = this.hovered.get(event.pointerId) ?? new Set(); + if (next.size === 0 && previous.size === 0) { + return; + } + const point = {x: event.clientX, y: event.clientY}; + for (const hover of previous) { + if (!next.has(hover)) { + hover.hoverExited(point); + } + } + for (const hover of next) { + if (previous.has(hover)) { + hover.hoverMoved(point); + } else { + hover.hoverEntered(point, event.pointerType); + } + } + if (next.size === 0) { + this.hovered.delete(event.pointerId); + } else { + this.hovered.set(event.pointerId, next); + } + } + + private exitHover(pointerId: number): void { + const hovers = this.hovered.get(pointerId); + if (hovers == null) { + return; + } + this.hovered.delete(pointerId); + for (const hover of hovers) { + hover.hoverExited(); + } + } + + private readonly onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget == null) { + this.exitHover(event.pointerId); + } + }; + + // --- Clicks, menus, scrolls, focus ----------------------------------------- + + private replayClick(touch: GestureTouch, allowed: boolean): void { + if (!allowed) { + return; + } + // The browser's own click was swallowed while recognizers decided; they + // all failed, so the click the touch earned is delivered now. + disarmClickGuard(this.doc); + const target = touch.target; + const view = this.doc.defaultView; + if (!(target instanceof Element) || !target.isConnected || view == null) { + return; + } + withoutClickGuard(() => + target.dispatchEvent( + new view.MouseEvent('click', { + bubbles: true, + cancelable: true, + composed: true, + detail: 1, + clientX: touch.x, + clientY: touch.y, + }), + ), + ); + } + + private readonly onContextMenu = (event: MouseEvent): void => { + for (const touch of this.arena.touches) { + if (this.arena.isClaimed(touch.id)) { + event.preventDefault(); + return; + } + } + }; + + /** + * A recognizer is about to scroll `scroller` itself (a menu's edge + * autoscroll, spec:AST-094 FR30): the scroll that write fires is not a + * finger's and not the browser taking one. It passes until the end of the + * next frame, when that event has fired; a real pan in that window still + * reports itself through pointercancel or touchcancel. + */ + ownScroll(scroller: Node): void { + const call = ++this.ownScrollCalls; + this.ownedScrolls.set(scroller, call); + const view = this.doc.defaultView; + if (view == null) { + return; + } + view.requestAnimationFrame(() => { + view.requestAnimationFrame(() => { + // A newer call keeps it until its own frame has passed. + if (this.ownedScrolls.get(scroller) === call) { + this.ownedScrolls.delete(scroller); + } + }); + }); + } + + /** + * @internal Whether a recognizer is scrolling `scroller` itself now + * (ownScroll): that scroll is not a finger's (fingerScroll.ts reads it). + */ + isOwnScroll(scroller: Node): boolean { + return this.ownedScrolls.has(scroller); + } + + private readonly onScroll = (event: Event): void => { + const time = eventTime(event); + const scroller = event.target === this.doc ? this.doc : event.target; + if (!(scroller instanceof Node) || this.ownedScrolls.has(scroller)) { + return; + } + // A scroll is a finger's only if that finger was inside this scroller, + // recently; anything else is the program moving something. + const last = this.lastTouch; + if ( + last != null && + time - last.time < UIKIT_PRESS.momentumMemory.value && + containsAlongFlatTree(scroller, last.target) + ) { + this.lastScroll = {scroller, time}; + } + const taken: number[] = []; + for (const touch of this.arena.touches) { + if ( + touch.takenByBrowser || + touch.phase === 'ended' || + touch.phase === 'cancelled' || + touch.pointerType !== 'touch' || + this.arena.isClaimed(touch.id) + ) { + continue; + } + if ( + touch.target instanceof Node && + containsAlongFlatTree(scroller, touch.target) && + // Only a pan this finger drove takes the touch: the browser starts + // one only once the finger has travelled its slop. A scroll under a + // finger that has not (scrollIntoView, a transcript pinned to its + // end, scroll anchoring) is the page's own: the touch, and the click + // it earns, stay where they are. + Math.hypot(touch.x - touch.startX, touch.y - touch.startY) >= + UIKIT_PRESS.slop.value + ) { + taken.push(touch.id); + } + } + if (taken.length > 0) { + this.browserTook(taken, time); + } + }; + + private noteFingersDown(event: TouchEvent): void { + const fingers: Node[] = []; + for (const touch of touchesOf(event.touches)) { + if ((touch as StylusTouch).touchType !== 'stylus') { + if (touch.target instanceof Node) { + fingers.push(touch.target); + } + } + } + this.fingerTargets = fingers; + for (const listener of [...this.fingerListeners]) { + listener(); + } + } + + /** + * A second finger landed somewhere (wherever: often on a plain row): a + * pan that takes one finger only and has not claimed its touch fails, so + * the two fingers stay the browser's pinch (WCAG 1.4.4) instead of the + * pan claiming them midway. A drag a recognizer has claimed keeps its + * finger: a second finger never cancels or redirects it (`spec:AST-094` + * FR10), and no claim is released here. + */ + private failUnclaimedLoneFingerPans(): void { + for (const recognizer of [...this.arena.involvedRecognizers]) { + if ( + isPanRecognizer(recognizer) && + recognizer.state === 'possible' && + recognizer.maximumNumberOfTouches === 1 && + recognizer.trackedTouches.length > 0 && + recognizer.trackedTouches.every( + touch => + touch.pointerType === 'touch' && !this.arena.isClaimed(touch.id), + ) + ) { + recognizer.cancel(); + } + } + } + + private readonly onVisibilityChange = (): void => { + if (this.doc.visibilityState === 'hidden') { + this.cancelEverything(this.arena.now()); + } + }; + + private readonly onBlur = (): void => { + this.cancelEverything(this.arena.now()); + }; + + /** The app lost focus: UIKit cancels every touch in flight. */ + private cancelEverything(time: number): void { + const ids = [...this.pointerIds.values()]; + for (const id of ids) { + this.unlockSelection(id); + } + for (const identifier of [...this.touchIds.keys()]) { + // The browser may keep the touch alive: its lift must not click. + this.cancelledFingers.add(identifier); + const id = this.forgetFinger(identifier); + if (id != null) { + ids.push(id); + } + } + this.pointerIds.clear(); + this.retargeted.clear(); + this.fingerPointers.clear(); + this.pendingPointers.clear(); + this.unpairedTouches.clear(); + this.lastFed.clear(); + this.pointerFed.clear(); + if (ids.length > 0) { + this.arena.cancelled(ids, time); + } + for (const pointerId of [...this.hovered.keys()]) { + this.exitHover(pointerId); + } + } +} + +function needsSuppression( + dispositions: Map, +): boolean { + for (const disposition of dispositions.values()) { + if (disposition !== 'allow') { + return true; + } + } + return false; +} + +function coalescedHistory(event: PointerEvent): TouchSample[] { + if (typeof event.getCoalescedEvents !== 'function') { + return []; + } + const coalesced = event.getCoalescedEvents(); + return coalesced.slice(0, -1).map(sample => ({ + x: sample.clientX, + y: sample.clientY, + time: eventTime(sample), + })); +} + +/** + * iOS WebKit, by the one property only it implements (the engine check of + * utils/touchPresentation.ts, which this module cannot import: it brings + * React). It gates one behaviour, presented hit testing, an engine + * workaround (`spec:AST-013` FR6) with its review condition and regression + * fixture in motion/presentation.ts. + */ +function isIosWebKit( + view: (Window & {CSS?: Partial}) | null, +): boolean { + return view?.CSS?.supports?.('-webkit-touch-callout', 'none') === true; +} + +/** The gesture host of a document, created on first use. */ +export function getGestureHost(doc: Document): GestureHost { + let host = gestureHosts.get(doc); + if (host == null) { + host = new GestureHost(doc); + gestureHosts.set(doc, host); + } + return host; +} diff --git a/packages/core/src/gestures/GestureRecognizer.ts b/packages/core/src/gestures/GestureRecognizer.ts index 349ebbbda9ce8..de03cbbf2a9a6 100644 --- a/packages/core/src/gestures/GestureRecognizer.ts +++ b/packages/core/src/gestures/GestureRecognizer.ts @@ -24,7 +24,8 @@ * event, so a listener that writes a transform writes it in the same frame * as the input, with no React render in between. * - * SYNC: When modified, update GestureArena.ts. + * SYNC: When modified, update GestureArena.ts, recognizerInternals.ts and + * README.md. */ import type {GestureArena} from './GestureArena'; @@ -38,6 +39,16 @@ import type { } from './types'; import {DEFAULT_ENVIRONMENT} from './types'; +/** + * Turns text selection and the touch callout off on `elements` (and on a + * non-text input `target` inside one) for one held press; returns the undo. + * utils/selectionLock.ts's `lockSelection` is the one implementation. + */ +export type SelectionLock = ( + elements: Iterable, + target: EventTarget | null, +) => () => void; + export interface GestureRecognizerOptions { /** A name for debugging and test output. */ name?: string; @@ -51,6 +62,18 @@ export interface GestureRecognizerOptions { cancelsTouchesInView?: boolean; /** Hold the view's touch-began until this recognizer fails. @default false */ delaysTouchesBegan?: boolean; + /** + * For a hold that does something (a slider's thumb, a switch's or a + * segmented control's drag): the lock that turns text selection and the + * touch callout off on this recognizer's element for each held press + * (`spec:AST-094` FR41). Pass `lockSelection` from utils/selectionLock.ts; + * the host calls it and never imports it, so a component without such a + * hold does not bundle it. Content under a sheet's or a pager's pan stays + * selectable, so there is none by default; a mouse keeps desktop selection + * either way. A long press brings its own when it has a hold (a + * `minimumPressDuration` above 0); `null` brings none. + */ + selectionLock?: SelectionLock | null; /** * Hold the view's touch-ended (and the browser's click) while this * recognizer may still recognize. @default true @@ -60,6 +83,17 @@ export interface GestureRecognizerOptions { allowedPointerTypes?: ReadonlyArray; /** Ignore a touch of a different kind than the ones already tracked. @default true */ requiresExclusiveTouchType?: boolean; + /** + * Whether this recognizer takes its touches from the browser when it + * recognizes. `false` makes a watcher (a tab bar that minimizes while the + * page scrolls): it never claims, so it gets only passive listeners and + * never delays a scroll; it prevents no other recognizer and none prevents + * it (`spec:AST-094` FR15); it cancels no press or click under it; and it + * keeps tracking a touch the browser takes for a scroll, + * reporting through the native scroll and ending at the lift. + * @default true + */ + claimsTouches?: boolean; /** gestureRecognizerShouldBegin: return false to fail instead of beginning. */ shouldBegin?: (recognizer: R) => boolean; /** gestureRecognizer(_:shouldReceive:): return false to never see this touch. */ @@ -118,7 +152,9 @@ export function isActive(state: GestureState): boolean { return state === 'began' || state === 'changed'; } -/** The browser behaviors `touch-action` can keep or remove. */ +/** + * The browser behaviors `touch-action` can keep or remove. + */ export interface NativeTouchBehavior { readonly panX: boolean; readonly panY: boolean; @@ -150,6 +186,8 @@ export abstract class GestureRecognizer { name: string; isEnabled: boolean; cancelsTouchesInView: boolean; + /** See GestureRecognizerOptions.selectionLock. */ + readonly selectionLock: SelectionLock | null; delaysTouchesBegan: boolean; delaysTouchesEnded: boolean; allowedPointerTypes: ReadonlySet | null; @@ -161,9 +199,9 @@ export abstract class GestureRecognizer { view: unknown = null; /** @internal The arena delivering touches to this recognizer, if any. */ - arena: GestureArena | null = null; + protected arena: GestureArena | null = null; /** @internal A recognition waiting for a failure requirement. */ - pending: 'began' | 'ended' | null = null; + protected pending: 'began' | 'ended' | null = null; /** * The delegate functions. Typed against the base so a subclass stays @@ -182,7 +220,7 @@ export abstract class GestureRecognizer { /** Touches currently tracked, by id. */ protected readonly touches = new Map(); /** @internal Every touch id received since the last reset. */ - readonly seenTouchIds = new Set(); + protected readonly seenTouchIds = new Set(); private lastLocation: Point = {x: 0, y: 0}; constructor(options: GestureRecognizerOptions = {}) { @@ -191,6 +229,7 @@ export abstract class GestureRecognizer { this.name = options.name ?? this.constructor.name; this.isEnabled = options.isEnabled ?? true; this.cancelsTouchesInView = options.cancelsTouchesInView ?? true; + this.selectionLock = options.selectionLock ?? null; this.delaysTouchesBegan = options.delaysTouchesBegan ?? false; this.delaysTouchesEnded = options.delaysTouchesEnded ?? true; this.allowedPointerTypes = @@ -212,7 +251,7 @@ export abstract class GestureRecognizer { * it. Every touch recognizer claims; hover has no touches to claim. */ get claimsTouches(): boolean { - return true; + return this.options.claimsTouches ?? true; } get state(): GestureState { @@ -229,7 +268,7 @@ export abstract class GestureRecognizer { } /** @internal Whether this recognizer listens to touch at all. */ - get acceptsTouchInput(): boolean { + protected get acceptsTouchInput(): boolean { return ( this.allowedPointerTypes == null || this.allowedPointerTypes.has('touch') ); @@ -286,7 +325,7 @@ export abstract class GestureRecognizer { } /** @internal Whether this recognizer must wait for `other` to fail. */ - requiresFailureOf(other: GestureRecognizer): boolean { + protected requiresFailureOf(other: GestureRecognizer): boolean { return ( this.failureRequirements.has(other) || this.options.shouldRequireFailureOf?.(this, other) === true || @@ -299,7 +338,7 @@ export abstract class GestureRecognizer { * (`recognizeSimultaneouslyWith` sets both sides; each delegate answers for * its own recognizer), so one delegate alone keeps them exclusive (FR15). */ - allowsSimultaneous(other: GestureRecognizer): boolean { + protected allowsSimultaneous(other: GestureRecognizer): boolean { return ( this.simultaneousWith.has(other) || (this.options.shouldRecognizeSimultaneously?.(this, other) === true && @@ -308,10 +347,20 @@ export abstract class GestureRecognizer { } /** @internal The delegate's should-begin answer. */ - shouldBegin(): boolean { + protected shouldBegin(): boolean { return this.options.shouldBegin?.(this) ?? true; } + /** + * @internal Whether this recognizer holds `touchId` before it begins: the + * host then cancels that finger's moves as for a touch a recognizer owns. + * None does by default (`spec:AST-094` FR19); a scroll-edge pan holds a + * finger pulling away from an edge every scroller under it is at. + */ + protected keepsTouch(_touchId: number): boolean { + return false; + } + /** UIKit's `canPrevent(_:)`: whether recognizing fails `other`. */ canPrevent(_other: GestureRecognizer): boolean { return true; @@ -396,6 +445,10 @@ export abstract class GestureRecognizer { touches: ReadonlyArray, event: GestureEventContext, ): boolean { + if (!this.claimsTouches) { + // A watcher follows the browser's scroll to the lift. + return true; + } this.touchesCancelled(touches, event); return false; } @@ -469,7 +522,7 @@ export abstract class GestureRecognizer { // --- Arena hooks (internal) ---------------------------------------------- /** @internal Whether this recognizer takes `touch` right now. */ - wantsTouch(touch: GestureTouch): boolean { + protected wantsTouch(touch: GestureTouch): boolean { if (this.state !== 'possible' && !isActive(this.state)) { return false; } @@ -480,7 +533,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverBegan( + protected deliverBegan( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -493,7 +546,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverBeganElsewhere( + protected deliverBeganElsewhere( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -504,7 +557,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverMoved( + protected deliverMoved( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -517,7 +570,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverEnded( + protected deliverEnded( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -535,7 +588,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverCancelled( + protected deliverCancelled( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -552,7 +605,7 @@ export abstract class GestureRecognizer { } /** @internal */ - deliverTakenByBrowser( + protected deliverTakenByBrowser( touches: ReadonlyArray, event: GestureEventContext, ): void { @@ -568,12 +621,19 @@ export abstract class GestureRecognizer { } } + /** @internal The host's hook: listeners follow whether anything is enabled. */ + onEnabledChange: (() => void) | null = null; + /** * UIKit's `isEnabled` setter: turning a recognizer off mid-gesture cancels * the gesture (an active one) or fails it (a possible one). */ setEnabled(value: boolean): void { + const changed = this.isEnabled !== value; this.isEnabled = value; + if (changed) { + this.onEnabledChange?.(); + } if (!value && (this.state !== 'possible' || this.isInvolved())) { this.cancel(); } @@ -592,7 +652,7 @@ export abstract class GestureRecognizer { } /** @internal Apply a transition the arena has approved. */ - applyState(next: GestureState): void { + protected applyState(next: GestureState): void { const from = this.stateValue; if (from === next && next !== 'changed') { return; @@ -615,7 +675,7 @@ export abstract class GestureRecognizer { } /** @internal Return to `possible` for the next attempt. */ - resetToPossible(): void { + protected resetToPossible(): void { this.clearTimer(); this.pending = null; this.touches.clear(); diff --git a/packages/core/src/gestures/HoverGestureRecognizer.ts b/packages/core/src/gestures/HoverGestureRecognizer.ts new file mode 100644 index 0000000000000..844ee41852795 --- /dev/null +++ b/packages/core/src/gestures/HoverGestureRecognizer.ts @@ -0,0 +1,91 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file HoverGestureRecognizer.ts + * @input Hover enter / move / exit reports from a GestureHost + * @output Exports HoverGestureRecognizer (UIHoverGestureRecognizer) + * @position Continuous recognizer of packages/core/src/gestures that takes + * no touches: a mouse or a hovering pen over its view + * + * Began when a hovering pointer (no button pressed) enters the view, changed + * as it moves inside, ended when it leaves. A finger never hovers, so the + * recognizer is silent on touch screens, as UIKit's is on an iPhone. It + * claims nothing and conflicts with nothing: the host drives it directly, + * outside the touch arena. + * + * Governed by architecture:interaction-modality: hover only enhances, so no + * behavior may depend on this recognizer alone, and a touch or keyboard + * path reaches whatever it reveals. + */ + +import { + GestureRecognizer, + isActive, + isTerminal, + type GestureRecognizerOptions, +} from './GestureRecognizer'; +import type {Point, PointerKind} from './types'; + +export class HoverGestureRecognizer extends GestureRecognizer { + private point: Point = {x: 0, y: 0}; + /** The kind of pointer hovering: a mouse, or a pen in range. */ + pointerType: PointerKind | null = null; + + constructor(options: GestureRecognizerOptions = {}) { + super(options); + } + + override get claimsTouches(): boolean { + return false; + } + + /** + * @internal The host's brand for a hover recognizer, which it drives + * outside the touch arena, read without importing this class + * (recognizerInternals). + */ + protected get family(): 'hover' { + return 'hover'; + } + + override location(): Point { + return this.point; + } + + /** @internal The host saw a hovering pointer enter the view. */ + hoverEntered(point: Point, pointerType: PointerKind): void { + if (!this.isEnabled || isActive(this.state)) { + return; + } + if (isTerminal(this.state)) { + // A hover cancelled mid-way (the recognizer was disabled, or stopped + // from code) starts over at the next enter. + this.resetToPossible(); + } + this.point = point; + this.pointerType = pointerType; + this.applyState('began'); + } + + /** @internal */ + hoverMoved(point: Point): void { + if (!isActive(this.state)) { + return; + } + this.point = point; + this.applyState('changed'); + } + + /** @internal The pointer left, pressed, or went away. */ + hoverExited(point?: Point): void { + if (!isActive(this.state)) { + return; + } + if (point != null) { + this.point = point; + } + this.applyState('ended'); + this.resetToPossible(); + this.pointerType = null; + } +} diff --git a/packages/core/src/gestures/LongPressGestureRecognizer.ts b/packages/core/src/gestures/LongPressGestureRecognizer.ts index b552928573727..91fdde83db499 100644 --- a/packages/core/src/gestures/LongPressGestureRecognizer.ts +++ b/packages/core/src/gestures/LongPressGestureRecognizer.ts @@ -21,6 +21,7 @@ */ import {UIKIT_LONG_PRESS, UIKIT_TAP} from '../motion/uikit/gestures'; +import {lockSelection} from '../utils/selectionLock'; import {travel} from './geometry'; import { GestureRecognizer, @@ -41,6 +42,11 @@ export interface LongPressGestureRecognizerOptions extends GestureRecognizerOpti } export class LongPressGestureRecognizer extends GestureRecognizer { + /** @internal The host's brand: a long press keeps the callout and selection off (recognizerInternals). */ + protected get family(): 'longPress' { + return 'longPress'; + } + minimumPressDuration: number; allowableMovement: number; numberOfTapsRequired: number; @@ -55,7 +61,20 @@ export class LongPressGestureRecognizer extends GestureRecognizer { beganAt: number | null = null; constructor(options: LongPressGestureRecognizerOptions = {}) { - super(options); + // A long press's hold does something (a menu, a hold-repeat): it locks + // selection for its press (spec:AST-094 FR41). One at 0 ms recognizes at + // touch-down and has no hold, so it brings none, nor does one told null: + // there the browser keeps its native long press. + super({ + ...options, + selectionLock: + options.selectionLock !== undefined + ? options.selectionLock + : (options.minimumPressDuration ?? + UIKIT_LONG_PRESS.minimumPressDuration.value) > 0 + ? lockSelection + : null, + }); this.minimumPressDuration = options.minimumPressDuration ?? UIKIT_LONG_PRESS.minimumPressDuration.value; diff --git a/packages/core/src/gestures/PanGestureRecognizer.ts b/packages/core/src/gestures/PanGestureRecognizer.ts index 136fbcfe094f3..a3ad3b5ef2b8b 100644 --- a/packages/core/src/gestures/PanGestureRecognizer.ts +++ b/packages/core/src/gestures/PanGestureRecognizer.ts @@ -87,6 +87,11 @@ const DIRECTION_VECTORS: Partial> = { }; export class PanGestureRecognizer extends GestureRecognizer { + /** @internal The host's brand: a pan follows the one-finger rule (recognizerInternals). */ + protected get family(): 'pan' { + return 'pan'; + } + minimumNumberOfTouches: number; maximumNumberOfTouches: number; hysteresis: number; @@ -364,6 +369,36 @@ export class PanGestureRecognizer extends GestureRecognizer { this.offset = {x: -swallowed.x, y: -swallowed.y}; } + /** + * A scroll-edge pan holds its finger from the first move while every + * scroller under it is at its edge and the finger pulls away from that + * edge, the pan's own way (`spec:AST-094` FR19, DEC-18): + * the browser would only rubber-band there. It still begins at its + * hysteresis; a move the content can still scroll stays the browser's, + * and so does every move while a second finger is down (a pinch). + */ + protected override keepsTouch(touchId: number): boolean { + const touch = this.touches.get(touchId); + if ( + !this.scrollEdge || + this.state !== 'possible' || + // A lone finger only: two fingers before a claim are the page's pinch + // (spec:AST-094 FR10). + this.touches.size !== 1 || + touch == null || + touch.pointerType !== 'touch' || + touch.takenByBrowser + ) { + return false; + } + const moved = this.raw; + return ( + (moved.x !== 0 || moved.y !== 0) && + this.classify(moved) === 'begin' && + !this.nativeCanScroll(moved.x, moved.y) + ); + } + private nativeCanScroll(dx: number, dy: number): boolean { for (const touch of this.touches.values()) { if (touch.scroll?.canScroll(dx, dy) === true) { diff --git a/packages/core/src/gestures/PinchGestureRecognizer.ts b/packages/core/src/gestures/PinchGestureRecognizer.ts new file mode 100644 index 0000000000000..0ec42d730b991 --- /dev/null +++ b/packages/core/src/gestures/PinchGestureRecognizer.ts @@ -0,0 +1,149 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PinchGestureRecognizer.ts + * @input Touches from a GestureArena + * @output Exports PinchGestureRecognizer (UIPinchGestureRecognizer) and its + * options + * @position Continuous two-finger recognizer of packages/core/src/gestures + * + * Tracks the first two fingers; more are ignored. Begins once the distance + * between them has changed by `hysteresis` px. As in UIKit, `scale` is then + * the current distance over the starting distance moved by the hysteresis + * toward the pinch, so it does not jump (settable to re-base it); `velocity` + * is scale per second. Lifting either finger ends an + * active pinch and fails a possible one. + * + * A host gives every element WITHOUT a pinch recognizer `pinch-zoom` in its + * `touch-action`, so the page stays zoomable (WCAG 1.4.4); attaching one + * takes the two-finger gesture for this element only. + */ + +import {UIKIT_PINCH} from '../motion/uikit/gestures'; +import {centroid, distance} from './geometry'; +import { + GestureRecognizer, + isActive, + type GestureRecognizerOptions, + type NativeTouchBehavior, +} from './GestureRecognizer'; +import type {GestureEventContext, GestureTouch, Point} from './types'; +import {VelocityTracker} from './VelocityTracker'; + +export interface PinchGestureRecognizerOptions extends GestureRecognizerOptions { + hysteresis?: number; +} + +export class PinchGestureRecognizer extends GestureRecognizer { + hysteresis: number; + private initialSpan = 0; + private scaleValue = 1; + private readonly tracker = new VelocityTracker(); + /** The lift's event time, for the stillness reset. */ + private liftTime: number | undefined; + + constructor(options: PinchGestureRecognizerOptions = {}) { + super(options); + this.hysteresis = options.hysteresis ?? UIKIT_PINCH.hysteresis.value; + } + + /** Current span over the span the fingers started at. */ + get scale(): number { + return this.scaleValue; + } + + /** Re-base: from now on the scale reads `value` at the current span. */ + set scale(value: number) { + const span = this.span(); + if (span > 0 && value > 0) { + const factor = this.scaleValue > 0 ? value / this.scaleValue : 1; + this.initialSpan = span / value; + this.scaleValue = value; + // The velocity continues from the re-based value, without a jump: the + // samples scale with it, keeping the release. + this.tracker.scaleBy(factor); + } + } + + /** Scale per second (UIKit's filter; measured for pans, assumed here). */ + get velocity(): number { + return this.tracker.velocity(this.liftTime).x; + } + + /** The midpoint between the two fingers. */ + focalPoint(): Point { + return centroid(this.touches.values()); + } + + /** Two fingers on this view are this recognizer's, not the browser's zoom. */ + override get nativeTouchBehavior(): NativeTouchBehavior { + return {panX: true, panY: true, pinchZoom: false, doubleTapZoom: false}; + } + + protected override acceptsTouch(touch: GestureTouch): boolean { + return this.numberOfTouches < 2 && super.acceptsTouch(touch); + } + + protected override touchesBegan( + _touches: ReadonlyArray, + event: GestureEventContext, + ): void { + if (this.numberOfTouches === 2) { + this.initialSpan = this.span(); + this.scaleValue = 1; + this.tracker.reset(); + this.tracker.add(1, 0, event.time); + } + } + + protected override touchesMoved( + _touches: ReadonlyArray, + event: GestureEventContext, + ): void { + if (this.numberOfTouches < 2 || this.initialSpan <= 0) { + return; + } + const span = this.span(); + // Once recognized, a pinch waiting on a recognizer it requires to fail + // keeps its re-based start: its moves only follow the fingers. + if (this.state === 'possible' && this.pending == null) { + if (Math.abs(span - this.initialSpan) >= this.hysteresis) { + // UIKit measures from the start moved by the hysteresis, and its + // first velocity spans from the touch-down (scale 1) to here. + this.initialSpan += + Math.sign(span - this.initialSpan) * this.hysteresis; + this.scaleValue = span / this.initialSpan; + this.tracker.add(this.scaleValue, 0, event.time); + this.setState('began'); + } + return; + } + this.scaleValue = span / this.initialSpan; + // Every move of the fingers is a sample: a span that holds while they + // move reads as no change, so the velocity never goes stale. + this.tracker.add(this.scaleValue, 0, event.time, {isMotion: true}); + if (isActive(this.state)) { + this.setState('changed'); + } + } + + protected override touchesEnded( + _touches: ReadonlyArray, + event: GestureEventContext, + ): void { + this.liftTime = event.time; + this.setState(isActive(this.state) ? 'ended' : 'failed'); + } + + protected override reset(): void { + this.liftTime = undefined; + this.initialSpan = 0; + this.scaleValue = 1; + this.tracker.reset(); + } + + private span(): number { + const [a, b] = this.trackedTouches; + return a == null || b == null ? 0 : distance(a, b); + } +} diff --git a/packages/core/src/gestures/PressTracker.ts b/packages/core/src/gestures/PressTracker.ts new file mode 100644 index 0000000000000..309dd2b24a272 --- /dev/null +++ b/packages/core/src/gestures/PressTracker.ts @@ -0,0 +1,399 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PressTracker.ts + * @input Touches the arena delivers to a pressable view (TouchConsumer) + * @output Exports PressTracker (UIControl's touch tracking), its options, the + * control event type, and PRESS_PROFILES (a control's and a table cell's + * tracking and release clock) + * @position View-level consumer of packages/core/src/gestures: what a + * UIControl does with the touches no gesture recognizer took + * + * UIControl semantics, event for event: + * + * - `touchDown` when the finger lands; the highlight comes on in the same + * frame, unless the control sits in a scroller, where it waits + * `contentTouchDelay` (UIScrollView.delaysContentTouches) so a flick + * paints nothing. + * - `touchDragInside` / `touchDragOutside` on every move, `touchDragExit` / + * `touchDragEnter` when the finger crosses the control's bounds EXPANDED + * by `trackingExpansion` (a fingertip dragged a little off a button is + * still on it). The highlight follows inside / outside. + * - `touchUpInside` / `touchUpOutside` at the lift. A tap quicker than the + * scroller delay shows the highlight at the lift for `quickTapFlash`. + * - `touchCancel` when a recognizer took the touch (`cancelsTouchesInView`) + * or the browser took it to scroll: the highlight goes at once, with no + * release, one frame before the content moves. + * + * Two profiles (PRESS_PROFILES) cover what UIKit does: + * + * - `control` (a button, a switch): as above. + * - `cell` (a table or collection row): the highlight waits the + * content-touch delay inside a scroll view and comes on at once outside one + * (`spec:AST-094` FR11), travel past the pan hysteresis cancels the press + * for good (the scroll view took the touch), and a quicker tap highlights + * at the lift straight into the release, with no flash. + * + * And UIControl's iOS 6 rule: a single-finger single tap recognizer on an + * ANCESTOR does not beat the control's own action for a touch on it (the + * default veto; `vetoesAncestorRecognizer` replaces it, as a slider vetoes a + * pan along its track). + * + * The bounds are read once, when the finger lands; nothing reads layout + * during the press. Activation stays the browser's click, which the arena + * swallows for a touch a recognizer took. + */ + +import {UIKIT_CONTROL, UIKIT_PAN, UIKIT_PRESS} from '../motion/uikit/gestures'; +import {timing} from '../motion/uikit/timing'; +import {travel} from './geometry'; +import type {GestureRecognizer, SelectionLock} from './GestureRecognizer'; +import {internals} from './recognizerInternals'; +import type {TapGestureRecognizer} from './TapGestureRecognizer'; +import type {GestureTouch, TouchConsumer} from './types'; + +export type ControlEvent = + | 'touchDown' + | 'touchDragInside' + | 'touchDragOutside' + | 'touchDragEnter' + | 'touchDragExit' + | 'touchUpInside' + | 'touchUpOutside' + | 'touchCancel'; + +export interface PressBounds { + readonly left: number; + readonly top: number; + readonly right: number; + readonly bottom: number; +} + +export interface PressTrackerOptions { + /** The control's bounds in client px, read when a finger lands. */ + getBounds: () => PressBounds | null; + /** The view the tracker belongs to; recognizers on it are not ancestors. */ + view?: unknown; + onControlEvent?: (event: ControlEvent, touch: GestureTouch) => void; + onHighlightChange?: (isHighlighted: boolean) => void; + /** + * A continuous-tracking control (a held stepper) keeps its touch once its + * press is delivered: the host cancels the browser's pan for that finger, + * as UIKit's `touchesShouldCancel(in:)` is false for a tracking control. + * Its element then gets a blocking `touchmove` listener, so a scroll that + * starts on it waits on the main thread: never set it on plain buttons, + * links, cards or list cells. + * @default false + */ + keepsTouch?: boolean; + /** + * For a control whose own record defines a hold action (a stepper's + * hold-repeat): pass `lockSelection` (gestures/selectionLock.ts) to turn + * text selection and the callout off for each press, where the primary + * pointer is coarse (`spec:AST-094` FR41). A control whose hold does + * nothing passes none. @default undefined + */ + selectionLock?: SelectionLock; + trackingExpansion?: number; + /** + * When the highlight waits `contentTouchDelay`: inside a scroller (a + * control or a cell), always, or never. @default 'inScrollers' + */ + highlightDelay?: PressHighlightDelay; + /** + * Travel (px) past which the press is cancelled for good, with no + * re-highlight on the way back (a cell whose scroll view took the touch). + * Unset, the finger is tracked in and out of the expanded bounds. + */ + cancelTravel?: number; + /** + * How long a tap quicker than the delay shows the highlight at the lift, + * ms; 0 highlights and releases at once. @default the quick-tap flash + */ + quickTapFlash?: number; + /** Which ancestor recognizers the control's own action beats. */ + vetoesAncestorRecognizer?: (recognizer: GestureRecognizer) => boolean; + /** @default () => true */ + isEnabled?: () => boolean; + /** + * Whether this touch is braking a scroll the finger set moving (see + * GestureHost.isScrollBraking): a native list highlights nothing for one, + * and the control does not track it at all. + */ + isBraking?: (touch: GestureTouch) => boolean; +} + +const PRESS = UIKIT_PRESS; + +export type PressHighlightDelay = 'inScrollers' | 'always' | 'never'; + +/** + * How a kind of pressable tracks a finger and how long its release takes. + */ +export interface PressProfile { + readonly highlightDelay: PressHighlightDelay; + readonly cancelTravel?: number; + readonly quickTapFlash: number; + /** The release (`fading`) clock a painter uses, ms. */ + readonly releaseMs: number; +} + +export const PRESS_PROFILES = { + /** UIControl: same-frame highlight outside scrollers, 70 pt tracking. */ + control: { + highlightDelay: 'inScrollers', + quickTapFlash: PRESS.quickTapFlash.value, + // The shared press styles release every surface on the deselect clock + // until a control runs its own release (UIButton's 250 ms), so a + // painter's `fading` never outlasts the fade it shows. + releaseMs: timing.press.highlightOutMs, + }, + /** + * A table or collection cell: inside a scroll view it highlights after the + * content-touch delay, outside one at once (AST-094 FR11: the delay applies + * only inside scroll views); cancelled by the scroll. + */ + cell: { + highlightDelay: 'inScrollers', + cancelTravel: UIKIT_PAN.hysteresis.value, + quickTapFlash: 0, + releaseMs: timing.press.highlightOutMs, + }, +} as const satisfies Record; + +export type PressProfileName = keyof typeof PRESS_PROFILES; + +/** + * UIButton's rule: an ancestor's single-finger single tap. The tap is told + * apart by its family rather than its class, so press tracking never ships + * TapGestureRecognizer. + */ +function vetoesSingleTap(recognizer: GestureRecognizer): boolean { + if (internals(recognizer).family !== 'tap') { + return false; + } + const tap = recognizer as TapGestureRecognizer; + return tap.numberOfTapsRequired === 1 && tap.numberOfTouchesRequired === 1; +} + +export class PressTracker implements TouchConsumer { + isHighlighted = false; + private isKept = false; + isTracking = false; + isTouchInside = false; + trackingExpansion: number; + + private readonly options: PressTrackerOptions; + private touchId: number | null = null; + private bounds: PressBounds | null = null; + private highlightTimer: ReturnType | null = null; + private flashTimer: ReturnType | null = null; + /** A pending highlight whose finger travelled: it will never paint. */ + private isPendingDead = false; + + constructor(options: PressTrackerOptions) { + if (options.keepsTouch === true) { + // Only the tracked touch whose press was delivered is kept. + this.keepsTouch = touchId => this.isKept && this.touchId === touchId; + } + this.options = options; + this.trackingExpansion = + options.trackingExpansion ?? UIKIT_CONTROL.trackingExpansion.value; + } + + /** + * Whether the control takes touches now: a disabled one gives the host no + * reason to listen (`spec:AST-094` FR24). + */ + isEnabled(): boolean { + return this.options.isEnabled?.() !== false; + } + + touchBegan(touch: GestureTouch): void { + if ( + this.touchId != null || + this.options.isEnabled?.() === false || + this.options.isBraking?.(touch) === true + ) { + return; + } + this.clearFlash(); + this.touchId = touch.id; + this.bounds = this.options.getBounds(); + this.isTracking = true; + this.isTouchInside = true; + this.isPendingDead = false; + this.emit('touchDown', touch); + const delay = this.options.highlightDelay ?? 'inScrollers'; + const delays = + touch.pointerType === 'touch' && + (delay === 'always' || + (delay === 'inScrollers' && touch.scroll?.isInsideScroller === true)); + if (delays) { + this.highlightTimer = setTimeout(() => { + this.highlightTimer = null; + if (this.isTouchInside) { + this.setHighlighted(true); + } + }, PRESS.contentTouchDelay.value); + } else { + this.setHighlighted(true); + } + } + + touchMoved(touch: GestureTouch): void { + if (touch.id !== this.touchId) { + return; + } + const cancelTravel = this.options.cancelTravel; + if (cancelTravel != null && travel(touch) > cancelTravel) { + // A cell's scroll view took the touch: the press is over. + this.touchCancelled(touch); + return; + } + if (this.highlightTimer != null && travel(touch) > PRESS.slop.value) { + // A finger travelling before the delay ran out is scrolling: the + // press it began never paints, as a flick on a list paints nothing. + clearTimeout(this.highlightTimer); + this.highlightTimer = null; + this.isPendingDead = true; + } + const inside = this.contains(touch); + if (inside !== this.isTouchInside) { + this.isTouchInside = inside; + this.emit(inside ? 'touchDragEnter' : 'touchDragExit', touch); + if (this.highlightTimer == null && !this.isPendingDead) { + this.setHighlighted(inside); + } + } + this.emit(inside ? 'touchDragInside' : 'touchDragOutside', touch); + } + + touchEnded(touch: GestureTouch): void { + if (touch.id !== this.touchId) { + return; + } + this.isKept = false; + const inside = this.contains(touch); + const wasPending = this.highlightTimer != null; + this.clearHighlightTimer(); + this.emit(inside ? 'touchUpInside' : 'touchUpOutside', touch); + const flash = this.options.quickTapFlash ?? PRESS.quickTapFlash.value; + if (wasPending && inside) { + // Quicker than the delay: the highlight shows at the lift, for the + // flash, or (a cell) straight into the release. + this.setHighlighted(true); + if (flash > 0) { + this.flashTimer = setTimeout(() => { + this.flashTimer = null; + this.setHighlighted(false); + }, flash); + } else { + this.setHighlighted(false); + } + } else { + this.setHighlighted(false); + } + this.finish(); + } + + touchCancelled(touch: GestureTouch): void { + if (touch.id !== this.touchId) { + return; + } + this.isKept = false; + this.clearHighlightTimer(); + this.emit('touchCancel', touch); + this.setHighlighted(false); + this.finish(); + } + + allowsAncestorRecognizer(recognizer: unknown): boolean { + const candidate = recognizer as GestureRecognizer; + if (candidate.view != null && candidate.view === this.options.view) { + return true; + } + const vetoes = this.options.vetoesAncestorRecognizer ?? vetoesSingleTap; + return !vetoes(candidate); + } + + /** Drop any highlight and timers (the control unmounted or was disabled). */ + dispose(): void { + this.clearHighlightTimer(); + this.clearFlash(); + this.setHighlighted(false); + this.finish(); + } + + private contains(touch: GestureTouch): boolean { + const bounds = this.bounds; + if (bounds == null) { + return true; + } + const margin = this.trackingExpansion; + return ( + touch.x >= bounds.left - margin && + touch.x <= bounds.right + margin && + touch.y >= bounds.top - margin && + touch.y <= bounds.bottom + margin + ); + } + + private emit(event: ControlEvent, touch: GestureTouch): void { + // A finger where the primary pointer is fine gets press feedback only. + if (touch.isFeedbackOnly === true) { + return; + } + this.options.onControlEvent?.(event, touch); + } + + private setHighlighted(value: boolean): void { + if (value && this.touchId != null) { + // Delivered: the control keeps this touch until it ends (also when a + // quick tap's flash is still showing as the press arrives). + this.isKept = true; + } + if (this.isHighlighted === value) { + return; + } + this.isHighlighted = value; + this.options.onHighlightChange?.(value); + } + + /** + * Present only for a continuous-tracking control created with + * `keepsTouch: true` (a held stepper): once its press is delivered, the + * browser's pan must not take the touch. A plain pressable leaves it + * undefined and keeps passive listeners, so a scroll starting on it never + * waits on the main thread. + */ + readonly keepsTouch?: (touchId: number) => boolean; + + /** The selection lock of a control whose hold acts (FR41); see the option. */ + get selectionLock(): SelectionLock | undefined { + return this.options.selectionLock; + } + + private clearHighlightTimer(): void { + if (this.highlightTimer != null) { + clearTimeout(this.highlightTimer); + this.highlightTimer = null; + } + } + + private clearFlash(): void { + if (this.flashTimer != null) { + clearTimeout(this.flashTimer); + this.flashTimer = null; + } + } + + private finish(): void { + // A highlight shown at the lift (a quick tap's flash) keeps nothing. + this.isKept = false; + this.touchId = null; + this.isTracking = false; + this.isTouchInside = false; + this.bounds = null; + } +} diff --git a/packages/core/src/gestures/README.md b/packages/core/src/gestures/README.md new file mode 100644 index 0000000000000..2a581f6877f65 --- /dev/null +++ b/packages/core/src/gestures/README.md @@ -0,0 +1,675 @@ +# Gestures + +A gesture recognizer system with UIKit's semantics, for the touch +presentations of Astryx core components. Components import it from +`../gestures`. Lab reaches its primitives through +`@astryxdesign/core/internal/touch`, an internal entry reserved for Astryx's +own packages (`architecture:react-component-runtime` INV13); nothing here is +part of the public API. + +UIKit is the reference throughout: the same recognizer states, the same +conflict rules, the same thresholds (from `../motion/uikit/gestures.ts`, each +one sourced or marked as a guess awaiting measurement), and touch handling +that behaves like `UIControl` and `UIScrollView`. The rest of this page says +where the web forces a difference. + +## Using it from a component + +```tsx +import {useRef} from 'react'; +import {PanGestureRecognizer, useGestureRecognizer} from '../gestures'; + +function Sheet() { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'down'}), + pan => { + // Runs inside the input event: write the transform, render nothing. + const element = ref.current; + if (element == null) return; + if (pan.state === 'ended') { + settle(element, pan.translation().y, pan.velocity().y); + } else { + element.style.transform = `translateY(${pan.translation().y}px)`; + } + }, + ); + return
…
; +} +``` + +- The recognizer is created once per component and keeps its identity, so + relationships set up when it is created (`requireToFail`, + `recognizeSimultaneouslyWith`) keep working. +- The listener is read through a ref. Passing a new function on each render + attaches nothing new, and a gesture never causes a React render. +- The recognizer attaches in a layout effect, so the element's `touch-action` + is set before the browser can see a finger. It follows the element behind + the ref: a node rendered after mount, or swapped behind the same ref (a + trigger rendered `as` another element), is attached at that commit. +- `isEnabled: false` mid-gesture ends the gesture without calling the + listener, since the handler that turned it off owns that end (no consumer + callback from an Effect). A handler that wants UIKit's `.cancelled` calls + `recognizer.setEnabled(false)` itself, which reports it at once. A + recognizer that required it to fail (a single tap waiting on a double + tap) resolves just after that render, and its listener reports as usual; + so does one waiting on a recognizer whose node a render replaced. After + unmount the listener hears nothing, not even the cancellation of a + gesture the unmount ended. +- `actionGroup: true` attaches the element as an action group, sibling + actions a held pointer slides across, which the host serves where the + primary pointer is fine too (`family:action-groups` FR5). Changing it + attaches the element again. +- `usePressTracker(ref, options)` gives an element `UIControl` touch tracking + (see below). It follows its element the same way, takes `isBraking` (by + default the host's: a touch that stops a flick presses nothing), and keeps + one tracker for the component's life: it reads `trackingExpansion`, + `highlightDelay`, `cancelTravel` and `quickTapFlash` (and the ref itself) + as they are now, so a change, even mid-press, never drops the press in + progress. Unmounting or losing its element calls neither + `onHighlightChange` nor `onControlEvent`, and neither does disabling the + control under a finger: the press ends, so the handler that disables it + clears its own highlight. +- `useFingerScroll(scroller)`, an internal piece of the touch module with + no public API (not in the gestures barrel; a scroll-driven component + imports its module, so it ships only where used), returns a reader, `true` while the scroller's scroll is a finger's: from a scroll + while a finger that landed inside it is down, until its `scrollend` (the + momentum is still the finger's), or, where the browser fires none (mobile + Safari before 26), until its scroll events go quiet. Pass a ref, or the + document for the page. Outside React, `watchFingerScrolls(host, scroller)` and + `isFingerScroll(host, scroller)` from `fingerScroll.ts` do the same; that + module adds passive scroll listeners only while something is watched. + +Outside React, `getGestureHost(document).attach(element, recognizer)` does +the same and returns a detach function. `getGestureHost(document).fingersDown()` +is how many fingers are on the screen (the last touch event's `touches`, +wherever they landed): a recognizer that must not begin under a second +finger checks it in `shouldBegin`, so two fingers stay the browser's pinch. +Once a drag has claimed its finger, a second finger never breaks it. + +## Recognizers + +| Recognizer | UIKit | Kind | Notes | +| -------------------------------- | ---------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TapGestureRecognizer` | `UITapGestureRecognizer` | discrete | `numberOfTapsRequired`, `numberOfTouchesRequired`; fails past 45 px of travel, on extra fingers, a hold over 1.5 s, a late or far next tap | +| `LongPressGestureRecognizer` | `UILongPressGestureRecognizer` | continuous | `minimumPressDuration` 500 ms, `allowableMovement` 10 px (a distance, not per axis), `numberOfTapsRequired`; a zero duration begins on the touch itself (a selection tracker over a group of actions) | +| `PanGestureRecognizer` | `UIPanGestureRecognizer` | continuous | min/max touches, `direction` (axis or one-way) with `axisRatio`, `lockAxis`, `scrollEdge`; `translation()`, `totalTranslation()`, `velocity()`, `scrollVelocity()` | +| `SwipeGestureRecognizer` | `UISwipeGestureRecognizer` | discrete | recognized during the motion; one or more directions, `numberOfTouchesRequired`; 50 px along, at most 50 px across, 500 ms | +| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` | +| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s | +| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside | +| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers | + +A recognizer created with `claimsTouches: false` is a watcher (a tab bar +that minimizes while the page scrolls): it never claims, so its element gets +only passive listeners and a scroll never waits for it; it prevents no other +recognizer and none prevents it; and it follows a touch the browser takes +for a scroll to the lift. It adds nothing to the element's derived +`touch-action`. + +Every recognizer also has UIKit's switches: `isEnabled` (use `setEnabled`; +turning it off mid-gesture cancels), `cancelsTouchesInView` (default true), +`delaysTouchesBegan` (default false), `delaysTouchesEnded` (default true), +`allowedPointerTypes`, and `requiresExclusiveTouchType`. + +### What the pan reports + +The pan reads its numbers the way `UIPanGestureRecognizer` does, as measured +in the UIKit lab: + +- It begins once the centroid has moved `hysteresis` (10 px) along either + axis, not as a distance. +- `translation()` leaves out the travel the threshold took: a slow drag reads + zero when the pan begins, and a step that overshoots keeps only the + overshoot. A dragged surface does not jump. `totalTranslation()` is the + movement since the fingers landed, for a surface that wants it. +- `axisRatio` (for a pan limited to an axis): the movement along the axis + must exceed `axisRatio` times the movement across it to begin; movement + across that exceeds the movement along it fails the pan; in between it + waits. +- `velocity()` is UIKit's own filter (see Velocity). The lift updates + neither number; a finger that rested past the stillness interval releases + at 0. + +### States + +``` +possible ─► began ─► changed* ─► ended | cancelled continuous +possible ─► ended discrete ("recognized") +possible ─► failed +``` + +Listeners (UIKit's target-action) run for `began`, `changed`, `ended` and +`cancelled`. `addTransitionListener` also reports `failed` and the return to +`possible`. A recognizer in a terminal state ignores the touches it still +holds and returns to `possible` when all of them have ended. + +## Conflicts: the arena + +A new touch goes to every recognizer on its hit path, innermost first, and +each one that accepts it tracks it until it ends. Then: + +- **Exclusivity.** Two recognizers that share a touch are never active + together unless one allows it (`recognizeSimultaneouslyWith`, or the + `shouldRecognizeSimultaneously` delegate). The first to recognize fails + the other possible ones. A recognizer that tries to begin while a + conflicting one is active fails instead. When two qualify on the same + input, the innermost one wins. +- **Failure requirements.** `a.requireToFail(b)` keeps `a` in `possible` + until `b` fails, then lets `a` recognize. If `b` recognizes, `a` fails. A + recognizer that is not involved in the current touches holds nothing up. + The delegates `shouldRequireFailureOf` and `shouldBeRequiredToFailBy` + express the same thing dynamically. +- **Delegates.** `shouldBegin`, `shouldReceiveTouch`, and `canPrevent` / + `canBePrevented` on subclasses. The arena's plumbing (touch delivery, + approved transitions, a pending recognition) is `protected`: a subclass + overrides the `touches*` handlers and sets `state`, and only the gesture + system drives the rest. + +```ts +// Single tap waits for the double tap to fail, as in UIKit. +single.requireToFail(double); + +// Inner horizontal scroller yields to the back-swipe in the edge band only: +// the edge pan fails at touch-down anywhere else, so nothing waits. +innerPan.requireToFail(edgePan); +``` + +## Touches in the view: `PressTracker` + +A pressable control's view-level touch handling, with `UIControl` +semantics: + +- `touchDown`, `touchDragInside` / `touchDragOutside`, `touchDragEnter` / + `touchDragExit`, `touchUpInside` / `touchUpOutside`, `touchCancel`. +- "Inside" is the control's bounds expanded by `trackingExpansion`, so a + fingertip that slides a little off a button is still on it. +- The highlight comes on in the same frame. Inside a scroller it waits + `contentTouchDelay` (`UIScrollView.delaysContentTouches`), so a flick + paints nothing. A tap quicker than that delay shows the highlight at the + lift for `quickTapFlash`. +- A recognizer that takes the touch (`cancelsTouchesInView`), or the browser + taking it to scroll, cancels the press with no release. +- `UIControl`'s iOS 6 rule applies: an ancestor's single-finger single tap + does not beat the control's own action for a touch on it. + `vetoesAncestorRecognizer` replaces that rule (a slider vetoes a pan along + its track). + +The browser's click stays the control's activation, so keyboard and +assistive-technology activation keep working. + +### Press profiles + +`PRESS_PROFILES` holds the two ways UIKit tracks a press: + +- `control` (a button, a switch): the highlight comes on in the same frame + outside a scroller, the finger is tracked 70 pt beyond the bounds, and + the release takes the list's deselect clock, as every surface's does + until a control runs its own (UIButton's 0.25 s); +- `cell` (a table or collection row): the highlight waits the content-touch + delay inside a scroll view and comes on at once outside one + (`spec:AST-094` FR11), travel past the pan hysteresis cancels the press for + good, a quicker tap highlights at the lift straight into the release, and + the release takes the list's deselect clock. + +`paintPress(element, 'on' | 'held' | 'fading' | 'none', {releaseMs})`, in +`utils/pressFeedback.ts`, writes `data-astryx-press` for every press it tracks; +the action-group tracker (`hooks/actionGroupTracker.ts`) writes its groups' +presses itself. `paintPress` owns the release timer and writes +only over values the caller may replace (by default none, `on` and +`fading`), so a `held` row survives presses until its owner releases it +with `onlyFrom: ['held']`. + +### Rows and cards + +A list of a thousand rows cannot pay for a hook and four listeners per row. +The document press controller (`utils/pressFeedback.ts`) presses the +innermost enabled `[data-astryx-pressable]` under each touch and paints it, +with no listener per row; a touch that brakes a scroll the finger set moving +presses nothing. + +## What the browser is told + +- **Only where the primary pointer is coarse.** Recognizers attach, and the + host registers listeners, only while `TOUCH_QUERY` matches + (`spec:AST-094` FR19, FR24; `../utils/coarsePointer.ts`). Where the primary pointer + is fine, an attach is recorded and nothing else happens: no document, + window or element listener, no style below, nothing delivered, so a + finger on a touchscreen laptop gets the browser's defaults. The host + follows the query (a tablet docking or undocking a mouse): turning coarse + installs it and styles every element; turning fine cancels every touch in + flight and removes it all. A development warning names the first attach + under a fine pointer: attach touch work under a touch presentation. The + one carve-out is a recognizer or press tracker that reads each event's + pointer itself, as `spec:AST-057`'s row swipe does, or a press tracker that + paints press feedback (an FR19 exception for press feedback only, pending + in `spec:AST-094`'s amendment): attached with + `perEventPointer: true` (an option of `attach`, `attachConsumer`, + `useGestureRecognizer` and `usePressTracker`), it attaches on any pointer + with no warning, while everything else beside it stays inactive. A + per-event recognizer gets the host's listeners, its deliveries and its own + element's style. Where the pointer is fine, a per-event press tracker gets + only passive `touchstart` and `pointerdown` listeners on its own element + and no style; the host's document and window listeners are added at a + finger's press on it and removed once that press ends. It paints that + press and does nothing else: it reports no control event, keeps nothing + from the browser, which still pans, and takes no mouse or pen press. + Even where it is coarse, the host listens only while something enabled + could take a touch: a disabled recognizer or consumer registers nothing + (FR24), and after a consumer's `isEnabled()` changes, `host.update(element)` + re-syncs. So a component that activates through a recognizer (a tap that + pages, a press that opens) also activates from the browser's own `click` + where the pointer is fine, and a mouse, a trackpad and the keyboard keep + working; its tests declare a phone's pointer for the touch path + (`coarsePrimaryPointer()` in `__tests__/pointerQuery.ts`). +- **`touch-action`.** Each element gets the intersection of what its + recognizers leave the browser, and never more than the element's own + value allows: a caller's explicit `touch-action` (a trigger's `none`) + stays in force, as the intersection of both. It is set inline on attach + and restored on detach (opt out with `manageStyle: false`): + + | Recognizers on the element | `touch-action` | + | ------------------------------------------------- | ------------------- | + | tap, long press | `manipulation` | + | horizontal pan or swipe | `pan-y pinch-zoom` | + | horizontal edge pan on a strip within its band | `pan-y pinch-zoom` | + | vertical pan or swipe | `pan-x pinch-zoom` | + | one-way pan, pan with `scrollEdge` | left as is (`auto`) | + | edge pan on a view wider than its band | left as is (`auto`) | + | free pan | `pinch-zoom` | + | pinch or rotation | `pan-x pan-y` | + | pinch or rotation together with a free pan | `none` | + | a watcher (`claimsTouches: false`), any direction | left as is (`auto`) | + + A one-way pan leaves the browser its own axis too: a toast dragged away + from its edge scrolls the page. It claims its direction by cancelling + `touchmove` when it begins. + + `pinch-zoom` stays in the value unless a pinch or rotation recognizer is + attached, so the page stays zoomable (WCAG 1.4.4). A long press with a + hold also turns off the iOS callout and text selection on its element for + its press; one at 0 ms (a touch-down detector) or given + `selectionLock: null` leaves them to the browser. + + While a finger or a pen is down, the host turns them off too only where + the hold does something (`spec:AST-094` FR41): on the element of a + recognizer created with a `selectionLock` (pass `lockSelection`; a + slider's thumb, a segmented + control's or a switch's drag) that would take the touch (enabled, and + accepting its pointer), restored when the contact ends. The host calls + the recognizer's lock and never imports one, so a component with no such + hold does not bundle it. A press tracker created with a `selectionLock` + (a control whose own record defines a hold action, such as a stepper's + hold-repeat) gets the same for each press. A press-tracked + control or row whose hold does nothing, and the document press + controller's surfaces (`usePressFeedback` rows), keep the browser's + long-press selection; a tap never selects. A non-text input under the finger inside such an element (a checkbox, a radio, a switch's input) is locked too: WebKit's own stylesheet makes every input selectable, which no ancestor's lock reaches. A text field keeps its selection, since a held finger places its caret. Only where the + primary pointer is coarse: a mouse never takes the lock, nor a finger or a + pen where the primary pointer is fine (a touchscreen laptop, a desktop pen + tablet), so desktop selection is unchanged (`spec:AST-094` FR19); content + under a sheet's or a pager's pan stays selectable. + +- **Claiming.** When a recognizer recognizes, it owns its touches. Each + element with a recognizer that can claim a moving touch (a pan, a swipe, a + long press that then drags, a pinch, a rotation) has a non-passive + `touchmove` listener that cancels the event only while one of its touches + is owned. A touch nobody owns scrolls exactly as it would without the + system. Taps claim only at the lift, and a recognizer that never claims + (`claimsTouches` false, such as a pull that only watches the scroll) never + owns a touch, so an element with only those, or only a press consumer, + gets no such listener (see the known limit below). A press tracker created + with `keepsTouch: true` (opt-in, for a continuous-tracking control such + as a held stepper; never plain buttons, links, cards or list cells) gets + one: once its press is delivered its finger's moves are cancelled, so the + browser's pan never takes the held control (UIKit's + `touchesShouldCancel(in:)` is false for a tracking control). Where the + pointer is coarse its element also gets `touch-action: manipulation`, as a + tap recognizer's does (`spec:AST-094` FR18), so taps in quick succession + never zoom. A scroll that starts on such a control waits on the main + thread; a move before the press is delivered still scrolls. The listener the host + adds to a finger's target at touch-down, for a target that leaves the + document mid-gesture, is passive too unless a recognizer on that touch can + claim while it moves. +- **Claim at the begin.** A pan claims its touch only as it begins, at + UIKit's 10 pt (`spec:AST-094` FR19), before WebKit commits a slow drag to + a scroll at 10–11 px; a drag the browser took first stays the browser's + (FR14). One exception: a `scrollEdge` pan cancels its finger's moves from + the first one while every scroller under the finger is at its edge and + the finger pulls away from it, the pan's own way (the browser could only + rubber-band there); it still begins at 10 pt. It holds only while that is + the one finger on the screen: with a second down anywhere, the moves stay + the browser's, for its pinch (FR10). + Measured in Mobile Safari (iOS 26.2): `touchmove` arrives from the first + pixel; on a scrollable element WebKit commits to scrolling at 10 to 11 px + (the last cancelable `touchmove` at 10 px), and cancelling every + `touchmove` from 8 px keeps the page still for the whole gesture. Under + `pan-y` it scrolls only drags steeper than about 2:1 to 3:1, once the + vertical travel reaches about 10 px. A pan that competes with native + scrolling therefore decides at 10 px or less. +- **The browser's claim.** `pointercancel` on a finger, or a scroll above + the touch once the finger has travelled the slop (the browser starts a pan + no earlier), means the browser took the finger; a scroll under a finger + that has not moved (scrollIntoView, a transcript pinned to its end, scroll + anchoring) is the page's own and leaves the press and its click alone. So + does its first `touchmove` + that can no longer be cancelled, when the browser could pan along the + finger's main axis. (Chromium also stops asking after one unconsumed + `touchmove` when `touch-action` keeps it from panning that way, as for a + horizontal drag under `pan-y`; that is not a claim.) Possible recognizers that share it fail, active ones are + cancelled, and the view's press is cancelled. +- **Clicks.** When a touch's gesture cancelled the view's touches, its + `touchend` is cancelled (no click and no compatibility mouse events) and + the shared click guard (`utils/clickGuard.ts`, the menu press model's + too) swallows the browser's next click, until the next `pointerdown` or + 400 ms. A click held back for a + recognizer that may still recognize (a double tap between taps) is + swallowed at once and replayed on the original target if the recognizer + fails. Clicks with `detail === 0` (keyboard, assistive technology, code) + always pass. +- **Context menus.** A `contextmenu` while a recognizer owns a touch is + cancelled, so a long press never also opens the browser's menu. +- **Focus.** Losing window focus or hiding the page cancels every touch in + flight, as UIKit does when the app resigns active. + +### Native scrolling + +`scrollEdge` makes a pan share its axis with the native scrollers under the +finger. This is the sheet over its own list: the pan begins only if nothing +under the touch could scroll for that movement, the finger landed at the +edge and pulls away from it. The browser has not committed yet, so beginning +claims the touch and the content never moves. From the first move that +pulls away from such an edge the pan already holds the finger, so its moves +are cancelled before it begins and the page does not rubber-band either. + +Who gets a drag is decided once, when it begins, as in UIKit: a drag that +began as a scroll stays the scroll view's for its whole length (it scrolls +to the top, then rubber-bands), and never changes hands midway +(`spec:AST-094` FR14). + +"Could scroll" follows the browser's own rules: `touch-action` between the +touch and each scroller, scroll chaining stopped by `overscroll-behavior`, +and RTL `scrollLeft` ranges, along the flat tree (content slotted into a +shadow-tree scroller scrolls with it, as the hit path does). + +### The input stream + +The host listens through `utils/inputStream.ts`, which it shares with the +press feedback controller: one native listener per event type, on the +window's capture phase, fanned out to every subscriber in order. It runs +ahead of any document listener, so an app's window-capture +`stopPropagation`, or a document-capture `stopImmediatePropagation` +registered before core, no longer hides a touch from either, as UIKit's +recognizers see touches before views do. A recognizer that must claim +mid-gesture still needs the touch to reach its element, where its blocking +`touchmove` sits, below such a stop. The stream stays ahead of an app's own +window-capture listener only until a type's listener is re-added (a +passivity switch, or a subscription after its last subscriber left), which +appends it after the app's. A type's listener is passive unless a +subscriber cancels it: the host cancels `touchend` and `contextmenu` there +while a claiming participant is attached, never a `touchstart` or a +`touchmove`, and the click guard (`../utils/clickGuard.ts`) keeps one +cancelling `click` once it first arms. A blocking `touchmove` sits on a claiming +element and on a claimed touch's target, on the document while an element a +claiming recognizer lives in is moving (Touches on moving elements), or on +the window only while a page hold is held, so a page of pressables blocks +no scroll. + +### Holding the page still + +A scroll lock may hold the page with `holdPageScroll(document, decide)` from +`pageHold.ts`, which returns the release (none does yet: `useScrollLock` +does not call it). It lives in its own module, so a page without a scroll +lock never ships it, and it imports nothing of the gesture host: it asks the +document's host whether a recognizer owns a touch only if a host exists, and +a document without one has no recognizer to own it. A hold asks nothing as +it starts (an overlay opens without reading the pointer): the input stream's +`pointerdown` reads the primary pointer at its first touch or pen contact, +and the hold follows it from then on. While any hold is active, and only +where the primary pointer is coarse, the input stream listens with a +non-passive `touchmove`; where the contact's `touchstart` came before its +`pointerdown`, the finger is named at its first move. Once per one-finger touch that no +recognizer owns, after the +finger clears a 5 px slop (under WebKit's 10–11 px scroll claim), it asks +`decide(origin, dx, dy)` whether that pan would scroll the page, `origin` +being the innermost node under the finger (the composed path's first node), +and cancels the touch's moves for its whole length if so. A second finger is +never cancelled, so pinch-zoom keeps working. With no hold, the listener is +gone and every touch scrolls as without the system. + +### Multiple fingers + +A drag a recognizer has claimed keeps its finger: a second finger never +cancels or redirects it (`spec:AST-094` FR10), as in UIKit, where a pan +ignores touches beyond its maximum. Before any claim, a second finger landing +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. + +## Time + +Every input carries its own timestamp (`event.timeStamp`), and timers are +deadlines on that same clock. Before an input is processed, every timer due +by its timestamp runs first, at its own deadline. A long press therefore +begins at exactly touch-down + 500 ms even when the main thread was blocked +and the timer callback ran late. Tests drive the whole system with explicit +timestamps and no real time. + +## Velocity + +`VelocityTracker` reports px per second, UIKit's unit, and is the one +velocity tracker every continuous recognizer uses +(`architecture:interaction-modality` INV10, `spec:AST-100` FR1, FR2; no +alternative estimators). One filter, iOS's measured blend, updated on each +moved sample (a sample at the newest position is not motion): + +- A pan's velocity is 0.2 times the newest interval's velocity plus 0.8 times + the previous interval's (an interval runs from one moved sample to the + next; the touch-down counts as the first). With one interval so far it is + that interval, as UIKit reports it. It reproduces every value UIKit + reported on six recorded touch streams, and like UIKit's it reacts a frame + late to an acceleration. +- A scrolling surface's release (paging, a wheel) is 0.25 times the newest + pan value plus 0.75 times the previous one: a pan's `scrollVelocity()`. + Everything else reads `velocity()`. + +A release reads 0 when the finger stayed still for longer than the stillness +interval (`UIKIT_GESTURES.velocity.stillness`) before lifting, the one +difference from iOS, whose filter never decays and so flings after a rest; +and 0 with fewer than two moved samples. The pan passes its lift's event time +to the tracker. + +Times are the events' own. A finger the browser reports on both pointer and +touch events is sampled from its pointer events alone (from its touch events +once the browser cancels its pointer), so the two copies of one move never +both count. + +## Haptics + +`playHaptic(kind)` vibrates where `navigator.vibrate` exists, with one +pattern per UIKit feedback kind. iOS Safari has no vibration API. It plays a +light impact only when a real tap toggles an ``, and WebKit requires a trusted event for it. `attachTapHaptic(label)` +puts a hidden switch inside a label the control renders as its tap target, +so every real tap on it plays that impact; focus the activation gives the +switch goes straight back. A label tied to its control by `for`, or whose +control is itself a switch, gets no switch; a label wrapping its own control +gets one, which hands each tap on to that control. Nothing can play a haptic later +from script, so mid-drag haptics (detent snaps, picker ticks) are not +possible in iOS Safari. + +## Thresholds + +All numbers live in `../motion/uikit/gestures.ts`. Each one carries either +its source (Apple documentation, or a measurement: the recognizers' own +values read in a UIKit app on the iOS 26.2 simulator and confirmed with +touches, or Mobile Safari measured on the same simulator) or a guess +together with the measurement that will replace it. A unit test fails if an +entry has neither. + +## Touch presentation + +Whether a component presents itself the touch (UIKit) way is answered once, +in `../utils/touchPresentation.ts`: + +- touch behaviours (this system, the press model, UIKit springs and + timings) key on a coarse PRIMARY pointer (`TOUCH_QUERY`); the gesture + host itself installs nothing while it is fine (What the browser is told); +- layout behaviours key on a compact viewport, narrower than the theme's + `md` width point or shorter than 500 px (`COMPACT_QUERY`; behaviour reads + the active theme's points), the closest a size can get to + UIKit's compact size class (a Max-size iPhone in landscape is its one + known miss); +- a presentation that needs both, a sheet in place of a popover, asks for + both (`COMPACT_TOUCH_QUERY`). + +Styles switch on the same conditions by writing the same query as a StyleX +media key (`'@media (pointer: coarse)'`, and the compact-touch query spelled +exactly as `COMPACT_TOUCH_QUERY`, which a test enforces), so the first paint +is right. Behaviour reads +`useTouchPresentation()` (SSR-safe), a component with no interaction to +infer from included (UIKit timings under a finger, its own motion under a +mouse), and a decision taken once reads `readTouchPresentation()`. + +## Migrating a raw-touch drag + +A component that listens to touches itself moves onto the recognizers this +way: + +1. No window or document touch listeners: attach a recognizer with + `useGestureRecognizer` and read `translation()` and `velocity()` per + state. The host owns the page-wide listeners. +2. Pick the reading: a drag that coasts like a scroll view (a wheel, a + picker, a pager, a carousel) releases with the pan's `scrollVelocity()`; + anything else with `velocity()`. A one-way drag sets `direction`. The host derives + `touch-action`, so delete a hand-written one. +3. Native scrolling: a sheet over its own list uses `scrollEdge`, not + `scrollTop` checks; who gets a drag is decided once, when it begins + (`spec:AST-094` FR14). +4. A behavior that only watches the person's scrolling uses a watcher + (`claimsTouches: false`) and `useFingerScroll`. +5. A scroll lock holds the page with `holdPageScroll`; no `preventDefault` + of its own. +6. A drag limited to one finger sets `maximumNumberOfTouches: 1`. Before + it claims, a second finger fails it; once claimed, it keeps its finger + (`spec:AST-094` FR10). Delete a local pinch yield. +7. The release hands `velocity()` (px/s, event time) to the animator. + Delete local estimators, sample buffers and time windows; a flick, fling + or commit threshold stays in the component, compared against it. +8. A control whose hold acts (a slider's thumb, a stepper's hold-repeat) + passes `selectionLock: lockSelection` to its recognizer or press tracker + (`spec:AST-094` FR41); content drags keep selection. +9. Presses use `usePressTracker`; `keepsTouch: true` only for a control that + tracks a held finger across its own area. +10. Unit tests drive the gesture harness (`__tests__/harness.ts`) with + timestamped touches; interaction specs use real touch input, never + synthetic DOM events. + +## Known limits + +- **A scroll that begins on a recognizer waits for a busy main thread.** In + Mobile Safari (iOS 26.2, measured), a vertical scroll that starts on an + element with a non-passive `touchmove` listener waits until the current + JavaScript task ends; on a plain row, a pressable row or a tap-only + element it starts at once (within 50 ms) even during a 500 ms task. + Chromium documents the same rule for non-passive listeners. The listener + stays because nothing else keeps UIKit's arbitration: with a passive + listener, or one added during `touchstart` (which Safari ignores for that + touch), Safari hands every diagonal drag on a swipe row to the list's + scroll, 3:1 included. It is never worse than UIKit, where a busy main + thread stalls every scroll. +- **Lists where every row has a horizontal recognizer** (swipe actions on + every row of a mail list): every scroll start waits on the current task in + Safari. Keep list screens' tasks short. A native horizontal `scroll-snap` + reveal inside the row would never wait, but it tracks with scroll physics + rather than UIKit's measured tracking spring; it is a possible opt-in, not + the default. +- **Follow-up:** Chromium keeps 3:1 to 1.5:1 drags as swipes even with a + passive listener (its `touch-action` filter drops a `pan-y` scroll that + starts sideways), so a passive listener there would cost no parity. It + waits on a measured run on Android before any engine-specific choice. + +## Touches on moving elements + +iOS WebKit hit-tests an element that a compositor animation moves where +the animation started: it keeps the transform it resolved when the +animation went to the compositor, so event targets, `elementFromPoint` and +`getBoundingClientRect` stay there for the whole curve, though the inline +style holds the value the curve starts from and the computed style is live +(iOS 26.2, measured; Chromium and Linux WebKit hit what is drawn). A finger on a settling sheet +lands on whatever sat under the sheet's start (an upward release can hit +the scrim and close the sheet). UIKit keeps an interruptible animation +hit-testable where it is drawn (UIViewPropertyAnimator). + +A style write on the element makes WebKit resolve the transform again, to +the value drawn at that moment, where it stays. So on iOS WebKit, at +touch-down, the host has every element the animator reports as moving +(`motion/presentation.ts`) resolved with one custom-property write, then +asks the engine what is under the finger: the touch goes to what is drawn +there, with z-order and `pointer-events` as the engine sees them. Press +trackers take their bounds from `presentedRect`, the reported box moved by +the motion since. A retargeted tap cancels the engine's click, +which would hit the start position, and clicks what it touched; the +click guard drops a late engine click, so the tap acts once. Safari honours a +blocking `touchmove` only where it was registered before the touch began, +on the engine's own target, which for a moving element is whatever sits +under its start: so while an element a claiming recognizer lives in is +moving, the document carries the blocking listener, and a scroll starting +anywhere waits on the main thread only for that long. Not covered: +scale and rotate (a zoom transition, a lifted preview is hit at its +unscaled box) and CSS transitions or animations the animator did not start. + +This is an engine workaround (`spec:AST-013` FR6). Its review condition: +re-measure on each iOS release, and remove the correction once iOS WebKit +hit-tests a compositor-moved element where it is drawn. Its regression +fixture is `__tests__/presentedHitTesting.test.ts`. + +## Where the web differs from UIKit + +- Once a recognizer owns a touch, the browser cannot take the scroll back + later in the same gesture. UIKit does not either: it decides between a + sheet and its list when the drag begins. +- In Chromium a finger's first `touchmove` arrives only after about 16 px. + Movement below that comes from the finger's pointer events, but the + browser's own decision to scroll happens at that first `touchmove`. + Mobile Safari delivers `touchmove` from the first pixel. +- A tap that holds a plain `
', + ); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(button, consumer)); + const clicks: number[] = []; + button.addEventListener('click', () => clicks.push(1)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + // The program scrolls (a transcript pinned to its end, scrollIntoView). + scrollable(list, {scrollTop: 50, scrollHeight: 2000, clientHeight: 300}); + list.dispatchEvent(new Event('scroll')); + expect(consumer.events.some(event => event.startsWith('cancelled'))).toBe( + false, + ); + const end = touch('touchend', button, [finger], [], 120); + expect(end.defaultPrevented).toBe(false); + expect(consumer.events.at(-1)).toMatch(/^ended/); + }); + + it("cancels a finger's moves only when a claimed pen's own stylus touch moves, never a finger nobody owns", () => { + const {pad, list} = mount('
'); + const drawing = new PanGestureRecognizer({name: 'draw'}); + detachers.push( + host.attach(pad, drawing), + host.attach( + list, + new PanGestureRecognizer({name: 'other', direction: 'horizontal'}), + ), + ); + for (const [x, time] of [ + [0, 0], + [5, 16], + [30, 32], + ] as const) { + pointer(time === 0 ? 'pointerdown' : 'pointermove', pad, { + id: 9, + pointerType: 'pen', + x, + y: 0, + buttons: 1, + time, + }); + } + expect(drawing.state).toBe('began'); + // A finger on the list, which no recognizer owns: its moves scroll. + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', list, [finger], [finger], 40); + const move = touch( + 'touchmove', + list, + [{...finger, y: 40}], + [{...finger, y: 40}], + 56, + ); + expect(move.defaultPrevented).toBe(false); + // The pen's own stylus touch is held: the browser must not pan with it. + const stylus = new Event('touchmove', {bubbles: true, cancelable: true}); + const pen = { + identifier: 77, + clientX: 31, + clientY: 0, + target: pad, + touchType: 'stylus', + }; + Object.defineProperty(stylus, 'changedTouches', {value: [pen]}); + Object.defineProperty(stylus, 'touches', {value: [pen]}); + Object.defineProperty(stylus, 'timeStamp', {value: 60}); + pad.dispatchEvent(stylus); + expect(stylus.defaultPrevented).toBe(true); + pointer('pointerup', pad, { + id: 9, + pointerType: 'pen', + x: 31, + y: 0, + time: 80, + }); + }); + + it('keeps every touchmove listener passive on a finger nothing can claim as it moves', () => { + const {taps, quiet} = mount('
'); + class Watcher extends PanGestureRecognizer { + override get claimsTouches(): boolean { + return false; + } + } + detachers.push( + host.attach(taps, new TapGestureRecognizer({name: 'tap'})), + host.attachConsumer(taps, new RecordingConsumer()), + host.attach(quiet, new Watcher({name: 'watcher'})), + ); + for (const element of [taps, quiet]) { + const added = vi.spyOn(element, 'addEventListener'); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', element, [finger], [finger], 0); + const blocking = added.mock.calls.filter( + ([type, , options]) => + type === 'touchmove' && + (options as AddEventListenerOptions | undefined)?.passive === false, + ); + expect(blocking).toHaveLength(0); + touch('touchend', element, [finger], [], 50); + } + }); + + it('follows the flat tree: content slotted into a shadow scroller scrolls with it', () => { + const {host: shell, item} = mount( + '
row
', + ); + const shadow = shell.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const scroller = shadow.getElementById('scroller') as HTMLElement; + scrollable(scroller, { + scrollTop: 200, + scrollHeight: 2000, + clientHeight: 300, + }); + const probe = createNativeScrollProbe(item); + expect(probe.canScroll(0, 10)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + }); + + it("writes a touch-action no wider than the caller's own", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + vi.advanceTimersByTime(16); + // manipulation (the hold's) with none, and pan-y pinch-zoom (the + // pan's) with pan-y, allow the caller's own: its rule stays in force. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(window.getComputedStyle(strip).touchAction).toBe('pan-y'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('marks an element with its derived touch-action as it attaches, under a rule any author rule outranks, with no inline write and no style read (spec:AST-094 FR18, DEC-8)', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + const set = vi.spyOn(box.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(hostRules(document)).toContain( + ':where([data-astryx-touch-action="pan-y pinch-zoom"])', + ); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(read).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('leaves no touch-action rule in a document or shadow root once its last marked element lets go (spec:AST-094 FR24)', () => { + const restore = touchActionFromData(); + try { + const {box, other, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
'; + const inner = shadow.getElementById('inner') as HTMLElement; + const releaseBox = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + const releaseOther = host.attach( + other, + new PanGestureRecognizer({direction: 'vertical'}), + ); + const releaseInner = host.attach( + inner, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + expect(hostRules(document)).toContain('data-astryx-touch-action'); + expect(hostRules(shadow)).toContain('data-astryx-touch-action'); + // One element still marked keeps its root's rules. + releaseBox(); + expect(hostRules(document)).toContain('data-astryx-touch-action'); + releaseOther(); + expect(hostRules(document)).not.toContain('data-astryx-touch-action'); + releaseInner(); + expect(hostRules(shadow)).not.toContain('data-astryx-touch-action'); + // A later attach brings its rule back. + detachers.push( + host.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(hostRules(document)).toContain( + ':where([data-astryx-touch-action="pan-y pinch-zoom"])', + ); + } finally { + restore(); + } + }); + + it("keeps an author rule in force from the first frame, and writes the intersection inline only where it is narrower than the author's (spec:AST-094 FR18)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {both, strip} = mount( + '
', + ); + const setBoth = vi.spyOn(both.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(both, new PanGestureRecognizer({direction: 'horizontal'})), + host.attach(strip, new PanGestureRecognizer({direction: 'horizontal'})), + ); + // Before the host reads anything, the author's value stands. + expect(both.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(window.getComputedStyle(both).touchAction).toBe('pan-x pan-y'); + expect(setBoth).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + vi.advanceTimersByTime(16); + // pan-y pinch-zoom with pan-x pan-y allows pan-y: narrower than the + // author's, so it is written. + expect(setBoth).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // With pan-y it is the author's own value: nothing to write. + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('removes the mark when the last recognizer detaches', () => { + const {box} = mount('
'); + const detach = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + detach(); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(false); + }); + + it('adds the rule to the shadow root an element is in', () => { + const {box} = mount('
'); + const shadow = box.attachShadow({mode: 'open'}); + const inner = document.createElement('div'); + shadow.append(inner); + detachers.push(host.attach(inner, new LongPressGestureRecognizer())); + expect(inner.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + expect(hostRules(shadow)).toContain( + ':where([data-astryx-touch-action="manipulation"])', + ); + }); + + it("is never wider than the author's touch-action, at any frame (spec:AST-094 FR18, DEC-8)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + ); + // The author's none outranks the host's manipulation from the attach + // on, and the read finds nothing to narrow. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + vi.advanceTimersByTime(16); + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('gives an element attached while on screen its touch-action in the attach itself', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'}))); + expect(window.getComputedStyle(box).touchAction).toBe('manipulation'); + } finally { + restore(); + } + }); + + it("reads no computed style while attaching: a caller's inline touch-action applies at once, a stylesheet's is read in the next frame", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + // jsdom keeps no touch-action in a style: this stands in for the + // caller's inline `touch-action: pan-y`. + vi.spyOn(strip.style, 'getPropertyValue').mockImplementation(name => + name === 'touch-action' ? 'pan-y' : '', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + // Attaching forces no style recalc over whatever just mounted. + expect(read).not.toHaveBeenCalled(); + // The caller's inline value: pan-y pinch-zoom ∩ pan-y, at once. + expect(setStrip).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // A stylesheet's value: read once in the next frame, where none is + // already no wider than both allow. + vi.advanceTimersByTime(16); + expect(read).toHaveBeenCalledTimes(1); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('releases the same velocity for one stroke from a finger, a pen and a mouse (spec:AST-100 FR1, FR4)', () => { + const velocities: number[] = []; + for (const kind of ['touch', 'pen', 'mouse'] as const) { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: kind}); + let released = 0; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity().x; + } + }); + const detach = host.attach(box, pan); + const at = (x: number, time: number, type: 'down' | 'move' | 'up') => { + if (kind === 'touch') { + const finger = {id: 1, x, y: 0}; + touch( + type === 'down' + ? 'touchstart' + : type === 'move' + ? 'touchmove' + : 'touchend', + box, + [finger], + type === 'up' ? [] : [finger], + time, + ); + } else { + pointer( + type === 'down' + ? 'pointerdown' + : type === 'move' + ? 'pointermove' + : 'pointerup', + box, + { + id: 5, + pointerType: kind, + x, + y: 0, + buttons: type === 'up' ? 0 : 1, + time, + }, + ); + } + }; + // Each stroke later than the last: the arena's clock never goes back. + const start = 1000 * (velocities.length + 1); + at(0, start, 'down'); + for (let step = 1; step <= 10; step++) { + at(step * 12, start + step * 8, 'move'); + } + at(120, start + 80, 'up'); + velocities.push(released); + detach(); + } + expect(velocities[0]).toBeGreaterThan(0); + expect(velocities[1]).toBeCloseTo(velocities[0], 6); + expect(velocities[2]).toBeCloseTo(velocities[0], 6); + }); + + describe('finger scrolls', () => { + it('reads a scroll while a finger that landed inside is down as the finger’s, until the scroller’s scrollend', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The lift starts the momentum: still the finger's scroll. + touch('touchend', row, [finger], [], 120); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('forgets a finger scroll in progress when the watch stops listening, so a later program scroll is not the finger’s', () => { + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The pointer turns fine mid-scroll: the watch stops listening, and its + // scrollend never comes. + pointer.set(false); + pointer.set(true); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads a scroll under a finger inside an open shadow root as the finger’s, comparing them along the flat tree', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + }); + + it('counts a finger on a recognizer inside an open shadow root as down in a light-DOM scroller around it, whichever listener saw it last (spec:AST-094 FR20)', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + // A recognizer on the inner node: the host also listens there, where + // the browser hands the touch's target as that node. + detachers.push( + host.attach(row, new PanGestureRecognizer({direction: 'vertical'})), + ); + const finger = {id: 1, x: 10, y: 10, target: row}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 12}; + touch('touchmove', row, [moved], [moved], 16); + expect(host.isFingerDownIn(list)).toBe(true); + expect(host.isFingerDownIn(outer)).toBe(true); + }); + + it('arms the scroll brake for a flick by a finger inside an open shadow root, and brakes a touch there, comparing along the flat tree', () => { + const {list, outer} = mount( + '
', + ); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(row, consumer)); + const finger = {id: 1, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + const moved = {...finger, y: 40}; + touch('touchmove', row, [moved], [moved], 16); + // Each scroll at its own time, on the touches' clock. + const scroll = (time: number) => { + const event = new Event('scroll'); + Object.defineProperty(event, 'timeStamp', {value: time}); + list.dispatchEvent(event); + }; + scroll(24); + touch('touchend', row, [moved], [], 32); + scroll(48); + expect(host.isScrollBraking(row)).toBe(true); + }); + + it('reads a scroll with no finger down as the program’s, even right after a tap', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + // A tap whose click scrolls the list (a scrollTo after the lift). + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + touch('touchend', row, [finger], [], 80); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads the page’s own scroll on the document', () => { + const {row} = mount('
'); + detachers.push(watchFingerScrolls(host, document)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + document.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, document)).toBe(true); + touch('touchend', row, [finger], [], 120); + document.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, document)).toBe(false); + }); + + it('does not count a finger down outside the scroller', () => { + const {list, other} = mount( + '
', + ); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', other, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('keeps a scroll that ends while the finger still rests the finger’s until the lift', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(true); + // Lifting without moving the list again ends it. + touch('touchend', row, [finger], [], 300); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('ends a finger’s scroll after a quiet spell where the browser sends no scrollend', () => { + vi.useFakeTimers(); + const own = Object.getOwnPropertyDescriptor(window, 'onscrollend'); + const proto = Object.getPrototypeOf(window) as object; + const inherited = Object.getOwnPropertyDescriptor(proto, 'onscrollend'); + Reflect.deleteProperty(window, 'onscrollend'); + Reflect.deleteProperty(proto, 'onscrollend'); + try { + expect('onscrollend' in window).toBe(false); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + touch('touchend', row, [finger], [], 120); + // The momentum keeps scrolling: still the finger's. + vi.advanceTimersByTime(100); + list.dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(100); + expect(isFingerScroll(host, list)).toBe(true); + // Quiet for the fallback spell: the scroll has ended. + vi.advanceTimersByTime(60); + expect(isFingerScroll(host, list)).toBe(false); + // A program's scroll afterwards is not the finger's. + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + } finally { + if (inherited != null) { + Object.defineProperty(proto, 'onscrollend', inherited); + } + if (own != null) { + Object.defineProperty(window, 'onscrollend', own); + } + vi.useRealTimers(); + } + }); + + it('forgets a scroller once nobody watches it', () => { + const {list, row} = mount('
'); + const stop = watchFingerScrolls(host, list); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + stop(); + expect(isFingerScroll(host, list)).toBe(false); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + }); + + it('drives mouse input through Pointer Events', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 0, + y: 0, + buttons: 1, + time: 0, + }); + for (let t = 8; t <= 96; t += 8) { + pointer('pointermove', box, { + pointerType: 'mouse', + x: t * 2, + y: 0, + buttons: 1, + time: t, + }); + } + pointer('pointerup', box, {pointerType: 'mouse', x: 192, y: 0, time: 104}); + expect(pan.state).toBe('possible'); + expect(released.x).toBeCloseTo(2000, 0); + }); + + it('cancels the context menu while a long press owns the touch', () => { + const {box} = mount('
'); + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + const before = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(before); + expect(before.defaultPrevented).toBe(false); + host.arena.advanceTime(500); + expect(press.state).toBe('began'); + const during = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(during); + expect(during.defaultPrevented).toBe(true); + }); + + it('holds the click of a tap a double tap may claim, and replays it when the double tap fails', () => { + const {row, button} = mount( + '
', + ); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + detachers.push(host.attach(row, double)); + const clicks: {target: EventTarget | null; detail: number}[] = []; + button.addEventListener('click', event => + clicks.push({target: event.target, detail: event.detail}), + ); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + const end = touch('touchend', button, [finger], [], 60); + expect(end.defaultPrevented).toBe(true); + expect(clicks).toEqual([]); + const interval = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; + host.arena.advanceTime(60 + interval); + expect(clicks).toEqual([{target: button, detail: 1}]); + }); + + it('collects recognizers across shadow roots', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(outer, tap)); + const finger = {id: 1, x: 5, y: 5, target: inner}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root its finger: a touch reaches the page retargeted to the shadow host, and the composed path names what it landed on', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + // At the document, a touch's target is the shadow host, as the event's. + const finger = {id: 1, x: 5, y: 5, target: outer}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root a mouse press too, the pointer event retargeted to the shadow host at the document', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + pointer('pointerdown', inner, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', inner, {pointerType: 'mouse', x: 5, y: 5, time: 50}); + expect(actions).toEqual(['ended']); + }); + + it('drives hover recognizers from a hovering mouse', () => { + const {box, other} = mount('
'); + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, hover)); + pointer('pointermove', box, {pointerType: 'mouse', x: 1, y: 1, time: 0}); + pointer('pointermove', box, {pointerType: 'mouse', x: 2, y: 1, time: 16}); + pointer('pointermove', other, { + pointerType: 'mouse', + x: 300, + y: 1, + time: 32, + }); + expect(states).toEqual(['began', 'changed', 'ended']); + // A finger never hovers. + pointer('pointermove', box, {pointerType: 'touch', x: 1, y: 1, time: 48}); + expect(states).toHaveLength(3); + }); + + it.each([ + ['stopPropagation on the window', window, 'stopPropagation'], + [ + 'stopImmediatePropagation on the document', + document, + 'stopImmediatePropagation', + ], + ] as const)( + "recognizes a touch an app's capture-phase listener, registered first, stops (%s)", + (_name, node, stop) => { + const {box} = mount('
'); + const listener = (event: Event) => event[stop](); + const types = ['touchstart', 'touchmove', 'touchend'] as const; + // The app listens before any recognizer exists (the host installs on + // its first attach), as an app shell does. + for (const type of types) { + node.addEventListener(type, listener, {capture: true}); + } + try { + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + touch('touchend', box, [{...finger, x: 40}], [], 32); + // The host hears the window's capture phase: a listener on the same + // node still runs after stopPropagation, and the window comes before + // the document. + expect(states).toEqual(['began', 'ended']); + } finally { + for (const type of types) { + node.removeEventListener(type, listener, {capture: true}); + } + } + }, + ); + + it('cancels every touch in flight when the window loses focus', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + window.dispatchEvent(new Event('blur')); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('remembers a finger the window blur cancelled until it lifts, and refuses that lift its click: nothing acts (spec:AST-094 FR28)', () => { + const {box, page} = mount('

text

'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const states: string[] = []; + tap.addTransitionListener((_r, _from, to) => states.push(to)); + detachers.push(host.attach(box, tap)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + window.dispatchEvent(new Event('blur')); + // The browser kept the touch alive: its lift would click the box. + const lift = touch('touchend', box, [finger], [], 80); + expect(lift.defaultPrevented).toBe(true); + expect(states).not.toContain('ended'); + // Only that lift: a later finger with the same identifier keeps its + // click. + touch('touchstart', page, [finger], [finger], 200); + expect(touch('touchend', page, [finger], [], 260).defaultPrevented).toBe( + false, + ); + // A cancelled finger whose lift never came: the next touch with its + // identifier is a new finger, and its lift keeps its click too. + touch('touchstart', box, [finger], [finger], 300); + window.dispatchEvent(new Event('blur')); + touch('touchstart', page, [finger], [finger], 400); + expect(touch('touchend', page, [finger], [], 460).defaultPrevented).toBe( + false, + ); + }); + + it('finishes a gesture whose target left the document mid-gesture', () => { + const {box, child} = mount('
x
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', child, [finger], [finger], 0); + touch('touchmove', child, [{...finger, x: 40}], [{...finger, x: 40}], 16); + // A re-render replaces the span: its events no longer reach the document. + child.remove(); + const move = touch( + 'touchmove', + child, + [{...finger, x: 60}], + [{...finger, x: 60}], + 32, + ); + expect(move.defaultPrevented).toBe(true); + touch('touchend', child, [{...finger, x: 60}], [], 48); + // The move and the lift after the removal still reached the pan. + expect(states).toEqual(['began', 'changed', 'ended']); + expect(host.arena.touches).toEqual([]); + }); + + it('cancels a finger the events say is no longer down', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [first], [first], 0); + touch('touchmove', box, [{...first, x: 40}], [{...first, x: 40}], 16); + // Its touchend went to a node outside the document; the next event for + // another finger no longer lists it. + const other = {id: 2, x: 300, y: 300}; + touch('touchmove', document.body, [other], [other], 32); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('treats a reused finger number as a new touch', () => { + const {box} = mount('
'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, tap)); + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // No touchend reached us; the same identifier lands again. + touch('touchstart', box, [finger], [finger], 500); + touch('touchend', box, [finger], [], 560); + expect(states).toEqual(['ended']); + }); + + it('cancels every finger on a touchcancel that names none', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + box.dispatchEvent(new Event('touchcancel', {bubbles: true})); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('reads epoch-stamped events on the arena clock', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + const epoch = performance.timeOrigin + 5000; + const finger = {id: 1, x: 0, y: 10}; + touch('touchstart', box, [finger], [finger], epoch); + for (let t = 8; t <= 96; t += 8) { + const at = {...finger, x: t}; + touch('touchmove', box, [at], [at], epoch + t); + } + touch('touchend', box, [{...finger, x: 96}], [], epoch + 100); + expect(released.x).toBeCloseTo(1000, 0); + expect(host.arena.now()).toBeLessThan(1e9); + }); + + it('locks only for a recognizer that would take the contact: enabled, and accepting its pointer kind', () => { + detachers.push(coarsePrimaryPointer()); + const {slider, stylus} = mount( + '
', + ); + const release = vi.fn(); + const lock = vi.fn(() => release); + const drag = new PanGestureRecognizer({ + name: 'drag', + direction: 'horizontal', + selectionLock: lock, + }); + detachers.push(host.attach(slider, drag)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', slider, [finger], [finger], 0); + expect(lock).toHaveBeenCalledTimes(1); + touch('touchend', slider, [finger], [], 40); + expect(release).toHaveBeenCalledTimes(1); + drag.setEnabled(false); + touch('touchstart', slider, [finger], [finger], 100); + touch('touchend', slider, [finger], [], 140); + expect(lock).toHaveBeenCalledTimes(1); + // A pen-only drag locks for a pen: pen acceptance decides, not touch. + const penLock = vi.fn(() => () => {}); + detachers.push( + host.attach( + stylus, + new PanGestureRecognizer({ + name: 'pen', + direction: 'horizontal', + allowedPointerTypes: ['pen'], + selectionLock: penLock, + }), + ), + ); + pointer('pointerdown', stylus, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + time: 200, + }); + expect(penLock).toHaveBeenCalledTimes(1); + pointer('pointerup', stylus, { + id: 7, + pointerType: 'pen', + x: 10, + y: 10, + time: 240, + }); + }); + + it('shares one blocking touchmove handler among the elements that claim while moving: 200 pans, one handler, none for a tap (spec:AST-094 FR19)', () => { + const added = vi.spyOn(Element.prototype, 'addEventListener'); + try { + const pans = Array.from({length: 200}, () => { + const element = document.createElement('div'); + document.body.appendChild(element); + detachers.push( + host.attach( + element, + new PanGestureRecognizer({direction: 'horizontal'}), + ), + () => element.remove(), + ); + return element; + }); + const tapTarget = document.createElement('div'); + document.body.appendChild(tapTarget); + detachers.push(host.attach(tapTarget, new TapGestureRecognizer()), () => + tapTarget.remove(), + ); + const blocking = added.mock.calls + .map((call, index) => ({ + call, + element: added.mock.contexts[index] as Element, + })) + .filter( + ({call: [type, , options]}) => + type === 'touchmove' && + typeof options === 'object' && + options.passive === false, + ); + // Each claiming element carries the listener itself, so a scroll that + // starts outside the rows never waits on it... + expect(blocking.map(({element}) => element)).toEqual(pans); + // ...and all of them share the host's one handler. + expect(new Set(blocking.map(({call}) => call[1])).size).toBe(1); + } finally { + added.mockRestore(); + } + }); + + it('keeps native long-press selection on a press-tracked element whose hold does nothing (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {row, text} = mount( + '
Row
', + ); + detachers.push(host.attachConsumer(row, new RecordingConsumer())); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', text, [finger], [finger], 0); + expect(row.style.getPropertyValue('user-select')).toBe('text'); + touch('touchend', text, [finger], [], 600); + expect(row.style.getPropertyValue('user-select')).toBe('text'); + }); + + it('turns text selection and the callout off while a finger holds an element whose hold opens a menu (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {trigger} = mount( + '
Hold for the menu
', + ); + const release = vi.fn(); + const lock = vi.fn(() => release); + detachers.push( + host.attach( + trigger, + new LongPressGestureRecognizer({name: 'menu', selectionLock: lock}), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', trigger, [finger], [finger], 0); + // The host calls the long press's lock for this contact alone. + expect(lock).toHaveBeenCalledTimes(1); + expect([ + ...(lock.mock.calls[0] as unknown as [Iterable])[0], + ]).toEqual([trigger]); + expect(release).not.toHaveBeenCalled(); + touch('touchend', trigger, [finger], [], 600); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('a long press brings lockSelection by default', () => { + expect(new LongPressGestureRecognizer().selectionLock).toBe(lockSelection); + expect(new TapGestureRecognizer().selectionLock).toBeNull(); + }); + + it('keeps native long-press selection under a 0 ms long press, a touch-down detector whose hold does nothing (spec:AST-094 FR41)', () => { + detachers.push(coarsePrimaryPointer()); + const {photo} = mount( + '
Photo
', + ); + detachers.push( + host.attach( + photo, + new LongPressGestureRecognizer({minimumPressDuration: 0}), + ), + ); + const set = vi.spyOn(photo.style, 'setProperty'); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', photo, [finger], [finger], 0); + expect(set).not.toHaveBeenCalledWith('user-select', 'none'); + expect(set).not.toHaveBeenCalledWith('-webkit-touch-callout', 'none'); + expect(photo.style.getPropertyValue('user-select')).toBe('text'); + touch('touchend', photo, [finger], [], 1200); + }); + + it('keeps only the finger a kept-press control tracks: a second finger on the held control still scrolls (spec:AST-094 FR19)', () => { + detachers.push(coarsePrimaryPointer()); + const {stepper} = mount('
+
'); + detachers.push( + host.attachConsumer( + stepper, + new PressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + const held = {id: 1, x: 5, y: 5}; + const heldMoved = {id: 1, x: 5, y: 40}; + const second = {id: 2, x: 8, y: 8}; + const secondMoved = {id: 2, x: 8, y: 60}; + touch('touchstart', stepper, [held], [held], 0); + // The held finger's press is delivered: its moves are kept. + expect( + touch('touchmove', stepper, [heldMoved], [heldMoved], 16) + .defaultPrevented, + ).toBe(true); + // A second finger the tracker is not tracking keeps its own moves. + touch('touchstart', stepper, [second], [heldMoved, second], 32); + expect( + touch('touchmove', stepper, [secondMoved], [heldMoved, secondMoved], 48) + .defaultPrevented, + ).toBe(false); + touch('touchend', stepper, [heldMoved, secondMoved], [], 64); + }); + + it('writes touch-action: manipulation for a kept-press control where the primary pointer is coarse, so rapid taps never zoom, and nothing where it is fine (spec:AST-094 FR18, FR19)', () => { + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {stepper, row} = mount( + '
+
Row
', + ); + detachers.push( + host.attachConsumer( + stepper, + new PressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + detachers.push( + host.attachConsumer(row, new PressTracker({getBounds: () => null})), + ); + expect(stepper.getAttribute('data-astryx-touch-action')).toBe( + 'manipulation', + ); + // A press tracker that keeps no touch gets no mark. + expect(row.hasAttribute('data-astryx-touch-action')).toBe(false); + // Where the pointer turns fine, the browser's own value comes back (FR19). + pointer.set(false); + expect(stepper.hasAttribute('data-astryx-touch-action')).toBe(false); + }); + + it("calls a press tracker's own selection lock for each finger's press, a stepper's hold-repeat, and nowhere else (spec:AST-094 FR41)", () => { + detachers.push(coarsePrimaryPointer()); + const {stepper, row} = mount( + '
+
Row
', + ); + const release = vi.fn(); + const lock = vi.fn( + (_elements: Iterable, _target: EventTarget | null) => release, + ); + detachers.push( + host.attachConsumer(stepper, new LockingConsumer(lock)), + host.attachConsumer(row, new RecordingConsumer()), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', stepper, [finger], [finger], 0); + expect(lock).toHaveBeenCalledTimes(1); + expect([...lock.mock.calls[0][0]]).toEqual([stepper]); + expect(release).not.toHaveBeenCalled(); + touch('touchend', stepper, [finger], [], 600); + expect(release).toHaveBeenCalledTimes(1); + // A mouse press keeps desktop selection. + pointer('pointerdown', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 700, + }); + pointer('pointerup', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + time: 750, + }); + // A tracker whose hold does nothing brings no lock. + touch('touchstart', row, [finger], [finger], 800); + touch('touchend', row, [finger], [], 900); + expect(lock).toHaveBeenCalledTimes(1); + }); + it('locks for a hold that takes the touch, and nothing where it declines one: a zero-delay hold opts in with its own lock (spec:AST-094 FR41)', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {bar, lens, search} = mount( + '
Lens
', + ); + // A tab bar's lens: a hold at 0 ms that brings the lock itself, and + // takes only touches on the lens, not on the search tab beside it. + detachers.push( + host.attach( + bar, + new LongPressGestureRecognizer({ + minimumPressDuration: 0, + selectionLock: lockSelection, + shouldReceiveTouch: (_recognizer, contact) => + contact.target instanceof Node && lens.contains(contact.target), + }), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', search, [finger], [finger], 0); + expect(bar.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', search, [finger], [], 100); + touch('touchstart', lens, [finger], [finger], 200); + expect(bar.style.getPropertyValue('user-select')).toBe('none'); + touch('touchend', lens, [finger], [], 300); + expect(bar.style.getPropertyValue('user-select')).toBe(''); + }); + + it('on a coarse primary pointer, keeps selection for a mouse, a press-tracked row and a pan that does not ask, and locks a pan that asks', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {row, sheet, slider} = mount( + '
', + ); + detachers.push( + host.attachConsumer(row, new RecordingConsumer()), + host.attach(sheet, new PanGestureRecognizer()), + host.attach( + slider, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const at = {x: 5, y: 5}; + pointer('pointerdown', row, { + pointerType: 'mouse', + ...at, + buttons: 1, + time: 0, + }); + expect(row.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', row, {pointerType: 'mouse', ...at, time: 50}); + // A pen on a touch device (an iPad's pencil) on a row whose hold does + // nothing keeps native selection too (spec:AST-094 FR41). + pointer('pointerdown', row, { + pointerType: 'pen', + ...at, + buttons: 1, + time: 100, + }); + expect(row.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', row, {pointerType: 'pen', ...at, time: 150}); + expect(row.style.getPropertyValue('user-select')).toBe(''); + + const finger = {id: 1, ...at}; + touch('touchstart', sheet, [finger], [finger], 200); + expect(sheet.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', sheet, [finger], [], 250); + touch('touchstart', slider, [finger], [finger], 300); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + touch('touchcancel', slider, [finger], [], 350); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + }); + + it("keeps selection for every contact where the primary pointer is fine: a touchscreen laptop's finger, a desktop pen and a mouse (spec:AST-094 FR19, spec:AST-017 FR59)", () => { + detachers.push(pointerQuery(false).restore); + const {row, slider} = mount('
'); + detachers.push( + host.attachConsumer(row, new RecordingConsumer()), + host.attach( + slider, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const at = {x: 5, y: 5}; + for (const [time, pointerType] of [ + [0, 'mouse'], + [100, 'pen'], + [200, 'touch'], + ] as const) { + pointer('pointerdown', row, { + pointerType, + ...at, + buttons: 1, + time, + }); + expect(row.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', row, {pointerType, ...at, time: time + 50}); + } + const finger = {id: 1, ...at}; + touch('touchstart', slider, [finger], [finger], 300); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', slider, [finger], [], 350); + }); + + it('locks a checkbox under the finger inside a locked element, which WebKit makes selectable, but never a text field', () => { + detachers.push(asIosWebKit()); + detachers.push(coarsePrimaryPointer()); + const {row, check, field} = mount( + '
', + ); + // A switch's drag: a hold that does something. + detachers.push( + host.attach( + row, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', check, [finger], [finger], 0); + expect(check.style.getPropertyValue('user-select')).toBe('none'); + touch('touchend', check, [finger], [], 100); + expect(check.style.getPropertyValue('user-select')).toBe(''); + touch('touchstart', field, [finger], [finger], 200); + expect(row.style.getPropertyValue('user-select')).toBe('none'); + expect(field.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', field, [finger], [], 300); + // An input outside every locked element is not ours to lock. + const {alone} = mount(''); + detachers.push(host.attach(alone, new TapGestureRecognizer())); + touch('touchstart', alone, [finger], [finger], 400); + expect(alone.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', alone, [finger], [], 450); + }); + + it('ignores touches that land outside every attached element', () => { + const {box, elsewhere} = mount( + '
', + ); + const pan = new PanGestureRecognizer({name: 'pan'}); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', elsewhere, [finger], [finger], 0); + const move = touch( + 'touchmove', + elsewhere, + [{...finger, x: 60}], + [{...finger, x: 60}], + 16, + ); + expect(move.defaultPrevented).toBe(false); + expect(host.arena.touches).toEqual([]); + }); +}); + +describe('holdPageScroll', () => { + const at = (id: number, x: number, y: number) => [{id, x, y}]; + /** A contact's pointerdown, which reads the primary pointer for a hold. */ + const contact = (pointerType = 'touch', x = 100, y = 300) => + pointer('pointerdown', document.body, {pointerType, x, y, time: 0}); + /** Hold the page, then touch it once, as a finger does. */ + const hold = (decide: PageScrollDecision) => { + const release = holdPageScroll(document, decide); + contact(); + return release; + }; + + it('asks nothing about the primary pointer as a hold starts, only at its first touch or pen contact', () => { + const ask = vi.spyOn(window, 'matchMedia'); + detachers.push(() => ask.mockRestore()); + detachers.push(holdPageScroll(document, () => true)); + expect(ask).not.toHaveBeenCalled(); + contact('mouse'); + expect(ask).not.toHaveBeenCalled(); + contact('touch'); + expect(ask).toHaveBeenCalledWith(COARSE_POINTER_QUERY); + }); + + it('holds a touch whose pointerdown comes after its touchstart: the finger is named at its first move', () => { + const {scrim} = mount('
'); + detachers.push(holdPageScroll(document, () => true)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + contact('touch', 100, 300); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + }); + + it('cancels a one-finger pan the decision says would scroll the page, for the rest of its touch', () => { + const {scrim} = mount('
'); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + const first = touch( + 'touchmove', + scrim, + at(1, 100, 250), + at(1, 100, 250), + 16, + ); + const second = touch( + 'touchmove', + scrim, + at(1, 100, 200), + at(1, 100, 200), + 32, + ); + + expect(first.defaultPrevented).toBe(true); + expect(second.defaultPrevented).toBe(true); + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(scrim, 0, -50); + }); + + it("judges a new touch afresh when it reuses the last touch's identifier", () => { + const {scrim} = mount('
'); + // The backdrop pan would scroll the page; the list pan scrolls the list. + const decide = vi.fn( + (_origin: EventTarget | null, _dx: number, dy: number) => dy < 0, + ); + detachers.push(hold(decide)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + touch('touchend', scrim, at(1, 100, 250), [], 32); + // The next touch reuses identifier 1 and pans the other way. + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 48); + expect( + touch('touchmove', scrim, at(1, 100, 350), at(1, 100, 350), 64) + .defaultPrevented, + ).toBe(false); + expect(decide).toHaveBeenCalledTimes(2); + }); + + it('keeps holding the first finger after a second finger touches and lifts', () => { + const {scrim} = mount('
'); + detachers.push(hold(() => true)); + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + // A second finger lands and lifts: while two are down, nothing is + // cancelled, so the page can zoom. + const both = [...at(1, 100, 250), ...at(2, 200, 300)]; + touch('touchstart', scrim, at(2, 200, 300), both, 32); + expect( + touch('touchmove', scrim, at(2, 200, 260), both, 48).defaultPrevented, + ).toBe(false); + touch('touchend', scrim, at(2, 200, 260), at(1, 100, 250), 64); + // Alone again, the first finger is held for the rest of its touch. + expect( + touch('touchmove', scrim, at(1, 100, 200), at(1, 100, 200), 80) + .defaultPrevented, + ).toBe(true); + }); + + it('judges a pan once it clears the slop, so a first wobble does not fix its axis', () => { + const {strip} = mount('
'); + const decide = vi.fn((_origin: EventTarget | null, dx: number) => dx === 0); + detachers.push(hold(decide)); + + touch('touchstart', strip, at(1, 100, 300), at(1, 100, 300), 0); + const wobble = touch( + 'touchmove', + strip, + at(1, 102, 302), + at(1, 102, 302), + 16, + ); + const swipe = touch( + 'touchmove', + strip, + at(1, 110, 303), + at(1, 110, 303), + 32, + ); + + expect(wobble.defaultPrevented).toBe(false); + expect(decide).toHaveBeenCalledTimes(1); + expect(decide).toHaveBeenCalledWith(strip, 10, 3); + expect(swipe.defaultPrevented).toBe(false); + }); + + it('lets a pan through when the decision says something under the finger takes it', () => { + const {list} = mount('
'); + detachers.push(hold(() => false)); + + touch('touchstart', list, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', list, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(false); + }); + + it('never cancels a two-finger gesture, so pinch-zoom keeps working', () => { + const {scrim} = mount('
'); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + const both = [ + {id: 1, x: 100, y: 300}, + {id: 2, x: 200, y: 300}, + ]; + + touch('touchstart', scrim, both, both, 0); + const moved = [ + {id: 1, x: 90, y: 300}, + {id: 2, x: 210, y: 300}, + ]; + expect(touch('touchmove', scrim, moved, moved, 16).defaultPrevented).toBe( + false, + ); + expect(decide).not.toHaveBeenCalled(); + }); + + it('hands the decision the innermost node of a touch inside a shadow root', () => { + const {outer} = mount('
'); + const inner = document.createElement('div'); + outer.attachShadow({mode: 'open'}).append(inner); + const decide = vi.fn(() => false); + detachers.push(hold(decide)); + + touch('touchstart', inner, at(1, 100, 300), at(1, 100, 300), 0); + touch('touchmove', inner, at(1, 100, 250), at(1, 100, 250), 16); + + expect(decide).toHaveBeenCalledWith(inner, 0, -50); + }); + + it('holds the page of a document that has no gesture host, and creates none', () => { + const {scrim} = mount('
'); + gestureHosts.delete(document); + detachers.push(hold(() => true)); + + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), 0); + expect( + touch('touchmove', scrim, at(1, 100, 250), at(1, 100, 250), 16) + .defaultPrevented, + ).toBe(true); + expect(gestureHosts.get(document)).toBeUndefined(); + }); + + it("leaves a touch that a recognizer of the document's host owns to that recognizer", () => { + const {box} = mount('
'); + gestureHosts.set(document, host); + detachers.push(() => gestureHosts.delete(document)); + const pan = new PanGestureRecognizer({name: 'pan'}); + detachers.push(host.attach(box, pan)); + const decide = vi.fn(() => true); + detachers.push(hold(decide)); + + touch('touchstart', box, at(1, 100, 300), at(1, 100, 300), 0); + touch('touchmove', box, at(1, 100, 250), at(1, 100, 250), 16); + + expect(pan.state).toBe('began'); + expect(decide).not.toHaveBeenCalled(); + }); + + it('stacks holds and stops judging pans once the last one is released', () => { + const {scrim} = mount('
'); + const releaseFirst = hold(() => true); + const releaseSecond = holdPageScroll(document, () => true); + const pan = (time: number) => { + touch('touchstart', scrim, at(1, 100, 300), at(1, 100, 300), time); + return touch( + 'touchmove', + scrim, + at(1, 100, 250), + at(1, 100, 250), + time + 16, + ).defaultPrevented; + }; + + releaseFirst(); + expect(pan(0)).toBe(true); + releaseSecond(); + releaseSecond(); + expect(pan(100)).toBe(false); + }); +}); + +describe('touch gates and a second finger', () => { + it('holds a gated target away from every participant until the gate is released', () => { + const {layer, button} = mount( + '
', + ); + const tap = new TapGestureRecognizer({name: 'tap'}); + detachers.push(host.attach(button, tap)); + const release = host.addTouchGate(target => layer.contains(target)); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', button, [finger], [finger], 0); + expect(tap.numberOfTouches).toBe(0); + touch('touchend', button, [finger], [], 40); + release(); + touch('touchstart', button, [finger], [finger], 100); + expect(tap.numberOfTouches).toBe(1); + touch('touchend', button, [finger], [], 140); + }); + + it('fails a one-finger pan that has not claimed when a second finger lands anywhere, so the browser can pinch', () => { + const {row, text} = mount('

plain

'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + maximumNumberOfTouches: 1, + }); + const states: string[] = []; + swipe.addListener(r => states.push(r.state)); + detachers.push(host.attach(row, swipe)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [first], [first], 0); + // Under the hysteresis: still possible, nothing claimed. + touch('touchmove', row, [{...first, x: 15}], [{...first, x: 15}], 16); + const second = {id: 2, x: 200, y: 300}; + touch('touchstart', text, [second], [{...first, x: 15}, second], 32); + // The pan can no longer claim the pair midway: the pinch is the browser's. + const pinch = touch( + 'touchmove', + row, + [{...first, x: 60}], + [{...first, x: 60}, second], + 48, + ); + expect(pinch.defaultPrevented).toBe(false); + expect(states).toEqual([]); + }); + + it('keeps a claimed one-finger drag when a second finger lands anywhere (AST-094 FR10)', () => { + const {row, text} = mount('

plain

'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + maximumNumberOfTouches: 1, + }); + detachers.push(host.attach(row, swipe)); + const first = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [first], [first], 0); + touch('touchmove', row, [{...first, x: 40}], [{...first, x: 40}], 16); + expect(swipe.state).toBe('began'); + const second = {id: 2, x: 200, y: 300}; + touch('touchstart', text, [second], [{...first, x: 40}, second], 32); + const drag = touch( + 'touchmove', + row, + [{...first, x: 80}], + [{...first, x: 80}, second], + 48, + ); + expect(swipe.state).toBe('changed'); + expect(drag.defaultPrevented).toBe(true); + }); +}); + +describe('a delivered press keeps its touch', () => { + it('cancels the browser pan once the consumer keeps the touch, and not before', () => { + const {row} = mount('
'); + class Keeper extends RecordingConsumer { + kept = false; + keepsTouch(): boolean { + return this.kept; + } + } + const control = new Keeper(); + detachers.push(host.attachConsumer(row, control)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + // Before the press is delivered, a move still scrolls. + const early = touch( + 'touchmove', + row, + [{...finger, y: 14}], + [{...finger, y: 14}], + 40, + ); + expect(early.defaultPrevented).toBe(false); + // Delivered (highlighted past the content delay): the touch is the control's. + control.kept = true; + const late = touch( + 'touchmove', + row, + [{...finger, y: 54}], + [{...finger, y: 54}], + 200, + ); + expect(late.defaultPrevented).toBe(true); + expect(control.events.some(event => event.startsWith('cancelled'))).toBe( + false, + ); + touch('touchend', row, [{...finger, y: 54}], [], 260); + }); +}); + +describe('detaching a keepsTouch press tracker', () => { + it('removes its blocking touchmove listener while the element keeps its recognizers', () => { + const {row} = mount('
'); + const active = new Set(); + const add = row.addEventListener.bind(row); + const remove = row.removeEventListener.bind(row); + vi.spyOn(row, 'addEventListener').mockImplementation( + (type, listener, options) => { + if ( + type === 'touchmove' && + listener != null && + typeof options === 'object' && + options.passive === false + ) { + active.add(listener); + } + add(type, listener, options); + }, + ); + vi.spyOn(row, 'removeEventListener').mockImplementation( + (type, listener, options) => { + if (type === 'touchmove' && listener != null) { + active.delete(listener); + } + remove(type, listener, options); + }, + ); + // A tap decides at the lift: on its own it needs no blocking listener. + detachers.push(host.attach(row, new TapGestureRecognizer({name: 'tap'}))); + expect(active.size).toBe(0); + class Keeper extends RecordingConsumer { + keepsTouch(): boolean { + return true; + } + } + const detach = host.attachConsumer(row, new Keeper()); + expect(active.size).toBe(1); + detach(); + expect(active.size).toBe(0); + }); +}); + +describe('GestureHost.fingersDown', () => { + it('counts every finger on the screen, one on a plain element too', () => { + const {handle, row} = mount( + '
plain
', + ); + expect(host.fingersDown()).toBe(0); + detachers.push( + host.attach(handle, new LongPressGestureRecognizer({name: 'hold'})), + ); + const first = {id: 1, x: 10, y: 10, target: handle}; + const second = {id: 2, x: 200, y: 10, target: row}; + touch('touchstart', handle, [first], [first], 0); + expect(host.fingersDown()).toBe(1); + // The second finger lands on an element no recognizer is attached to. + touch('touchstart', row, [second], [first, second], 16); + expect(host.fingersDown()).toBe(2); + // A lift lowers it. + touch('touchend', row, [second], [first], 32); + expect(host.fingersDown()).toBe(1); + touch('touchend', handle, [first], [], 48); + expect(host.fingersDown()).toBe(0); + }); + + it('drops to what a touchcancel lists', () => { + const {handle, row} = mount( + '
plain
', + ); + detachers.push( + host.attach(handle, new LongPressGestureRecognizer({name: 'hold'})), + ); + const first = {id: 1, x: 10, y: 10, target: handle}; + const second = {id: 2, x: 200, y: 10, target: row}; + touch('touchstart', handle, [first], [first], 0); + touch('touchstart', row, [second], [first, second], 16); + expect(host.fingersDown()).toBe(2); + // The system takes every finger. + touch('touchcancel', handle, [first, second], [], 32); + expect(host.fingersDown()).toBe(0); + }); + + it('reads 0 once the host stops listening', () => { + const {handle} = mount('
'); + const detach = host.attach( + handle, + new LongPressGestureRecognizer({name: 'hold'}), + ); + const finger = {id: 1, x: 10, y: 10, target: handle}; + touch('touchstart', handle, [finger], [finger], 0); + expect(host.fingersDown()).toBe(1); + detach(); + expect(host.fingersDown()).toBe(0); + }); +}); + +describe('only where the primary pointer is coarse (spec:AST-094 FR19, FR24)', () => { + /** Quiets the development warning, and returns its spy. */ + function quietWarning() { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detachers.push(() => warn.mockRestore()); + return warn; + } + + it('attaches nothing where it is fine: no touch or pointer listener, no style, nothing delivered', () => { + quietWarning(); + detachers.push(pointerQuery(false).restore); + const added = vi.spyOn(EventTarget.prototype, 'addEventListener'); + detachers.push(() => added.mockRestore()); + const {box, row} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const press = new LongPressGestureRecognizer({name: 'press'}); + const consumer = new RecordingConsumer(); + detachers.push( + host.attach(box, pan), + host.attach(box, press), + host.attachConsumer(row, consumer), + ); + expect( + added.mock.calls + .map(([type]) => type) + .filter(type => /^(touch|pointer)/.test(type)), + ).toEqual([]); + // The long press's callout and selection styles stay the browser's. + expect(box.style.getPropertyValue('user-select')).toBe(''); + const finger = {id: 1, x: 5, y: 5}; + const moved = {id: 1, x: 60, y: 5}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [moved], [moved], 16); + touch('touchend', box, [moved], [], 32); + touch('touchstart', row, [finger], [finger], 100); + touch('touchend', row, [finger], [], 150); + expect(pan.state).toBe('possible'); + expect(consumer.events).toEqual([]); + }); + + it('follows the pointer: turning coarse installs it, turning fine cancels the touch in flight and removes it all', () => { + quietWarning(); + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addTransitionListener((_current, _from, to) => states.push(to)); + detachers.push( + host.attach(box, pan), + host.attach(box, new LongPressGestureRecognizer({name: 'press'})), + ); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(false); + // A tablet undocks its mouse. + pointer.set(true); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + const finger = {id: 1, x: 5, y: 5}; + const moved = {id: 1, x: 40, y: 5}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [moved], [moved], 16); + expect(pan.state).toBe('began'); + // It docks one again mid-drag: the drag ends without acting. + pointer.set(false); + expect(states).toContain('cancelled'); + expect(box.style.getPropertyValue('user-select')).toBe(''); + const count = states.length; + const later = {id: 1, x: 80, y: 5}; + touch('touchmove', box, [later], [later], 32); + touch('touchend', box, [later], [], 48); + expect(states).toHaveLength(count); + }); + + it("attaches a recognizer that reads each event's pointer where it is fine, with its own listener and style and no warning (spec:AST-057)", () => { + const warn = quietWarning(); + detachers.push(pointerQuery(false).restore); + const {box} = mount('
'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + }); + const states: string[] = []; + swipe.addTransitionListener((_current, _from, to) => states.push(to)); + detachers.push(host.attach(box, swipe, {perEventPointer: true})); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + const finger = {id: 1, x: 5, y: 5}; + const moved = {id: 1, x: 60, y: 5}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [moved], [moved], 16); + touch('touchend', box, [moved], [], 32); + expect(states).toEqual(expect.arrayContaining(['began', 'ended'])); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps every other recognizer and press consumer beside it inactive where it is fine, and warns for them', () => { + const warn = quietWarning(); + detachers.push(pointerQuery(false).restore); + const {box, row} = mount('
'); + const set = vi.spyOn(row.style, 'setProperty'); + const tap = new TapGestureRecognizer({name: 'tap'}); + const taps: string[] = []; + tap.addTransitionListener((_current, _from, to) => taps.push(to)); + const consumer = new RecordingConsumer(); + detachers.push( + host.attach(box, new PanGestureRecognizer({name: 'swipe'}), { + perEventPointer: true, + }), + host.attach(row, tap), + host.attachConsumer(row, consumer), + ); + expect(set).not.toHaveBeenCalled(); + expect(row.hasAttribute('data-astryx-touch-action')).toBe(false); + const finger = {id: 2, x: 5, y: 5}; + touch('touchstart', row, [finger], [finger], 0); + touch('touchend', row, [finger], [], 50); + expect(taps).toEqual([]); + expect(consumer.events).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('where the pointer is fine, a per-event press tracker takes no mouse press, and attaches with no warning beside one that stays inactive (spec:AST-094 FR19)', () => { + const warn = quietWarning(); + detachers.push(pointerQuery(false).restore); + const {stepper, other} = mount( + '
', + ); + const bounds = {left: 0, top: 0, right: 100, bottom: 100}; + const tracker = (events: string[]) => + new PressTracker({ + getBounds: () => bounds, + onControlEvent: event => events.push(event), + onHighlightChange: isHighlighted => + events.push(isHighlighted ? 'highlight' : 'unhighlight'), + }); + const held: string[] = []; + const inactive: string[] = []; + detachers.push( + host.attachConsumer(stepper, tracker(held), {perEventPointer: true}), + ); + expect(warn).not.toHaveBeenCalled(); + detachers.push(host.attachConsumer(other, tracker(inactive))); + expect(warn).toHaveBeenCalledTimes(1); + pointer('pointerdown', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', stepper, { + pointerType: 'mouse', + x: 5, + y: 5, + time: 600, + }); + // Press feedback is for a finger: a mouse keeps the desktop's handling, + // with no paint and no control event. + expect(held).toEqual([]); + pointer('pointerdown', other, { + id: 2, + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 700, + }); + pointer('pointerup', other, { + id: 2, + pointerType: 'mouse', + x: 5, + y: 5, + time: 800, + }); + expect(inactive).toEqual([]); + }); + + it("where the pointer is fine, gives a per-event press tracker's finger its press feedback only: it paints, reports no control event, and the browser keeps the pan (spec:AST-094 FR19)", () => { + quietWarning(); + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + const {stepper} = mount('
'); + const bounds = {left: 0, top: 0, right: 100, bottom: 100}; + const events: string[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => bounds, + keepsTouch: true, + onControlEvent: event => events.push(event), + onHighlightChange: isHighlighted => highlights.push(isHighlighted), + }); + detachers.push( + host.attachConsumer(stepper, tracker, {perEventPointer: true}), + ); + const finger = {id: 1, x: 5, y: 5}; + const moved = {id: 1, x: 5, y: 60}; + touch('touchstart', stepper, [finger], [finger], 0); + // Its press is painted, as under a coarse pointer, and reports nothing. + expect(highlights).toContain(true); + expect(events).toEqual([]); + // But it never keeps the touch from the browser: the page still pans. + expect( + touch('touchmove', stepper, [moved], [moved], 16).defaultPrevented, + ).toBe(false); + touch('touchend', stepper, [moved], [], 32); + expect(events).toEqual([]); + // On a coarse primary pointer the same held press reports its events + // and keeps its touch. + pointer.set(true); + touch('touchstart', stepper, [finger], [finger], 100); + expect(events).toEqual(['touchDown']); + expect( + touch('touchmove', stepper, [moved], [moved], 116).defaultPrevented, + ).toBe(true); + touch('touchend', stepper, [moved], [], 132); + }); + + it("where the pointer is fine, an idle per-event press tracker leaves no document or window listener: they last only a finger's press (spec:AST-094 FR19)", async () => { + quietWarning(); + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + const {stepper} = mount('
'); + const listening = new Set(); + const watch = (scope: Document | Window, name: string) => { + const addOriginal = scope.addEventListener.bind(scope); + const removeOriginal = scope.removeEventListener.bind(scope); + const add = vi + .spyOn(scope, 'addEventListener') + .mockImplementation( + ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + listening.add(`${name}:${type}`); + addOriginal(type, listener, options); + }, + ); + const remove = vi + .spyOn(scope, 'removeEventListener') + .mockImplementation( + ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) => { + listening.delete(`${name}:${type}`); + removeOriginal(type, listener, options); + }, + ); + detachers.push(() => { + add.mockRestore(); + remove.mockRestore(); + }); + }; + watch(document, 'document'); + watch(window, 'window'); + const highlights: boolean[] = []; + detachers.push( + host.attachConsumer( + stepper, + new PressTracker({ + getBounds: () => ({left: 0, top: 0, right: 100, bottom: 100}), + onHighlightChange: value => highlights.push(value), + }), + {perEventPointer: true}, + ), + ); + const pageListeners = () => + [...listening].filter(key => /:(touch|pointer|click)/.test(key)); + // Attached and idle: nothing on the document or the window, even beside + // a finger-scroll watch. + expect(pageListeners()).toEqual([]); + detachers.push(watchFingerScrolls(host, document)); + expect(pageListeners()).toEqual([]); + const finger = {id: 1, x: 5, y: 5}; + touch('touchstart', stepper, [finger], [finger], 0); + // The press is painted, and the host listens for this press only. + expect(highlights).toContain(true); + expect(pageListeners().length).toBeGreaterThan(0); + touch('touchend', stepper, [finger], [], 40); + await Promise.resolve(); + expect(pageListeners()).toEqual([]); + }); + + it('keeps it working when the pointer turns fine, as the rest comes down', () => { + quietWarning(); + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {box, row} = mount('
'); + const swipe = new PanGestureRecognizer({ + name: 'swipe', + direction: 'horizontal', + }); + const states: string[] = []; + swipe.addTransitionListener((_current, _from, to) => states.push(to)); + detachers.push( + host.attach(box, swipe, {perEventPointer: true}), + host.attach(row, new LongPressGestureRecognizer({name: 'press'})), + ); + expect(row.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + pointer.set(false); + expect(row.hasAttribute('data-astryx-touch-action')).toBe(false); + // The swipe's own touch-action stays. + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + const finger = {id: 3, x: 5, y: 5}; + const moved = {id: 3, x: 60, y: 5}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [moved], [moved], 16); + touch('touchend', box, [moved], [], 32); + expect(states).toEqual(expect.arrayContaining(['began', 'ended'])); + }); + + it('a page hold and a finger-scroll watch register no touch or scroll listener where it is fine, and follow it while they run', () => { + quietWarning(); + const pointer = pointerQuery(false); + detachers.push(pointer.restore); + // On the document or the window, as each listens (jsdom's window carries + // its own listener methods). + const added = [ + vi.spyOn(EventTarget.prototype, 'addEventListener'), + vi.spyOn(window, 'addEventListener'), + ]; + const removed = [ + vi.spyOn(EventTarget.prototype, 'removeEventListener'), + vi.spyOn(window, 'removeEventListener'), + ]; + detachers.push(() => { + for (const spy of [...added, ...removed]) { + spy.mockRestore(); + } + }); + const {list} = mount('
'); + detachers.push( + holdPageScroll(document, () => true), + watchFingerScrolls(host, list), + ); + const types = (spies: typeof added) => + spies + .flatMap(spy => spy.mock.calls.map(([type]) => type)) + .filter(type => /^(touch|scroll)/.test(type)); + expect(types(added)).toEqual([]); + // The hold reads the pointer at its first touch contact: still fine. + document.body.dispatchEvent( + new PointerEvent('pointerdown', { + bubbles: true, + composed: true, + pointerType: 'touch', + }), + ); + expect(types(added)).toEqual([]); + pointer.set(true); + expect(types(added)).toEqual( + expect.arrayContaining([ + 'touchstart', + 'touchmove', + 'scroll', + 'scrollend', + ]), + ); + pointer.set(false); + expect(types(removed)).toEqual( + expect.arrayContaining([ + 'touchstart', + 'touchmove', + 'scroll', + 'scrollend', + ]), + ); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('warns once in development when something attaches where it is fine', () => { + const warn = quietWarning(); + detachers.push(pointerQuery(false).restore); + const {a, b} = mount('
'); + detachers.push( + host.attach(a, new PanGestureRecognizer()), + host.attach(b, new TapGestureRecognizer()), + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain( + 'where the primary pointer is fine', + ); + }); +}); + +describe('an action group under a fine pointer (family:action-groups FR5; spec:AST-094 FR19, FR24)', () => { + const listenerTypes = (spy: ReturnType) => + (spy.mock.calls as unknown[][]) + .map(call => call[0] as string) + .filter(t => /^(touch|pointer|click|blur|scroll|contextmenu)/.test(t)); + + it('keeps one passive pointerdown listener on the group while idle, and none for a recognizer that is not a group', () => { + const {group, other} = mount( + '
', + ); + const added = vi.spyOn(document, 'addEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const otherAdded = vi.spyOn(other, 'addEventListener'); + added.mockClear(); + viewAdded.mockClear(); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + fine.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + fine.attach(other, new TapGestureRecognizer()), + ); + expect(listenerTypes(added)).toEqual([]); + expect(listenerTypes(viewAdded)).toEqual([]); + expect(groupAdded.mock.calls).toEqual([ + ['pointerdown', expect.any(Function), {passive: true}], + ]); + expect(otherAdded).not.toHaveBeenCalled(); + }); + + it('adds page listeners only during a press, removes them at the release, and serves the press to the group', () => { + const {group, one} = mount( + '
', + ); + // The press's listeners come through the shared input stream, on the + // window's capture phase. + const added = vi.spyOn(window, 'addEventListener'); + const removed = vi.spyOn(window, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const tap = new TapGestureRecognizer(); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(fine.attach(group, tap, {actionGroup: true})); + added.mockClear(); + removed.mockClear(); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + expect(listenerTypes(added)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']), + ); + expect(listenerTypes(removed)).toEqual([]); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(states).toContain('ended'); + for (const type of ['pointermove', 'pointerup', 'pointercancel']) { + expect(listenerTypes(removed)).toContain(type); + } + }); + + it("adds no click listener of its own for a group press: the click guard's one listener per window hears its click, before and after the host installs", () => { + // A document of its own: no other host or guard has touched its window. + const frame = document.createElement('iframe'); + document.body.append(frame); + const frameDocument = frame.contentDocument as Document; + const frameWindow = frameDocument.defaultView as Window & typeof globalThis; + frameDocument.body.innerHTML = + '
'; + const group = frameDocument.getElementById('group') as HTMLElement; + const one = frameDocument.getElementById('one') as HTMLElement; + const row = frameDocument.getElementById('row') as HTMLElement; + const added = vi.spyOn(frameWindow, 'addEventListener'); + const clickListeners = () => + added.mock.calls.filter(call => call[0] === 'click').length; + try { + const send = (type: 'pointerdown' | 'pointerup', time: number) => { + const event = new frameWindow.PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType: 'mouse', + clientX: 10, + clientY: 10, + button: 0, + buttons: type === 'pointerdown' ? 1 : 0, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + one.dispatchEvent(event); + }; + const fine = new GestureHost(frameDocument, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detachGroup = fine.attach(group, new TapGestureRecognizer(), { + actionGroup: true, + }); + send('pointerdown', 0); + send('pointerup', 80); + // At most the click guard's own listener, installed once. + const afterFirst = clickListeners(); + expect(afterFirst).toBeLessThanOrEqual(1); + // A per-event recognizer installs the host mid-press: still no click + // listener of the press's own. + send('pointerdown', 1000); + const detachRow = fine.attach(row, new PanGestureRecognizer(), { + perEventPointer: true, + }); + send('pointerup', 1080); + expect(clickListeners()).toBe(afterFirst); + detachRow(); + detachGroup(); + } finally { + added.mockRestore(); + frame.remove(); + } + }); + + it('serves only the group: a recognizer that is not a group, on the press path, takes no part', () => { + const {outer, group, one} = mount( + '
', + ); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const groupTap = new TapGestureRecognizer(); + const outerTap = new TapGestureRecognizer(); + const outerStates: string[] = []; + outerTap.addListener(r => outerStates.push(r.state)); + detachers.push( + fine.attach(group, groupTap, {actionGroup: true}), + fine.attach(outer, outerTap), + ); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 80}); + expect(outerStates).toEqual([]); + }); + + it('serves a finger on the group too, and still answers a mouse afterwards', () => { + const {group, one} = mount( + '
', + ); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const tap = new TapGestureRecognizer(); + const states: string[] = []; + tap.addListener(r => states.push(r.state)); + detachers.push(fine.attach(group, tap, {actionGroup: true})); + pointer('pointerdown', one, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointermove', one, { + pointerType: 'touch', + x: 11, + y: 10, + buttons: 1, + time: 16, + }); + pointer('pointerup', one, {pointerType: 'touch', x: 11, y: 10, time: 60}); + expect(states).toEqual(['ended']); + pointer('pointerdown', one, { + pointerType: 'mouse', + x: 10, + y: 10, + buttons: 1, + time: 400, + }); + pointer('pointerup', one, {pointerType: 'mouse', x: 10, y: 10, time: 460}); + expect(states).toEqual(['ended', 'ended']); + }); + + it('removes the group listener on detach, and a coarse pointer keeps the usual page listeners instead', () => { + const {group} = mount('
'); + const groupRemoved = vi.spyOn(group, 'removeEventListener'); + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + const detach = fine.attach(group, new TapGestureRecognizer(), { + actionGroup: true, + }); + detach(); + expect(groupRemoved).toHaveBeenCalledWith( + 'pointerdown', + expect.any(Function), + ); + const groupAdded = vi.spyOn(group, 'addEventListener'); + const added = vi.spyOn(window, 'addEventListener'); + added.mockClear(); + const coarse = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => true, + }); + detachers.push( + coarse.attach(group, new TapGestureRecognizer(), {actionGroup: true}), + ); + expect(listenerTypes(added)).toContain('pointerdown'); + expect( + groupAdded.mock.calls.filter(call => call[0] === 'pointerdown'), + ).toEqual([]); + }); +}); + +describe("inputMode 'pointer-events'", () => { + /** A clock whose timers the test fires. */ + class TimerClock { + time = 0; + private readonly timers = new Map void>(); + private next = 1; + now(): number { + return this.time; + } + setTimeout(callback: () => void): number { + const id = this.next++; + this.timers.set(id, callback); + return id; + } + clearTimeout(id: number): void { + this.timers.delete(id); + } + /** Runs every timer, and the ones they set, at `time`. */ + fireAll(time: number): void { + this.time = time; + while (this.timers.size > 0) { + const due = [...this.timers.values()]; + this.timers.clear(); + for (const callback of due) { + callback(); + } + } + } + } + + it("tracks a finger once, from its pointer events, so a still finger's long press begins", () => { + const {box} = mount('
'); + const timers = new TimerClock(); + const pointerHost = new GestureHost(document, { + clock: timers, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const press = new LongPressGestureRecognizer(); + const states: string[] = []; + press.addListener(r => states.push(r.state)); + detachers.push(pointerHost.attach(box, press)); + // A browser with Touch Events sends both for one finger. + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + expect(press.numberOfTouches).toBe(1); + timers.fireAll(800); + expect(states).toContain('began'); + pointer('pointerup', box, {pointerType: 'touch', x: 10, y: 10, time: 820}); + touch('touchend', box, [finger], [], 820); + expect(states).toContain('ended'); + }); + + it("cancels a claimed finger's touchmove, matching its touches to its pointer", () => { + const {box} = mount('
'); + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + // Before the pan claims, the browser keeps the move. + const early = {...finger, x: 14}; + pointer('pointermove', box, { + pointerType: 'touch', + x: 14, + y: 10, + buttons: 1, + time: 8, + }); + expect(touch('touchmove', box, [early], [early], 8).defaultPrevented).toBe( + false, + ); + // Past its hysteresis the pan owns the finger: its touchmoves are cancelled. + const claimed = {...finger, x: 40}; + pointer('pointermove', box, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + expect( + touch('touchmove', box, [claimed], [claimed], 32).defaultPrevented, + ).toBe(true); + }); + + it("cancels a claimed finger's touchmove over content inside an open shadow root, matching its touches to its pointer along the flat tree (spec:AST-094 FR19)", () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
x
'; + const row = shadow.getElementById('row') as HTMLElement; + const pointerHost = new GestureHost(document, { + clock, + inputMode: 'pointer-events', + isPrimaryPointerCoarse: () => true, + }); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(pointerHost.attach(outer, pan)); + // The finger lands on the inner node; the claiming host's listener sees + // its touches retargeted to the shadow host. + pointer('pointerdown', row, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 7, x: 10, y: 10, target: outer}; + touch('touchstart', row, [finger], [finger], 0); + pointer('pointermove', row, { + pointerType: 'touch', + x: 40, + y: 10, + buttons: 1, + time: 32, + }); + expect(pan.state).toBe('began'); + const claimed = {...finger, x: 40}; + expect( + touch('touchmove', row, [claimed], [claimed], 32).defaultPrevented, + ).toBe(true); + }); +}); + +describe('an action group across attachments (family:action-groups)', () => { + /** The element's live `pointerdown` listeners, counted as they come and go. */ + function countPointerDowns(element: Element): () => number { + let count = 0; + const add = element.addEventListener.bind(element); + const remove = element.removeEventListener.bind(element); + element.addEventListener = ((type: string, ...rest: [never, never?]) => { + if (type === 'pointerdown') { + count += 1; + } + add(type, ...rest); + }) as typeof element.addEventListener; + element.removeEventListener = ((type: string, ...rest: [never, never?]) => { + if (type === 'pointerdown') { + count -= 1; + } + remove(type, ...rest); + }) as typeof element.removeEventListener; + return () => count; + } + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('keeps an element a group while any of its attachments asks, whatever the order, and only while one does', () => { + const fine = new GestureHost(document, { + clock: new ManualClock(), + isPrimaryPointerCoarse: () => false, + }); + for (const order of ['group first', 'group last'] as const) { + document.body.innerHTML = '
'; + const element = document.getElementById('group') as HTMLElement; + const pointerDowns = countPointerDowns(element); + const attachGroup = () => + fine.attach(element, new TapGestureRecognizer({name: 'group'}), { + actionGroup: true, + }); + const attachPlain = () => + fine.attach(element, new TapGestureRecognizer({name: 'plain'}), { + actionGroup: false, + }); + const [first, second] = + order === 'group first' + ? [attachGroup(), attachPlain()] + : [attachPlain(), attachGroup()]; + const detachGroup = order === 'group first' ? first : second; + const detachPlain = order === 'group first' ? second : first; + // The attachment that asked keeps the element a group. + expect(pointerDowns()).toBe(1); + // Once it goes, the attachment that never asked takes no presses here. + detachGroup(); + expect(pointerDowns()).toBe(0); + detachPlain(); + } + }); +}); + +describe('Touch Events from a browser that exposes no ontouchstart', () => { + it('takes a finger once, from its pointer events, so a one-finger long press recognizes (touch emulation turned on after load)', () => { + // Chromium fixes `ontouchstart` when the page is created; turning touch + // emulation on later sends Touch Events without it. + let owner: object | null = window; + let descriptor: PropertyDescriptor | undefined; + while (owner != null) { + descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart'); + if (descriptor != null) { + break; + } + owner = Object.getPrototypeOf(owner) as object | null; + } + if (owner != null && descriptor != null) { + const from = owner; + const saved = descriptor; + delete (from as {ontouchstart?: unknown}).ontouchstart; + detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved)); + } + expect('ontouchstart' in window).toBe(false); + const {box} = mount('
'); + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + const finger = {id: 1, x: 10, y: 10}; + pointer('pointerdown', box, { + id: 7, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + host.arena.advanceTime(600); + expect(press.state).toBe('began'); + }); + + it("cancels a claimed pan's touchmove there too: the finger read from its pointer events is the one the move reports", () => { + let owner: object | null = window; + let descriptor: PropertyDescriptor | undefined; + while (owner != null) { + descriptor = Object.getOwnPropertyDescriptor(owner, 'ontouchstart'); + if (descriptor != null) { + break; + } + owner = Object.getPrototypeOf(owner) as object | null; + } + if (owner != null && descriptor != null) { + const from = owner; + const saved = descriptor; + delete (from as {ontouchstart?: unknown}).ontouchstart; + detachers.push(() => Object.defineProperty(from, 'ontouchstart', saved)); + } + expect('ontouchstart' in window).toBe(false); + const {box} = mount('
'); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + pointer('pointerdown', box, { + id: 7, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + for (const [x, time] of [ + [30, 16], + [50, 32], + ] as const) { + pointer('pointermove', box, { + id: 7, + pointerType: 'touch', + x, + y: 10, + buttons: 1, + time, + }); + } + expect(['began', 'changed']).toContain(pan.state); + const moved = {...finger, x: 50}; + const move = touch('touchmove', box, [moved], [moved], 32); + expect(move.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts new file mode 100644 index 0000000000000..b428aab9da944 --- /dev/null +++ b/packages/core/src/gestures/__tests__/HoverGestureRecognizer.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file HoverGestureRecognizer.test.ts + * @input Hover entries, moves and exits from a mouse or a hovering pen + * @output HoverGestureRecognizer's state machine: enter, move and exit + * @position Unit tests of packages/core/src/gestures/HoverGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {HoverGestureRecognizer} from '../HoverGestureRecognizer'; + +describe('HoverGestureRecognizer', () => { + it('begins on enter, changes on move and ends on exit', () => { + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + hover.hoverEntered({x: 1, y: 1}, 'mouse'); + hover.hoverMoved({x: 2, y: 2}); + hover.hoverExited({x: 3, y: 3}); + expect(states).toEqual(['began', 'changed', 'ended']); + expect(hover.state).toBe('possible'); + expect(hover.claimsTouches).toBe(false); + }); + + it('hovers again after being disabled or cancelled mid-hover', () => { + const hover = new HoverGestureRecognizer({name: 'hover'}); + hover.hoverEntered({x: 1, y: 1}, 'mouse'); + expect(hover.state).toBe('began'); + hover.setEnabled(false); + expect(hover.state).toBe('cancelled'); + hover.setEnabled(true); + hover.hoverEntered({x: 2, y: 2}, 'mouse'); + expect(hover.state).toBe('began'); + hover.cancel(); + hover.hoverEntered({x: 3, y: 3}, 'mouse'); + expect(hover.state).toBe('began'); + hover.hoverExited(); + }); + + it('starts over at the next enter after a hover was cancelled mid-way', () => { + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + hover.hoverEntered({x: 1, y: 1}, 'mouse'); + hover.setEnabled(false); + hover.setEnabled(true); + hover.hoverEntered({x: 4, y: 4}, 'mouse'); + expect(states).toEqual(['began', 'cancelled', 'began']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts index d1c83b9466072..e298686d0f8d0 100644 --- a/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/LongPressGestureRecognizer.test.ts @@ -9,7 +9,9 @@ import {describe, expect, it} from 'vitest'; import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import type {SelectionLock} from '../GestureRecognizer'; import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {lockSelection} from '../../utils/selectionLock'; import {createHarness} from './harness'; describe('LongPressGestureRecognizer', () => { @@ -139,3 +141,27 @@ describe('LongPressGestureRecognizer', () => { expect(h.log).toContain('press:ended@800'); }); }); + +describe('LongPressGestureRecognizer selection lock (spec:AST-094 FR41)', () => { + it('brings the lock for a hold, none for a touch-down detector at 0 ms, and none when told null', () => { + expect(new LongPressGestureRecognizer().selectionLock).toBe(lockSelection); + expect( + new LongPressGestureRecognizer({minimumPressDuration: 300}).selectionLock, + ).toBe(lockSelection); + // A 0 ms long press recognizes at touch-down: it has no hold to act on, + // so the browser keeps its native long press. + expect( + new LongPressGestureRecognizer({minimumPressDuration: 0}).selectionLock, + ).toBeNull(); + expect( + new LongPressGestureRecognizer({selectionLock: null}).selectionLock, + ).toBeNull(); + const own: SelectionLock = () => () => {}; + expect( + new LongPressGestureRecognizer({ + minimumPressDuration: 0, + selectionLock: own, + }).selectionLock, + ).toBe(own); + }); +}); diff --git a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts index 1cf18ec0b6549..c160b33fa13a8 100644 --- a/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/PanGestureRecognizer.test.ts @@ -8,15 +8,67 @@ */ import {describe, expect, it} from 'vitest'; -import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {UIKIT_PAN, UIKIT_VELOCITY} from '../../motion/uikit/gestures'; import {PanGestureRecognizer} from '../PanGestureRecognizer'; -import {createHarness, scrollProbe} from './harness'; +import {createHarness} from './harness'; + +/** + * Swipes on a UIPickerView column (iOS 26.2 simulator, HID touches): [ms + * since touch-down, y in pt] per event, and the velocity UIKit handed + * scrollViewWillEndDragging, in offset px/s (the content moves against the + * finger). + */ +const PICKER_RELEASES = { + '200 pt in 50 ms': { + stream: [ + [0, 368], + [46.48, 334.67], + [72.92, 201.33], + ], + release: 933.4, + }, + '200 pt in 100 ms': { + stream: [ + [0, 368], + [49.2, 351.33], + [65.27, 301.33], + [88.47, 251.33], + [104.68, 218], + [129.4, 168], + ], + release: 2114, + }, + '200 pt in 200 ms': { + stream: [ + [0, 368], + [46.01, 359.67], + [63.5, 334.67], + [81.36, 318], + [97.69, 301.33], + [120.12, 276.33], + [137.2, 259.67], + [171.83, 226.33], + [196.13, 201.33], + [212.66, 184.67], + [230.83, 168], + ], + release: 1016.2, + }, + 'a 90 pt flick': { + stream: [ + [0, 353], + [41.5, 335], + [62.02, 281], + ], + release: 543.6, + }, +} as const; describe('PanGestureRecognizer', () => { it('begins past the hysteresis and leaves the threshold out of the translation', () => { const h = createHarness(); const pan = new PanGestureRecognizer({name: 'pan'}); - const hysteresis = UIKIT_GESTURES.pan.hysteresis.value; + const hysteresis = UIKIT_PAN.hysteresis.value; h.finger(1).down(100, 100, 0, {recognizers: [pan]}); h.finger(1).move(100 + hysteresis - 0.1, 100, 16); expect(pan.state).toBe('possible'); @@ -75,7 +127,7 @@ describe('PanGestureRecognizer', () => { }); it('releases at 0 after a rest longer than the stillness interval (AST-100 FR2)', () => { - const stillness = UIKIT_GESTURES.velocity.stillness.value; + const stillness = UIKIT_VELOCITY.stillness.value; const release = (liftAt: number) => { const h = createHarness(); const pan = new PanGestureRecognizer({name: 'pan'}); @@ -97,6 +149,56 @@ describe('PanGestureRecognizer', () => { expect(release(100 + stillness + 60)).toEqual({x: 0, y: 0}); }); + it.each(Object.entries(PICKER_RELEASES))( + 'releases a scrolling surface as UIScrollView does: %s', + (_, {stream, release}) => { + const h = createHarness(); + const pan = new PanGestureRecognizer({ + name: 'pan', + direction: 'vertical', + }); + let scroll = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + scroll = -r.scrollVelocity().y; + } + }); + const [first, ...rest] = stream; + const finger = h.finger(1).down(100, first[1], first[0], { + recognizers: [pan], + }); + for (const [time, y] of rest) { + finger.move(100, y, time); + } + finger.up(rest[rest.length - 1][0]); + expect(Math.abs(scroll - release)).toBeLessThan(0.5); + }, + ); + + it("reads a dragged surface's release as velocity(in:), not the scroll-view blend", () => { + const h = createHarness(); + const pan = new PanGestureRecognizer({name: 'pan', direction: 'vertical'}); + let drag = Number.NaN; + let scroll = Number.NaN; + pan.addListener(r => { + if (r.state === 'ended') { + drag = -r.velocity().y; + scroll = -r.scrollVelocity().y; + } + }); + const [first, ...rest] = PICKER_RELEASES['200 pt in 50 ms'].stream; + const finger = h.finger(1).down(100, first[1], first[0], { + recognizers: [pan], + }); + for (const [time, y] of rest) { + finger.move(100, y, time); + } + finger.up(rest[rest.length - 1][0]); + // UIPanGestureRecognizer's velocity(in:) on the same stream. + expect(Math.abs(drag - 1582.3)).toBeLessThan(0.5); + expect(Math.abs(scroll - 933.4)).toBeLessThan(0.5); + }); + it('fails a drag against its direction', () => { const vertical = createHarness(); const horizontal = new PanGestureRecognizer({ diff --git a/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts new file mode 100644 index 0000000000000..fe7c334be6e74 --- /dev/null +++ b/packages/core/src/gestures/__tests__/PinchGestureRecognizer.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PinchGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output PinchGestureRecognizer's state machine: the hysteresis, the scale against the moved starting span, the velocity, extra fingers, re-basing + * @position Unit tests of packages/core/src/gestures/PinchGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {createHarness} from './harness'; + +describe('PinchGestureRecognizer', () => { + it('begins after the hysteresis and reports scale and velocity', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + h.finger(2).move(205, 100, 16); + expect(pinch.state).toBe('possible'); + h.finger(2).move(210, 100, 32); + expect(pinch.state).toBe('began'); + // UIKit: against the starting span moved by the hysteresis (100 + 8). + expect(pinch.scale).toBeCloseTo(110 / 108, 5); + for (let t = 48; t <= 160; t += 16) { + h.finger(2).move(210 + (t - 32) * 0.1, 100, t); + } + // 0.1 px/ms over 108 px. + expect(pinch.velocity).toBeCloseTo(100 / 108, 5); + h.finger(1).up(170); + expect(h.log).toContain('pinch:ended@170'); + }); + + it('ignores a third finger and can be re-based', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(0, 0, 0, {recognizers: [pinch]}); + h.finger(2).down(100, 0, 0, {recognizers: [pinch]}); + h.finger(3).down(50, 50, 5, {recognizers: [pinch]}); + expect(pinch.numberOfTouches).toBe(2); + h.finger(2).move(150, 0, 16); + pinch.scale = 1; + h.finger(2).move(300, 0, 32); + expect(pinch.scale).toBeCloseTo(2, 5); + }); + + it('keeps its release velocity when a listener re-bases on every change', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + pinch.addListener(r => { + if (r.state === 'changed') { + r.scale = 1; + } + }); + let released = Number.NaN; + pinch.addListener(r => { + if (r.state === 'ended') { + released = r.velocity; + } + }); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + let t = 0; + for (let i = 0; i < 10; i++) { + t += 16; + h.finger(2).move(200 + t * 0.5, 100, t); + } + // Lift 8 ms after the last move: still spreading, so not 0. + h.finger(1).up(t + 8); + expect(released).toBeGreaterThan(1); + }); + + it('lets the velocity settle when the span holds while the fingers move', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch]}); + let time = 0; + for (let x = 210; x <= 260; x += 10) { + time += 16; + h.finger(2).move(x, 100, time); + } + const spreading = pinch.velocity; + expect(spreading).toBeGreaterThan(0); + // The second finger circles the first at exactly the same distance (160: + // 3-4-5 triangles), so the span holds while it moves. + for (const [dx, dy] of [ + [128, 96], + [96, 128], + [0, 160], + [-96, 128], + [-128, 96], + ]) { + time += 16; + h.finger(2).move(100 + dx, 100 + dy, time); + } + expect(Math.abs(pinch.velocity)).toBeLessThan(spreading * 0.25); + }); +}); diff --git a/packages/core/src/gestures/__tests__/PressTracker.test.ts b/packages/core/src/gestures/__tests__/PressTracker.test.ts new file mode 100644 index 0000000000000..7ea256b92160f --- /dev/null +++ b/packages/core/src/gestures/__tests__/PressTracker.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PressTracker.test.ts + * @input A PressTracker fed through a GestureArena by synthetic touches + * @output UIControl semantics: events, the expanded tracking bounds, the + * highlight timing inside and outside scrollers, cancellation + * @position Unit tests of packages/core/src/gestures/PressTracker.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PRESS_PROFILES, PressTracker, type ControlEvent} from '../PressTracker'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness, scrollProbe} from './harness'; + +const BOUNDS = {left: 100, top: 100, right: 200, bottom: 140}; +const EXPANSION = UIKIT_GESTURES.control.trackingExpansion.value; +const DELAY = UIKIT_GESTURES.press.contentTouchDelay.value; +const FLASH = UIKIT_GESTURES.press.quickTapFlash.value; + +function setup() { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + }); + return {events, highlights, tracker, h: createHarness()}; +} + +describe('PressTracker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('a keepsTouch tracker keeps only the touch whose press it delivered: a quick tap leaves the next touch free until its own press (spec:AST-094 FR19)', () => { + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + keepsTouch: true, + highlightDelay: 'always', + }); + const h = createHarness(); + // A tap quicker than the delay: the press shows at the lift, for a flash. + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(tracker.keepsTouch?.(1)).toBe(false); + h.finger(1).up(DELAY / 2); + expect(tracker.keepsTouch?.(1)).toBe(false); + // The next touch is not kept from its first move: its press waits for + // the delay, so a scroll that starts on the control is the browser's. + h.finger(2).down(150, 120, DELAY, {recognizers: [], consumer: tracker}); + expect(tracker.keepsTouch?.(2)).toBe(false); + vi.advanceTimersByTime(DELAY); + expect(tracker.keepsTouch?.(2)).toBe(true); + h.finger(2).up(DELAY * 3); + expect(tracker.keepsTouch?.(2)).toBe(false); + }); + + it('highlights in the same frame outside a scroller and acts on touch up inside', () => { + const {events, highlights, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(highlights).toEqual([true]); + h.finger(1).move(152, 121, 16); + h.finger(1).up(80); + expect(events).toEqual(['touchDown', 'touchDragInside', 'touchUpInside']); + expect(highlights).toEqual([true, false]); + }); + + it('keeps tracking inside the expanded bounds and exits past them', () => { + const {events, highlights, tracker, h} = setup(); + const finger = h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + }); + finger.move(200 + EXPANSION, 120, 16); + expect(tracker.isTouchInside).toBe(true); + finger.move(200 + EXPANSION + 1, 120, 32); + expect(tracker.isTouchInside).toBe(false); + finger.move(150, 120, 48); + finger.up(64, 200 + EXPANSION + 10, 120); + expect(events).toEqual([ + 'touchDown', + 'touchDragInside', + 'touchDragExit', + 'touchDragOutside', + 'touchDragEnter', + 'touchDragInside', + 'touchUpOutside', + ]); + expect(highlights).toEqual([true, false, true, false]); + }); + + it('waits the content-touch delay inside a scroller, and flashes a quicker tap', () => { + const {highlights, tracker, h} = setup(); + const probe = scrollProbe(() => true, true); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: probe, + }); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(DELAY - 1); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(1); + expect(highlights).toEqual([true]); + h.finger(1).up(400); + expect(highlights).toEqual([true, false]); + + const quick = setup(); + quick.h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: quick.tracker, + scroll: probe, + }); + quick.h.finger(1).up(60); + expect(quick.highlights).toEqual([true]); + vi.advanceTimersByTime(FLASH); + expect(quick.highlights).toEqual([true, false]); + }); + + it('never paints a pending press whose finger travelled (a flick)', () => { + const {highlights, tracker, h} = setup(); + const probe = scrollProbe(() => true, true); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: probe, + }); + h.finger(1).move(150, 140, 20); + vi.advanceTimersByTime(DELAY * 2); + expect(highlights).toEqual([]); + }); + + it('is cancelled, with no release, when a recognizer takes the touch', () => { + const {events, highlights, tracker, h} = setup(); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(150, 120, 0, {recognizers: [pan], consumer: tracker}); + h.finger(1).move(175, 120, 16); + expect(events).toEqual(['touchDown', 'touchCancel']); + expect(highlights).toEqual([true, false]); + }); + + it('is cancelled when the browser takes the touch to scroll', () => { + const {events, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).takenByBrowser(30); + expect(events).toEqual(['touchDown', 'touchCancel']); + expect(tracker.isTracking).toBe(false); + }); + + it('tracks one finger at a time', () => { + const {events, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(2).down(160, 120, 10, {recognizers: [], consumer: tracker}); + h.finger(2).up(20); + h.finger(1).up(30); + expect(events).toEqual(['touchDown', 'touchUpInside']); + }); +}); + +describe('PressTracker cell profile', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function cell() { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + ...PRESS_PROFILES.cell, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + }); + return {events, highlights, tracker, h: createHarness()}; + } + + it('highlights at once outside a scroller: the delay applies only inside one (AST-094 FR11)', () => { + const {highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + expect(highlights).toEqual([true]); + vi.advanceTimersByTime(DELAY); + expect(highlights).toEqual([true]); + h.finger(1).up(DELAY + 40); + expect(highlights).toEqual([true, false]); + }); + + it('waits the content-touch delay inside a scroller', () => { + const {highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + scroll: scrollProbe(() => true, true), + }); + expect(highlights).toEqual([]); + vi.advanceTimersByTime(DELAY); + expect(highlights).toEqual([true]); + h.finger(1).up(DELAY + 40); + expect(highlights).toEqual([true, false]); + }); + + it('is cancelled for good once the finger travels past the pan hysteresis', () => { + const {events, highlights, tracker, h} = cell(); + const finger = h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + }); + vi.advanceTimersByTime(DELAY); + const limit = UIKIT_GESTURES.pan.hysteresis.value; + finger.move(150, 120 + limit, DELAY + 16); + expect(tracker.isTracking).toBe(true); + finger.move(150, 120 + limit + 1, DELAY + 32); + expect(tracker.isTracking).toBe(false); + // Back inside: no re-highlight, and the lift does nothing. + finger.move(150, 120, DELAY + 48); + finger.up(DELAY + 64); + expect(events).toEqual(['touchDown', 'touchDragInside', 'touchCancel']); + expect(highlights).toEqual([true, false]); + }); + + it('shows a quicker tap at the lift straight into the release, with no flash', () => { + const {events, highlights, tracker, h} = cell(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).up(60); + expect(events).toEqual(['touchDown', 'touchUpInside']); + expect(highlights).toEqual([true, false]); + }); + + it('keeps the control profile on today defaults, with the button release clock', () => { + expect(PRESS_PROFILES.control.highlightDelay).toBe('inScrollers'); + expect(PRESS_PROFILES.control.quickTapFlash).toBe(FLASH); + expect(PRESS_PROFILES.cell.cancelTravel).toBe( + UIKIT_GESTURES.pan.hysteresis.value, + ); + }); +}); + +describe('PressTracker in the arena', () => { + it("lets a control veto an ancestor's single tap (UIControl's rule)", () => { + const h = createHarness(); + const button = new PressTracker({getBounds: () => null, view: 'button'}); + const tap = new TapGestureRecognizer({name: 'tap'}); + tap.view = 'row'; + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + double.view = 'row'; + h.finger(1).down(0, 0, 0, {recognizers: [tap, double], consumer: button}); + expect(h.finger(1).up(50)).toBe('defer'); + expect(h.log[0]).toBe('tap:failed@50'); + expect(double.state).toBe('possible'); + }); +}); diff --git a/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts new file mode 100644 index 0000000000000..f008b6ee8d612 --- /dev/null +++ b/packages/core/src/gestures/__tests__/RotationGestureRecognizer.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file RotationGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output RotationGestureRecognizer's state machine: the hysteresis and unwrapping past half a turn + * @position Unit tests of packages/core/src/gestures/RotationGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {RotationGestureRecognizer} from '../RotationGestureRecognizer'; +import {createHarness} from './harness'; + +describe('RotationGestureRecognizer', () => { + it('begins after the hysteresis and unwraps past half a turn', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + const at = (angle: number, time: number) => + h.finger(2).move(100 * Math.cos(angle), 100 * Math.sin(angle), time); + at((3 * Math.PI) / 180, 16); + expect(rotation.state).toBe('possible'); + at((6 * Math.PI) / 180, 32); + expect(rotation.state).toBe('began'); + let time = 32; + for (let degrees = 30; degrees <= 270; degrees += 30) { + time += 16; + at((degrees * Math.PI) / 180, time); + } + expect(rotation.rotation).toBeCloseTo((270 * Math.PI) / 180, 5); + expect(rotation.velocity).toBeGreaterThan(0); + }); + + it('keeps its velocity continuous when the rotation is re-based mid-turn', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + const at = (degrees: number, time: number) => + h + .finger(2) + .move( + 100 * Math.cos((degrees * Math.PI) / 180), + 100 * Math.sin((degrees * Math.PI) / 180), + time, + ); + // 2 degrees every 16 ms: about 2.18 rad/s. + let time = 0; + let degrees = 0; + for (let i = 0; i < 8; i++) { + time += 16; + degrees += 2; + at(degrees, time); + } + expect(rotation.state).toBe('changed'); + rotation.rotation = 0; + for (let i = 0; i < 3; i++) { + time += 16; + degrees += 2; + at(degrees, time); + expect(rotation.velocity).toBeGreaterThan(1.5); + } + }); + + it('keeps its release velocity when a listener re-bases on every change', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + rotation.addListener(r => { + if (r.state === 'changed') { + r.rotation = 0; + } + }); + let released = Number.NaN; + rotation.addListener(r => { + if (r.state === 'ended') { + released = r.velocity; + } + }); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + let time = 0; + let degrees = 0; + for (let i = 0; i < 8; i++) { + time += 16; + degrees += 2; + h.finger(2).move( + 100 * Math.cos((degrees * Math.PI) / 180), + 100 * Math.sin((degrees * Math.PI) / 180), + time, + ); + } + // Lift 8 ms after the last move: 2 degrees every 16 ms, about 2.18 rad/s, + // the same release as with no re-base (spec:AST-100 FR2). + h.finger(2).up(time + 8); + expect(released).toBeCloseTo((2 * Math.PI) / 180 / 0.016, 1); + }); + + it('setting rotation (UIKit\u2019s incremental read) never moves the velocity', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + const at = (degrees: number, time: number) => + h + .finger(2) + .move( + 100 * Math.cos((degrees * Math.PI) / 180), + 100 * Math.sin((degrees * Math.PI) / 180), + time, + ); + let time = 0; + for (let degrees = 10; degrees <= 60; degrees += 10) { + time += 16; + at(degrees, time); + // Read and reset each frame, as an incremental consumer does. + rotation.rotation = 0; + } + expect(rotation.velocity).toBeGreaterThan(0); + }); + + it('lets the velocity settle when the angle holds while the fingers move', () => { + const h = createHarness(); + const rotation = new RotationGestureRecognizer({name: 'rotation'}); + h.finger(1).down(0, 0, 0, {recognizers: [rotation]}); + h.finger(2).down(100, 0, 0, {recognizers: [rotation]}); + let time = 0; + for (const degrees of [10, 20, 40, 60, 80]) { + time += 16; + const a = (degrees * Math.PI) / 180; + h.finger(2).move(100 * Math.cos(a), 100 * Math.sin(a), time); + } + // Straight up from the first finger: exactly a quarter turn. + time += 16; + h.finger(2).move(0, 100, time); + const turning = rotation.velocity; + expect(turning).toBeGreaterThan(0); + // The second finger slides outward along the same line: the angle holds + // exactly while the finger moves. + for (let y = 110; y <= 160; y += 10) { + time += 16; + h.finger(2).move(0, y, time); + } + expect(Math.abs(rotation.velocity)).toBeLessThan(turning * 0.25); + }); +}); diff --git a/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts new file mode 100644 index 0000000000000..0ca3efc69ccb5 --- /dev/null +++ b/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ScreenEdgePanGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output ScreenEdgePanGestureRecognizer's state machine (the edge band, RTL edges, inward movement) and its edge-zone touch-action + * @position Unit tests of packages/core/src/gestures/ScreenEdgePanGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {UIKIT_SCREEN_EDGE_PAN} from '../../motion/uikit/gestures'; +import {ALL_NATIVE_TOUCH} from '../GestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {createHarness} from './harness'; + +describe('ScreenEdgePanGestureRecognizer', () => { + it('begins from the leading band and fails elsewhere at touch-down', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(40, 4, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + expect(edge.activeEdge).toBe('left'); + + const middle = createHarness(); + const miss = new ScreenEdgePanGestureRecognizer({name: 'miss'}); + middle.finger(1).down(60, 300, 0, {recognizers: [miss]}); + expect(middle.log).toEqual(['miss:failed@0']); + }); + + it('resolves the leading edge under RTL and requires inward movement', () => { + const h = createHarness(); + h.environment.rtl = true; + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(385, 300, 0, {recognizers: [edge]}) + .drag(-40, 2, 50); + expect(edge.activeEdge).toBe('right'); + expect(h.log[0]).toMatch(/^edge:began@/); + + const outward = createHarness(); + const back = new ScreenEdgePanGestureRecognizer({name: 'back'}); + outward + .finger(1) + .down(10, 300, 0, {recognizers: [back]}) + .drag(-8, 30, 50); + expect(back.state).toBe('failed'); + }); + + it('a drag 5 pt inward and then down the edge is the page\u2019s scroll: it fails and claims nothing (spec:AST-094 FR19)', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(5, 0, 30) + .drag(0, 20, 50); + expect(edge.state).toBe('failed'); + expect(h.claims).toEqual([]); + }); + + it('leaves a view wider than the edge zone every touch behavior, so its scrolling is untouched (spec:AST-094 FR18)', () => { + expect(new ScreenEdgePanGestureRecognizer().nativeTouchBehavior).toEqual( + ALL_NATIVE_TOUCH, + ); + const page = new ScreenEdgePanGestureRecognizer(); + page.view = viewOfSize(390, 844); + expect(page.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + // A pan from several edges too, off their zones. + expect( + new ScreenEdgePanGestureRecognizer({edges: ['left', 'right']}) + .nativeTouchBehavior, + ).toEqual(ALL_NATIVE_TOUCH); + }); + + it('takes the axis across its edge in the edge zone, a view no wider than the edge region (spec:AST-094 FR18)', () => { + const leading = new ScreenEdgePanGestureRecognizer(); + leading.view = viewOfSize(leading.edgeRegion, 844); + expect(leading.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + const bottom = new ScreenEdgePanGestureRecognizer({edges: ['bottom']}); + bottom.view = viewOfSize(390, 12); + expect(bottom.nativeTouchBehavior).toEqual({ + panX: true, + panY: false, + pinchZoom: true, + doubleTapZoom: false, + }); + // One pixel wider than the band is not the edge zone. + const wide = new ScreenEdgePanGestureRecognizer(); + wide.view = viewOfSize(wide.edgeRegion + 1, 844); + expect(wide.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + }); + + it('takes a strip authored at the edge region as the edge zone though layout lays it out at 40.203 px', () => { + const strip = new ScreenEdgePanGestureRecognizer(); + // 40.2 px snapped to layout's 1/64 px. + strip.view = viewOfSize(Math.ceil(strip.edgeRegion * 64) / 64, 844); + expect(strip.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: 40.203, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30); + expect(edge.state).toBe('possible'); + h.finger(1).drag(2, 0, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('in the edge zone, still decides at its hysteresis', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: edge.edgeRegion, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(10, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30); + expect(edge.state).toBe('possible'); + h.finger(1).drag(2, 0, 50); + expect(h.log[0]).toMatch(/^edge:began@/); + }); +}); + +describe('where an in-band touch decides (spec:AST-094 FR18, DEC-22)', () => { + it('on an element that reaches beyond the band, at 10 px, before the browser commits to a scroll', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + // A stack container: as wide as the screen, the touch 4 px from its edge. + h.environment.view = {width: 390, height: 844}; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(9, 0, 30, 9); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('on an element within the band, at 15 px', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.environment.view = {width: edge.edgeRegion, height: 844}; + const hysteresis = UIKIT_SCREEN_EDGE_PAN.hysteresis.value; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(hysteresis - 1, 0, 30, 14); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); + + it('from several edges, at 10 px on an element beyond their bands', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({ + name: 'edge', + edges: ['left', 'right'], + }); + h.environment.view = {width: 390, height: 844}; + h.finger(1) + .down(4, 300, 0, {recognizers: [edge]}) + .drag(9, 0, 30, 9); + expect(edge.state).toBe('possible'); + h.finger(1).drag(1, 0, 10, 1); + expect(h.log[0]).toMatch(/^edge:began@/); + }); +}); + +describe('a pan from several edges (spec:AST-094 FR18)', () => { + it('outside every edge zone leaves the browser every touch behavior', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(390, 844); + expect(edge.nativeTouchBehavior).toEqual(ALL_NATIVE_TOUCH); + }); + + it('inside a side edge zone claims pan-x only', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(edge.edgeRegion, 844); + expect(edge.nativeTouchBehavior).toEqual({ + panX: false, + panY: true, + pinchZoom: true, + doubleTapZoom: false, + }); + }); + + it('inside a bottom edge zone claims pan-y only', () => { + const edge = new ScreenEdgePanGestureRecognizer({ + edges: ['left', 'bottom'], + }); + edge.view = viewOfSize(390, edge.edgeRegion); + expect(edge.nativeTouchBehavior).toEqual({ + panX: true, + panY: false, + pinchZoom: true, + doubleTapZoom: false, + }); + }); +}); + +/** An element whose box has `width` × `height`, as layout would give it. */ +function viewOfSize(width: number, height: number): HTMLElement { + const view = document.createElement('div'); + view.getBoundingClientRect = () => + ({width, height, top: 0, left: 0, right: width, bottom: height}) as DOMRect; + return view; +} diff --git a/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts new file mode 100644 index 0000000000000..3cb8572b948d3 --- /dev/null +++ b/packages/core/src/gestures/__tests__/SwipeGestureRecognizer.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file SwipeGestureRecognizer.test.ts + * @input Synthetic touches with explicit timestamps through a GestureArena + * @output SwipeGestureRecognizer's state machine: recognition during the motion, and failure when too slow, off-axis or the wrong way + * @position Unit tests of packages/core/src/gestures/SwipeGestureRecognizer.ts + */ + +import {describe, expect, it} from 'vitest'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {createHarness} from './harness'; + +describe('SwipeGestureRecognizer', () => { + it('is recognized during the motion, once the distance is covered', () => { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + finger.move(30, 2, 30); + expect(swipe.state).toBe('possible'); + finger.move(50, 4, 50); + expect(swipe.recognizedDirection).toBe('right'); + expect(h.log[0]).toBe('swipe:ended@50'); + }); + + it('fails when too slow, off-axis, or the wrong way', () => { + const slow = createHarness(); + const a = new SwipeGestureRecognizer({name: 'a'}); + slow + .finger(1) + .down(0, 0, 0, {recognizers: [a]}) + .move(30, 0, 400); + slow.advance(500); + expect(a.state).toBe('failed'); + + // Off-axis: still possible while the swipe could be reached (45 along, + // 40 across), failed once the drift passes the 50 px across limit. + const diagonal = createHarness(); + const b = new SwipeGestureRecognizer({name: 'b'}); + diagonal + .finger(1) + .down(0, 0, 0, {recognizers: [b]}) + .move(45, 55, 60); + // 55 px across is past the 50 px secondary allowance. + expect(b.state).toBe('failed'); + + const wrong = createHarness(); + const c = new SwipeGestureRecognizer({name: 'c', direction: 'left'}); + wrong + .finger(1) + .down(100, 0, 0, {recognizers: [c]}) + .move(160, 0, 60); + expect(c.state).toBe('failed'); + }); + + it('takes several directions and fingers', () => { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + direction: ['up', 'down'], + numberOfTouchesRequired: 2, + }); + h.finger(1).down(0, 200, 0, {recognizers: [swipe]}); + h.finger(2).down(40, 200, 5, {recognizers: [swipe]}); + h.finger(1).move(0, 140, 50); + h.finger(2).move(40, 140, 50); + expect(swipe.recognizedDirection).toBe('up'); + }); + + it('gives a 20 degree drag the same answer in small and large steps', () => { + for (const step of [4, 9]) { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + direction: 'right', + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + const angle = (20 * Math.PI) / 180; + let travelled = 0; + let time = 0; + while (swipe.state === 'possible' && travelled < 80) { + travelled += step; + time += 8; + finger.move( + travelled * Math.cos(angle), + travelled * Math.sin(angle), + time, + ); + } + expect(swipe.state).toBe('ended'); + finger.up(time + 8); + } + }); + + it('gives the same verdict for the same path however its events are spaced', () => { + // A straight drag to (55, 40): along 55 past the 50 px distance, 40 px + // across within the 50 px allowance. + const sparse = createHarness(); + const a = new SwipeGestureRecognizer({name: 'a'}); + sparse + .finger(1) + .down(0, 0, 0, {recognizers: [a]}) + .move(55, 40, 40); + expect(a.state).toBe('ended'); + + const dense = createHarness(); + const b = new SwipeGestureRecognizer({name: 'b'}); + const finger = dense.finger(1).down(0, 0, 0, {recognizers: [b]}); + for (let step = 1; step <= 10; step += 1) { + finger.move(5.5 * step, 4 * step, 4 * step); + } + expect(b.state).toBe('ended'); + expect(b.recognizedDirection).toBe('right'); + }); + + it('recognizes a 40\u00b0 swipe at 16 px per event as at small steps', () => { + // At 40\u00b0 the travel along reaches 50 px with 42 px across, inside + // the 50 px allowance; at 16 px per event the next event is already 51 px + // across, so the verdict must come from the path between the events. + const angle = (40 * Math.PI) / 180; + for (const step of [2, 16]) { + const h = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [swipe]}); + for (let d = step, t = 4; d <= 96; d += step, t += 4) { + finger.move(d * Math.cos(angle), d * Math.sin(angle), t); + if (swipe.state !== 'possible') { + break; + } + } + expect(swipe.state).toBe('ended'); + expect(swipe.recognizedDirection).toBe('right'); + } + }); +}); diff --git a/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts b/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts index 3d1c8fffceb43..5b731aa83e206 100644 --- a/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts +++ b/packages/core/src/gestures/__tests__/TapGestureRecognizer.test.ts @@ -151,4 +151,25 @@ describe('TapGestureRecognizer', () => { h.advance(400); expect(tap.state).toBe('failed'); }); + + it('recognizes a two-finger double tap at the same two points', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({ + name: 'double2', + numberOfTapsRequired: 2, + numberOfTouchesRequired: 2, + }); + for (const start of [0, 200]) { + const left = h + .finger(start + 1) + .down(100, 100, start, {recognizers: [tap]}); + const right = h + .finger(start + 2) + .down(200, 100, start + 10, {recognizers: [tap]}); + // The fingers lift one after the other, the right one last. + left.up(start + 60); + right.up(start + 80); + } + expect(h.log).toContain('double2:ended@280'); + }); }); diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index 8cc06fc5fe270..062eda64833e7 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -24,6 +24,7 @@ import { import type {GestureEventContext, GestureTouch} from '../types'; import {GestureArena, type GestureClock} from '../GestureArena'; import {createHarness, RecordingConsumer} from './harness'; +import {internals} from '../recognizerInternals'; type Step = (recognizer: Scripted, event: GestureEventContext) => void; @@ -75,6 +76,11 @@ class Scripted extends GestureRecognizer { this.setState('failed'); } + /** Cancel itself, as a recognizer that gives up mid-gesture does. */ + cancelSelf(): void { + this.setState('cancelled'); + } + /** Recognize at `deadline` on the event clock, unless the touch lifts. */ recognizeAt(deadline: number): void { this.setTimer(deadline, () => this.recognize()); @@ -171,7 +177,7 @@ describe('arena: who wins', () => { h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); expect(single.state).toBe('possible'); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); h.advance(400); expect(h.log).toEqual([ 'double:failed@400', @@ -210,7 +216,7 @@ describe('arena: who wins', () => { }); h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); }); it('honors shouldBegin and shouldReceiveTouch', () => { @@ -517,6 +523,36 @@ describe('arena: claims', () => { first.up(80); second.up(80); }); + + it('keeps a moving finger claimed until it lifts when its gesture cancels itself on that move, so the drag never turns into the page scroll midway (FR14)', () => { + const h = createHarness(); + // A long press's shape: it begins while the finger rests, then cancels + // itself on the first move past its tolerance. + const press = new Scripted('press', { + began: (self, event) => self.recognizeAt(event.time + 500), + moved: (self, event) => { + const touch = event.allTouches[0]; + if ( + touch != null && + Math.abs(touch.y - touch.startY) > 10 && + isActive(self.state) + ) { + self.cancelSelf(); + } + }, + }); + const finger = h.finger(1).down(0, 0, 0, {recognizers: [press]}); + h.advance(600); + expect(press.state).toBe('began'); + expect(h.arena.isClaimed(1)).toBe(true); + finger.move(0, -11, 616); + expect(press.state).toBe('cancelled'); + expect(h.arena.isClaimed(1)).toBe(true); + finger.move(0, -60, 632); + expect(h.arena.isClaimed(1)).toBe(true); + finger.up(700); + expect(h.arena.isClaimed(1)).toBe(false); + }); }); /** Records the touches that began off its hit path. */ diff --git a/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts new file mode 100644 index 0000000000000..54829fb2aeac3 --- /dev/null +++ b/packages/core/src/gestures/__tests__/arenaPinchSwipeEdge.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file arenaPinchSwipeEdge.test.ts + * @input GestureArena with pinches, swipes, edge pans and pans, driven by + * synthetic touches with explicit timestamps + * @output Simultaneous recognition (pinch + pan), the first to recognize + * winning without permission, edge pan vs inner pan, swipe vs pan, and a + * delayed touch delivery + * @position Unit tests of packages/core/src/gestures/GestureArena.ts + */ + +import {describe, expect, it} from 'vitest'; +import {UIKIT_PINCH, UIKIT_SWIPE} from '../../motion/uikit/gestures'; +import {GestureRecognizer} from '../GestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {createHarness, RecordingConsumer} from './harness'; + +/** + * A recognizer that stays possible, even past its finger's lift, until the + * test fails it: something another recognizer can wait on. + */ +class Held extends GestureRecognizer { + private isWaiting = true; + + override isInvolved(): boolean { + return super.isInvolved() || this.isWaiting; + } + + fail(): void { + this.isWaiting = false; + this.setState('failed'); + } +} + +describe('conflict matrix', () => { + it('pinch + pan recognize simultaneously when allowed', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + pinch.recognizeSimultaneouslyWith(pan); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, pan]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, pan]}); + h.finger(1).move(80, 120, 16); + h.finger(2).move(240, 120, 16); + expect(h.log.slice(0, 2)).toEqual(['pinch:began@16', 'pan:began@16']); + // Span 100 -> 160, measured against 100 + 8. + expect(pinch.scale).toBeCloseTo(160 / 108, 5); + // The pan began at the first finger's move, centroid (-10, 10), which + // the hysteresis swallows; the second finger's move brought it to (10, 20). + expect(pan.translation()).toEqual({x: 20, y: 10}); + }); + + it('pinch vs pan without permission: the first to recognize wins', () => { + const h = createHarness(); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, pan]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, pan]}); + h.finger(1).move(80, 120, 16); + h.finger(2).move(240, 120, 16); + expect(h.log.slice(0, 2)).toEqual(['pinch:began@16', 'pan:failed@16']); + }); + + it('edge pan vs inner pan: in the band the edge pan wins, elsewhere the inner pan runs undelayed', () => { + const band = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + const inner = new PanGestureRecognizer({ + name: 'inner', + direction: 'horizontal', + }); + inner.requireToFail(edge); + // A thin edge view: the edge zone, where the pan takes its axis. + band.environment.view = {width: edge.edgeRegion, height: 844}; + band.finger(1).down(8, 300, 0, {recognizers: [inner, edge]}); + band.finger(1).drag(60, 2, 60, 6); + // The edge pan's own hysteresis is 15 px: the second 10 px step. + expect(band.log.slice(0, 2)).toEqual(['edge:began@20', 'inner:failed@20']); + + const middle = createHarness(); + const edge2 = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + const inner2 = new PanGestureRecognizer({ + name: 'inner', + direction: 'horizontal', + }); + inner2.requireToFail(edge2); + middle.finger(1).down(150, 300, 0, {recognizers: [inner2, edge2]}); + // Failed at touch-down, before any movement: nothing waits on it. + expect(middle.log).toEqual(['edge:failed@0']); + middle.finger(1).drag(60, 2, 60, 6); + expect(middle.log[1]).toBe('inner:began@10'); + }); + + it('swipe vs pan: the pan wins by default, the swipe when the pan requires it to fail', () => { + const plain = createHarness(); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + plain + .finger(1) + .down(0, 0, 0, {recognizers: [swipe, pan]}) + .drag(80, 0, 60, 6); + expect(plain.log.slice(0, 2)).toEqual(['pan:began@10', 'swipe:failed@10']); + + const ordered = createHarness(); + const swipe2 = new SwipeGestureRecognizer({name: 'swipe'}); + const pan2 = new PanGestureRecognizer({name: 'pan'}); + pan2.requireToFail(swipe2); + ordered + .finger(1) + .down(0, 0, 0, {recognizers: [swipe2, pan2]}) + .drag(80, 0, 60, 8); + expect(ordered.log.slice(0, 2)).toEqual([ + 'swipe:ended@37.5', + 'pan:failed@37.5', + ]); + + const slow = createHarness(); + const swipe3 = new SwipeGestureRecognizer({name: 'swipe'}); + const pan3 = new PanGestureRecognizer({name: 'pan'}); + pan3.requireToFail(swipe3); + slow + .finger(1) + .down(0, 0, 0, {recognizers: [swipe3, pan3]}) + .drag(30, 0, 300, 3); + expect(pan3.state).toBe('possible'); + slow.advance(500); + // The swipe ran out of time; the waiting pan begins with what it has. + expect(slow.log.slice(0, 2)).toEqual(['swipe:failed@500', 'pan:began@500']); + }); +}); + +describe('a recognizer waiting on another', () => { + it('a swipe holds its recognition past its lift and its deadline (spec:AST-094 FR15)', () => { + const h = createHarness(); + const held = new Held({name: 'held'}); + const swipe = new SwipeGestureRecognizer({name: 'swipe'}); + swipe.requireToFail(held); + h.finger(1) + .down(0, 0, 0, {recognizers: [swipe, held]}) + .drag(80, 0, 60, 8) + .up(70); + h.advance(UIKIT_SWIPE.maximumDuration.value + 100); + expect(swipe.state).toBe('possible'); + expect(h.log.some(entry => entry.startsWith('swipe:failed'))).toBe(false); + held.fail(); + expect(h.log).toContain( + `swipe:ended@${UIKIT_SWIPE.maximumDuration.value + 100}`, + ); + expect(swipe.recognizedDirection).toBe('right'); + }); + + it('a pinch keeps its start once it passes the hysteresis, so it begins at the scale its fingers show (spec:AST-094 FR15)', () => { + const h = createHarness(); + const held = new Held({name: 'held'}); + const pinch = new PinchGestureRecognizer({name: 'pinch'}); + pinch.requireToFail(held); + h.finger(1).down(100, 100, 0, {recognizers: [pinch, held]}); + h.finger(2).down(200, 100, 0, {recognizers: [pinch, held]}); + // Span 100 -> 120 -> 140 -> 160 while the pinch waits. + h.finger(2).move(220, 100, 16); + h.finger(2).move(240, 100, 32); + h.finger(2).move(260, 100, 48); + expect(pinch.state).toBe('possible'); + held.fail(); + expect(h.log).toContain('pinch:began@48'); + // Measured against 100 + 8 once, not re-based on every move. + const hysteresis = UIKIT_PINCH.hysteresis.value; + expect(pinch.scale).toBeCloseTo(160 / (100 + hysteresis), 5); + }); +}); + +describe('delivery to the view', () => { + it('holds the began with delaysTouchesBegan until the recognizer fails', () => { + const h = createHarness(); + const consumer = new RecordingConsumer(); + const swipe = new SwipeGestureRecognizer({ + name: 'swipe', + delaysTouchesBegan: true, + }); + h.finger(1).down(0, 0, 0, {recognizers: [swipe], consumer}); + expect(consumer.events).toEqual([]); + h.advance(UIKIT_SWIPE.maximumDuration.value); + expect(consumer.events).toEqual(['began:1']); + }); +}); diff --git a/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts b/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts index 91bd0b54033b5..24267edb00919 100644 --- a/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts +++ b/packages/core/src/gestures/__tests__/arenaTapLongPress.test.ts @@ -16,6 +16,7 @@ import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; import {TapGestureRecognizer} from '../TapGestureRecognizer'; import {UIKIT_GESTURES} from '../../motion/uikit/gestures'; import {createHarness, RecordingConsumer} from './harness'; +import {internals} from '../recognizerInternals'; const INTERVAL = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; @@ -31,7 +32,7 @@ describe('conflict matrix', () => { h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(80); expect(single.state).toBe('possible'); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); h.advance(80 + INTERVAL); expect(h.log).toEqual([ `double:failed@${80 + INTERVAL}`, @@ -41,6 +42,31 @@ describe('conflict matrix', () => { ]); }); + it('a requirement stopped inside deferResolution leaves the recognition waiting on it to a pass a microtask later', async () => { + const h = createHarness(); + const single = new TapGestureRecognizer({name: 'single'}); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + single.requireToFail(double); + h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); + h.finger(1).up(80); + // Code outside input (a React Effect) turns the double tap off: it fails + // at once, at the last event's time, and the single tap waiting on it + // resolves only in the next pass. + h.arena.deferResolution(() => double.setEnabled(false)); + expect(h.log).toEqual(['double:failed@80']); + expect(internals(single).pending).toBe('ended'); + await Promise.resolve(); + expect(h.log).toEqual([ + 'double:failed@80', + 'single:ended@80', + 'single:possible@80', + 'double:possible@80', + ]); + }); + it('tap vs double tap, single requires double to fail: a double tap fails the single', () => { const h = createHarness(); const single = new TapGestureRecognizer({name: 'single'}); @@ -95,7 +121,7 @@ describe('conflict matrix', () => { }); h.finger(1).down(0, 0, 0, {recognizers: [single, double]}); h.finger(1).up(50); - expect(single.pending).toBe('ended'); + expect(internals(single).pending).toBe('ended'); }); }); diff --git a/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts b/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts new file mode 100644 index 0000000000000..e2aaeaa925a0f --- /dev/null +++ b/packages/core/src/gestures/__tests__/arenaTimersAndClaims.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file arenaTimersAndClaims.test.ts + * @input Uses vitest, GestureArena, the tap, long-press and pan recognizers + * @output Checks a late timer against queued input, cancelling an idle + * recognizer, and the claim a waiting recognition takes + * @position Test for packages/core/src/gestures/GestureArena.ts + */ + +import {describe, expect, it} from 'vitest'; +import {GestureArena, type GestureClock} from '../GestureArena'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness} from './harness'; + +/** A clock whose timers run one task at a time, as an event loop does. */ +class TaskClock implements GestureClock { + time = 0; + private nextId = 1; + private readonly tasks = new Map void}>(); + now(): number { + return this.time; + } + setTimeout(run: () => void, delay: number): number { + const id = this.nextId++; + this.tasks.set(id, {at: this.time + delay, run}); + return id; + } + clearTimeout(id: number): void { + this.tasks.delete(id); + } + /** Run the earliest task due by `time`, if any. */ + runTask(time: number): void { + this.time = time; + const due = [...this.tasks.entries()] + .filter(([, task]) => task.at <= time) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (due != null) { + this.tasks.delete(due[0]); + due[1].run(); + } + } +} + +function pressArena(): { + clock: TaskClock; + arena: GestureArena; + press: LongPressGestureRecognizer; + log: string[]; +} { + const clock = new TaskClock(); + const log: string[] = []; + const arena: GestureArena = new GestureArena({ + clock, + onTransition: (recognizer, _from, to) => + log.push(`${recognizer.name}:${to}@${arena.now()}`), + }); + const press = new LongPressGestureRecognizer({name: 'press'}); + arena.began([ + { + id: 1, + x: 0, + y: 0, + time: 0, + pointerType: 'touch', + target: null, + scroll: null, + recognizers: [press], + consumer: null, + }, + ]); + return {clock, arena, press, log}; +} + +describe('a timer that fires late', () => { + it('lets a lift queued before its deadline run first', () => { + const {clock, arena, log} = pressArena(); + // A long task held the main thread from 100 to 800 ms; the finger + // lifted at 300. The timer task runs first, then the queued lift. + clock.runTask(800); + arena.ended([{id: 1, x: 0, y: 0, time: 300}]); + clock.runTask(800); + expect(log.some(entry => entry.startsWith('press:began'))).toBe(false); + }); + + it('keeps the timestamps of queued moves', () => { + const {clock, arena, log} = pressArena(); + clock.runTask(800); + // The finger travelled 40 px at 300 ms: past the allowable movement. + arena.moved([{id: 1, x: 40, y: 0, time: 300}]); + clock.runTask(800); + expect(log.some(entry => entry.startsWith('press:began'))).toBe(false); + expect(log).toContain('press:failed@300'); + arena.ended([{id: 1, x: 40, y: 0, time: 820}]); + }); + + it('still fires on its deadline when nothing was queued', () => { + const {clock, arena, log} = pressArena(); + clock.runTask(800); + clock.runTask(800); + expect(log).toContain('press:began@500'); + arena.ended([{id: 1, x: 0, y: 0, time: 820}]); + }); +}); + +describe('cancelling an idle recognizer', () => { + it('leaves it possible, so its next touch counts', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({name: 'tap'}); + // Never touched. + tap.cancel(); + expect(tap.state).toBe('possible'); + h.finger(1) + .down(10, 10, 0, {recognizers: [tap]}) + .up(50); + expect(h.log).toContain('tap:ended@50'); + // Touched before, idle now. + tap.cancel(); + expect(tap.state).toBe('possible'); + h.finger(2) + .down(10, 10, 1000, {recognizers: [tap]}) + .up(1050); + expect(h.log).toContain('tap:ended@1050'); + }); +}); + +describe('a waiting recognition that fails', () => { + it('gives its claimed touches back', () => { + const h = createHarness(); + const tap = new TapGestureRecognizer({name: 'tap'}); + const pan = new PanGestureRecognizer({name: 'pan'}); + pan.requireToFail(tap); + const finger = h + .finger(1) + .down(0, 0, 0, {recognizers: [pan, tap]}) + .move(20, 0, 16); + // The pan recognized but waits for the tap to fail, holding the touch. + expect(h.arena.isClaimed(1)).toBe(true); + pan.setEnabled(false); + expect(pan.state).toBe('failed'); + expect(h.arena.isClaimed(1)).toBe(false); + finger.up(40); + }); +}); diff --git a/packages/core/src/gestures/__tests__/browser/bareFixture.ts b/packages/core/src/gestures/__tests__/browser/bareFixture.ts new file mode 100644 index 0000000000000..dc4e86d49f031 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/bareFixture.ts @@ -0,0 +1,97 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file bareFixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page with nothing attached: the document's gesture host and the + * recognizers (pan, screen-edge pan, tap, long press) on `window.__api`, for a test to attach what it needs, and + * `window.__pageTouch`, the count of touch listeners on the window and the + * document right now + * @position Browser fixture of gestures.browser.spec.ts for the checks that + * need a page with no other gesture on it (FR19, FR24); test-only + */ + +import {getGestureHost} from '../../GestureHost'; +import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../../ScreenEdgePanGestureRecognizer'; +import {lockSelection} from '../../../utils/selectionLock'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; + +declare global { + interface Window { + __pageTouch: number; + __api: { + host: ReturnType; + LongPressGestureRecognizer: typeof LongPressGestureRecognizer; + PanGestureRecognizer: typeof PanGestureRecognizer; + ScreenEdgePanGestureRecognizer: typeof ScreenEdgePanGestureRecognizer; + TapGestureRecognizer: typeof TapGestureRecognizer; + lockSelection: typeof lockSelection; + }; + } +} + +// The touch listeners on the window and the document, as the engine sees +// them: what makes the page pay for touches. +window.__pageTouch = 0; +const live = new Set(); +const listenerIds = new WeakMap(); +let nextListenerId = 1; +function keyOf( + target: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, +): string { + let id = listenerIds.get(listener); + if (id == null) { + id = nextListenerId++; + listenerIds.set(listener, id); + } + const capture = + typeof options === 'boolean' ? options : (options?.capture ?? false); + return [target === window ? 'window' : 'document', type, capture, id].join(); +} +// Playwright's own hit-target interceptors listen on the window too; they +// are the harness's, not the page's. +const isPageTouch = (target: EventTarget, type: string) => + (target === window || target === document) && + type.startsWith('touch') && + !(new Error().stack ?? '').includes('InjectedScript'); +const addListener = EventTarget.prototype.addEventListener; +const removeListener = EventTarget.prototype.removeEventListener; +EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, +) { + if (listener != null && isPageTouch(this, type)) { + live.add(keyOf(this, type, listener, options)); + window.__pageTouch = live.size; + } + addListener.call(this, type, listener, options); +}; +EventTarget.prototype.removeEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions, +) { + if (listener != null && isPageTouch(this, type)) { + live.delete(keyOf(this, type, listener, options)); + window.__pageTouch = live.size; + } + removeListener.call(this, type, listener, options); +}; + +document.body.style.margin = '0'; +window.__api = { + host: getGestureHost(document), + LongPressGestureRecognizer, + PanGestureRecognizer, + ScreenEdgePanGestureRecognizer, + TapGestureRecognizer, + lockSelection, +}; diff --git a/packages/core/src/gestures/__tests__/browser/build-fixture.mjs b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs new file mode 100644 index 0000000000000..0d1be1180c45d --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/build-fixture.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file build-fixture.mjs + * @input fixture.ts beside this file + * @output A standalone HTML page (the fixture bundled inline) for running the + * gesture proofs in any browser, including Mobile Safari on the iOS + * simulator + * @position Test-only helper of packages/core/src/gestures + * + * node packages/core/src/gestures/__tests__/browser/build-fixture.mjs out.html + * + * The page records every recognizer transition and every click on + * `window.__gestures`; read it with the Web Inspector or by evaluating + * `JSON.stringify(window.__gestures)`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const out = process.argv[2]; +if (out == null) { + console.error('usage: build-fixture.mjs '); + process.exit(1); +} +const result = await build({ + entryPoints: [path.join(here, 'fixture.ts')], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', +}); +const html = ` + + +Gesture fixture + + +`; +fs.writeFileSync(out, html); +console.log(`wrote ${out} (${html.length} bytes)`); diff --git a/packages/core/src/gestures/__tests__/browser/fixture.ts b/packages/core/src/gestures/__tests__/browser/fixture.ts new file mode 100644 index 0000000000000..22b9bdaafe489 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/fixture.ts @@ -0,0 +1,261 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file fixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page of gesture hosts (a horizontal pan inside a scroller, a tap + * target, a long press, a pinch, a sheet body that hands its scroll to a + * pan, a passive control: a press consumer under `pan-y` with no + * recognizer) that records every recognizer transition, every consumer + * event and every click on `window.__gestures` + * @position Browser fixture of gestures.chromium.spec.ts; test-only + */ + +import {getGestureHost} from '../../GestureHost'; +import type {GestureRecognizer} from '../../GestureRecognizer'; +import {LongPressGestureRecognizer} from '../../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {lockSelection} from '../../../utils/selectionLock'; +import {PinchGestureRecognizer} from '../../PinchGestureRecognizer'; +import {PressTracker} from '../../PressTracker'; +import {holdPageScroll} from '../../pageHold'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; +import {internals} from '../../recognizerInternals'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +interface ListenerEntry { + id: string; + type: string; + passive: boolean | null; +} + +declare global { + interface Window { + __gestures: Entry[]; + __listeners: ListenerEntry[]; + /** Every touch listener added to the document or the window, by type. */ + __pageTouchListeners: string[]; + /** + * The same, as the page left them once loaded: the test driver adds its + * own window listeners when it first runs a script. + */ + __pageTouchListenersAtLoad: string[]; + } +} + +window.__gestures = []; + +// Every touch listener added to an element with an id, as the engine sees +// it: which elements make a scroll wait for the page. +window.__listeners = []; +window.__pageTouchListeners = []; +const addListener = EventTarget.prototype.addEventListener; +EventTarget.prototype.addEventListener = function ( + this: EventTarget, + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, +) { + if ((this === document || this === window) && type.startsWith('touch')) { + window.__pageTouchListeners.push(type); + } + if (this instanceof Element && this.id !== '' && type.startsWith('touch')) { + window.__listeners.push({ + id: this.id, + type, + passive: typeof options === 'object' ? (options.passive ?? null) : null, + }); + } + addListener.call(this, type, listener, options); +}; + +document.body.style.margin = '0'; +document.body.innerHTML = ` +
+
+
scroller
+
+ +
+
content
+
+
tap
+
press
+
pinch
+
+
sheet content
+
+
passive control
+
slider: a pan that turns selection off
+
checkbox row
+
switch: a drag that turns selection off
+
card (swipe up)
+
one-finger pan
+
kept press (a stepper's hold)
+
plain area
+
+
feed row (tap)
+
feed content
+
+
page
+
`; + +function element(id: string): HTMLElement { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +} + +const host = getGestureHost(document); + +function record(recognizer: GestureRecognizer, extra: () => Partial) { + recognizer.addTransitionListener((current, _from, to) => { + window.__gestures.push({ + name: current.name, + state: to, + time: internals(current).arena?.now() ?? performance.now(), + ...(to === 'possible' || to === 'failed' ? {} : extra()), + }); + }); +} + +const hpan = new PanGestureRecognizer({name: 'hpan', direction: 'horizontal'}); +record(hpan, () => ({x: hpan.translation().x, y: hpan.translation().y})); +host.attach(element('hpan'), hpan); + +const tap = new TapGestureRecognizer({name: 'tap'}); +record(tap, () => ({})); +host.attach(element('tapper'), tap); + +// A row in a scroller: a scroll the page makes under a still finger keeps +// its tap; a drag that scrolls the feed takes it. +const feedTap = new TapGestureRecognizer({name: 'feedTap'}); +record(feedTap, () => ({})); +host.attach(element('feedRow'), feedTap); + +const press = new LongPressGestureRecognizer({name: 'press'}); +record(press, () => ({ + beganAt: press.beganAt, + startedAt: press.trackedTouches[0]?.startTime, +})); +host.attach(element('presser'), press); + +const pinch = new PinchGestureRecognizer({name: 'pinch'}); +const pinchPan = new PanGestureRecognizer({ + name: 'pinchPan', + maximumNumberOfTouches: 2, +}); +pinch.recognizeSimultaneouslyWith(pinchPan); +record(pinch, () => ({scale: pinch.scale})); +record(pinchPan, () => ({x: pinchPan.translation().x})); +host.attach(element('pinchable'), pinch); +host.attach(element('pinchable'), pinchPan); + +const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + scrollEdge: true, +}); +record(sheet, () => ({y: sheet.translation().y})); +host.attach(element('sheetBody'), sheet); + +// A one-way pan over the scrollable page, as a toast's upward swipe is: it +// claims its touch as it begins, at UIKit's 10 pt (spec:AST-094 FR19). +const card = new PanGestureRecognizer({name: 'card', direction: 'up'}); +record(card, () => ({x: card.translation().x, y: card.translation().y})); +host.attach(element('card'), card); + +// A control with no recognizer of its own (no blocking listener): only a +// press consumer, under pan-y, as a passive switch is. +const passiveEvent = (state: string) => + window.__gestures.push({name: 'passive', state, time: performance.now()}); +host.attach( + element('slider'), + new PanGestureRecognizer({ + name: 'slider', + direction: 'horizontal', + selectionLock: lockSelection, + }), +); + +host.attach( + element('switchRow'), + new PanGestureRecognizer({ + name: 'switchRow', + direction: 'horizontal', + selectionLock: lockSelection, + }), +); + +host.attachConsumer(element('checkRow'), { + touchBegan: () => {}, + touchMoved: () => {}, + touchEnded: () => {}, + touchCancelled: () => {}, +}); + +host.attachConsumer(element('passive'), { + touchBegan: () => passiveEvent('began'), + touchMoved: () => passiveEvent('moved'), + touchEnded: () => passiveEvent('ended'), + touchCancelled: () => passiveEvent('cancelled'), +}); + +// A one-finger pan: a second finger before any claim fails it. +const onePan = new PanGestureRecognizer({ + name: 'onePan', + maximumNumberOfTouches: 1, +}); +record(onePan, () => ({ + x: onePan.translation().x, + y: onePan.translation().y, +})); +host.attach(element('onePan'), onePan); + +// A control that keeps its press through a drag, as a stepper's hold does. +const keptEvent = (state: string) => + window.__gestures.push({name: 'kept', state, time: performance.now()}); +host.attachConsumer( + element('kept'), + new PressTracker({ + getBounds: () => element('kept').getBoundingClientRect(), + keepsTouch: true, + onControlEvent: event => keptEvent(event), + onHighlightChange: isHighlighted => + keptEvent(isHighlighted ? 'highlight' : 'unhighlight'), + }), +); + +// A scroll lock's page hold, held on demand: every one-finger pan would +// scroll the page. +(window as unknown as {__holdPage: () => () => void}).__holdPage = () => + holdPageScroll(document, () => true); + +for (const type of ['click', 'mousedown']) { + document.addEventListener( + type, + event => { + const target = event.target as HTMLElement | null; + window.__gestures.push({ + name: `${type}:${target?.id ?? ''}`, + state: 'dom', + time: event.timeStamp, + }); + }, + true, + ); +} + +window.__pageTouchListenersAtLoad = [...window.__pageTouchListeners]; diff --git a/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts new file mode 100644 index 0000000000000..46b9c9c6ae443 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts @@ -0,0 +1,1473 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file gestures.browser.spec.ts + * @input The gesture fixture bundled with esbuild, a phone-sized touch + * screen, and real engine touch input: Chromium through CDP with explicit + * timestamps, WebKit through its own input command (real-time pacing) + * @output Browser proof of the negotiation the unit tests cannot see: + * touch-action per element, touchmove cancelled exactly while a recognizer + * owns the finger, native scrolling untouched otherwise, click suppression + * with no ghost click, a drag that began as a scroll staying the browser's, pinch-zoom + * left to the browser, a second finger that never breaks the first, + * timing taken from event timestamps under a blocked main thread, nothing + * attached where the primary pointer is fine or a recognizer is disabled, + * and a held finger's selection lock only where the hold takes the touch + * @position Browser tests of packages/core/src/gestures (see + * playwright.config.ts beside this file). WebKit's Linux build cannot + * scroll, fling or pinch-zoom from injected touches, and synthesises a + * click only from its tap gesture; the checks that need those run in + * Chromium here and in Mobile Safari on the iOS simulator. + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {expect, test, type Page} from '@playwright/test'; + +interface Entry { + name: string; + state: string; + time: number; + x?: number; + y?: number; + scale?: number; + beganAt?: number | null; + startedAt?: number; +} + +let bundle = ''; +let bareBundle = ''; + +test.beforeAll(async () => { + const bundleOf = async (file: string) => { + const result = await build({ + entryPoints: [ + path.join(path.dirname(fileURLToPath(import.meta.url)), file), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + // A development build, as an app's bundler would make one. + define: {'process.env.NODE_ENV': '"development"'}, + }); + return result.outputFiles[0].text; + }; + bundle = await bundleOf('fixture.ts'); + bareBundle = await bundleOf('bareFixture.ts'); +}); + +type Point = {x: number; y: number}; +type Finger = Point & {id: number}; + +/** The fingers down after a step, and when (ms after the gesture's start). */ +interface Step { + t: number; + down: Finger[]; +} + +interface Driver { + readonly engine: 'chromium' | 'webkit'; + /** Plays the steps as real touch input; returns the page time of t = 0. */ + play(steps: Step[]): Promise; + /** A tap through the engine's tap path (WebKit synthesises click only from it). */ + tap(at: Point): Promise; +} + +const sleep = async (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); + +/** Each step's change against the previous one: who landed, lifted, moved. */ +function diff(previous: Finger[], next: Finger[]) { + const before = new Set(previous.map(finger => finger.id)); + const after = new Set(next.map(finger => finger.id)); + return { + landed: next.filter(finger => !before.has(finger.id)), + lifted: previous.filter(finger => !after.has(finger.id)), + }; +} + +async function chromiumDriver(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + const origin = await page.evaluate(() => performance.timeOrigin); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + const send = async (type: string, points: Finger[], at: number) => + cdp.send('Input.dispatchTouchEvent', { + type: type as 'touchStart', + touchPoints: points.map(point => ({ + x: point.x, + y: point.y, + id: point.id, + })), + timestamp: (origin + at) / 1000, + }); + return { + engine: 'chromium', + async play(steps) { + // CDP stamps every event with the scripted time, so the page sees the + // script's timing exactly, whatever the dispatch latency. + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + for (const step of steps) { + const {landed} = diff(down, step.down); + // CDP diffs active points against the previous event: a new point + // is pressed, a missing one released; touchEnd carries none. + const type = + landed.length > 0 + ? 'touchStart' + : step.down.length === 0 + ? 'touchEnd' + : 'touchMove'; + await send(type, step.down, base + step.t); + down = step.down; + } + return base; + }, + async tap(at) { + const base = Math.ceil(await page.evaluate(() => performance.now())) + 50; + await send('touchStart', [{...at, id: 1}], base); + await send('touchEnd', [], base + 60); + }, + }; +} + +interface WebKitSession { + send(method: string, params?: Record): Promise; +} + +/** Playwright's server-side WebKit page session (a locally launched browser). */ +function webkitSession(page: Page): WebKitSession { + const connection = ( + page as unknown as {_connection?: {toImpl?: (o: unknown) => unknown}} + )._connection; + const impl = connection?.toImpl?.(page) as + {delegate?: {_pageProxySession?: WebKitSession}} | undefined; + const session = impl?.delegate?._pageProxySession; + if (session == null) { + throw new Error('WebKit touch input needs a locally launched browser'); + } + return session; +} + +function webkitDriver(page: Page): Driver { + const session = webkitSession(page); + // WebKit's command applies the event's phase to every point it lists: a + // start lists only the fingers that land, an end only those that lift, a + // move every finger down. Integer CSS px; the page sees real time. + const points = (fingers: Finger[]) => + fingers.map(f => ({x: Math.round(f.x), y: Math.round(f.y), id: f.id})); + // The fingers down, across calls: a gesture may span several. + let down: Finger[] = []; + return { + engine: 'webkit', + async play(steps) { + const start = Date.now(); + const base = await page.evaluate(() => performance.now()); + for (const step of steps) { + const wait = start + step.t - Date.now(); + if (wait > 0) { + await sleep(wait); + } + const {landed, lifted} = diff(down, step.down); + if (landed.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: points(landed), + }); + } else if (lifted.length > 0) { + await session.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: points(lifted), + }); + } else { + await session.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: points(step.down), + }); + } + down = step.down; + } + return base; + }, + async tap(at) { + await page.touchscreen.tap(Math.round(at.x), Math.round(at.y)); + }, + }; +} + +/** One finger from `from` to `to` in `count` frames of 16 ms, then a lift. */ +function drag(from: Point, to: Point, count = 12, lift = true): Step[] { + const steps: Step[] = [{t: 0, down: [{...from, id: 1}]}]; + for (let i = 1; i <= count; i++) { + const k = i / count; + steps.push({ + t: i * 16, + down: [ + { + x: from.x + (to.x - from.x) * k, + y: from.y + (to.y - from.y) * k, + id: 1, + }, + ], + }); + } + if (lift) { + steps.push({t: (count + 1) * 16, down: []}); + } + return steps; +} + +async function open(page: Page, engine: string): Promise { + // A real navigation first: touch emulation exposes the Touch Events API + // to documents it creates. + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + return engine === 'webkit' ? webkitDriver(page) : chromiumDriver(page); +} + +/** A page with nothing attached: the test attaches through `window.__api`. */ +async function openBare(page: Page, engine: string): Promise { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + return engine === 'webkit' ? webkitDriver(page) : chromiumDriver(page); +} + +/** + * The touch-action the host gives an element (its mark, or an inline value + * it wrote) and the page's touch listeners, read after the next frame: the + * host reads an author's value there (FR18), so an absent value is absent + * for good, not merely not yet written. + */ +async function footprint(page: Page, id: string) { + return page.evaluate(async target => { + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + const element = document.getElementById(target); + return { + touchAction: + element?.getAttribute('data-astryx-touch-action') ?? + element?.style.getPropertyValue('touch-action') ?? + null, + pageTouchListeners: window.__pageTouch, + }; + }, id); +} + +/** + * Whether a selection starting on an element now is refused: where iOS + * WebKit does not run, the selection lock cancels `selectstart` and writes + * no style (`spec:AST-094` FR41). + */ +async function refusesSelection(page: Page, id: string): Promise { + return page.evaluate(target => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + (document.getElementById(target) ?? document.body).dispatchEvent(event); + return event.defaultPrevented; + }, id); +} + +async function center(page: Page, id: string): Promise { + const box = await page.locator(`#${id}`).boundingBox(); + if (box == null) { + throw new Error(`#${id} has no box`); + } + return { + x: Math.round(box.x + box.width / 2), + y: Math.round(box.y + box.height / 2), + }; +} + +async function log(page: Page): Promise { + await page.waitForTimeout(50); + return page.evaluate(() => window.__gestures); +} + +async function clearLog(page: Page): Promise { + await page.evaluate(() => { + window.__gestures.length = 0; + }); +} + +async function scrollTop(page: Page, id: string): Promise { + return page.evaluate( + target => document.getElementById(target)?.scrollTop ?? -1, + id, + ); +} + +/** Record whether each touchmove reached the page cancelled. */ +async function logTouchMoves(page: Page): Promise { + await page.evaluate(() => { + const moves: boolean[] = []; + (window as unknown as {__moves: boolean[]}).__moves = moves; + window.addEventListener( + 'touchmove', + event => moves.push(event.defaultPrevented), + {passive: true}, + ); + }); +} + +async function readTouchMoves(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__moves: boolean[]}).__moves, + ); +} + +const states = (entries: Entry[], name: string) => + entries.filter(entry => entry.name === name).map(entry => entry.state); + +test('derives touch-action per element from its recognizers', async ({ + page, + browserName, +}) => { + await open(page, browserName); + // The host reads each author's value and writes its own in the frame + // after the attach (FR18). + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }), + ); + const actions = await page.evaluate(() => + Object.fromEntries( + ['hpan', 'tapper', 'presser', 'pinchable', 'sheetBody'].map(id => [ + id, + getComputedStyle(document.getElementById(id) as HTMLElement) + .touchAction, + ]), + ), + ); + expect(actions).toEqual({ + hpan: 'pan-y pinch-zoom', + tapper: 'manipulation', + presser: 'manipulation', + pinchable: 'none', + sheetBody: 'auto', + }); +}); + +test('only elements whose recognizers claim a moving touch carry a blocking touchmove listener', async ({ + page, + browserName, +}) => { + await open(page, browserName); + const blocking = await page.evaluate(() => + window.__listeners + .filter(entry => entry.type === 'touchmove' && entry.passive === false) + .map(entry => entry.id), + ); + // Pans (the cards', the switch's drag and a one-finger pan too), a long + // press, a pinch, the sheet's pan and a press that keeps its touch can take + // a moving touch; a tap-only element and a plain press consumer never wait. + expect(new Set(blocking)).toEqual( + new Set([ + 'hpan', + 'presser', + 'pinchable', + 'sheetBody', + 'slider', + 'switchRow', + 'card', + 'onePan', + 'kept', + ]), + ); +}); + +test('a horizontal drag on a pan-y element is the recognizer’s: cancelled moves, no scroll, no click', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hbutton'); + // A 4:1 drag, the shape WebKit would otherwise pan natively. + await driver.play(drag(start, {x: start.x + 120, y: start.y + 30})); + const entries = await log(page); + const hpan = states(entries, 'hpan'); + expect(hpan[0]).toBe('began'); + expect(hpan.slice(-2)).toEqual(['ended', 'possible']); + const last = entries + .filter(entry => entry.name === 'hpan' && entry.state === 'changed') + .at(-1); + // As in UIKit, the 10 px hysteresis is not part of the translation. + expect(last?.x).toBeCloseTo(120 - 10, 0); + // Every touchmove after the claim was cancelled. + const moves = await readTouchMoves(page); + expect(moves.length).toBeGreaterThan(0); + expect(moves.slice(-3).every(Boolean)).toBe(true); + expect(await scrollTop(page, 'scroller')).toBe(0); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); + expect(entries.some(entry => entry.name.startsWith('mousedown:'))).toBe( + false, + ); +}); + +test('a vertical drag on the same element fails the pan and is never cancelled', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'hpan'); + await driver.play(drag(start, {x: start.x + 4, y: start.y - 120})); + const entries = await log(page); + expect(states(entries, 'hpan')).toEqual(['failed', 'possible']); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + if (driver.engine === 'chromium') { + // The browser scrolled it natively. + expect(await scrollTop(page, 'scroller')).toBeGreaterThan(60); + } + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); + +test('a horizontal drag on a passive pan-y control keeps its press: uncancelable moves the browser cannot pan are not a claim', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('passive')?.scrollIntoView({block: 'center'}); + }); + const start = await center(page, 'passive'); + await driver.play( + drag({x: start.x - 120, y: start.y}, {x: start.x + 120, y: start.y + 6}), + ); + const consumed = (await log(page)) + .filter(entry => entry.name === 'passive') + .map(entry => entry.state); + expect(consumed[0]).toBe('began'); + expect(consumed).not.toContain('cancelled'); + expect(consumed.at(-1)).toBe('ended'); +}); + +test('a one-way overlay pan keeps its direction from the page, and leaves the other way to it', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + document.getElementById('card')?.scrollIntoView({block: 'center'}); + }); + const before = await page.evaluate(() => window.scrollY); + const start = await center(page, 'card'); + // Up, slowly: the card's pan begins at 10 px and the page never moves. + await driver.play(drag(start, {x: start.x, y: start.y - 40}, 40)); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + const up = (await log(page)).filter(entry => entry.name === 'card'); + expect(up[0]?.state).toBe('began'); + expect(up.filter(entry => entry.state === 'changed').at(-1)?.y).toBe(-30); + // Down: the page's. + await clearLog(page); + await driver.play(drag(start, {x: start.x, y: start.y + 60}, 30)); + expect(await page.evaluate(() => window.scrollY)).toBeLessThan(before - 20); + expect(states(await log(page), 'card')).toEqual(['failed', 'possible']); +}); + +test('a held finger turns text selection off only where the hold does something (a pan that asks), until it lifts; a press-tracked control, a mouse and other pans keep native selection (spec:AST-094 FR41)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const held: Record = {}; + for (const id of ['passive', 'slider', 'hpan']) { + await page.locator(`#${id}`).scrollIntoViewIfNeeded(); + const at = await center(page, id); + const before = await refusesSelection(page, id); + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + const during = await refusesSelection(page, id); + await driver.play([{t: 0, down: []}]); + held[id] = [before, during, await refusesSelection(page, id)]; + } + // A control whose hold does nothing keeps the browser's long-press + // selection. + expect(held.passive).toEqual([false, false, false]); + expect(held.slider).toEqual([false, true, false]); + expect(held.hpan).toEqual([false, false, false]); + + const at = await center(page, 'passive'); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + const withMouse = await refusesSelection(page, 'passive'); + await page.mouse.up(); + expect(withMouse).toBe(false); +}); + +test('a held finger on a checkbox inside an element whose hold does something turns its selection off too, as WebKit makes every input selectable; inside a press-tracked row it stays native (spec:AST-094 FR41)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const heldSelection = async (id: string) => { + await page.locator(`#${id}`).scrollIntoViewIfNeeded(); + const at = await center(page, id); + const before = await refusesSelection(page, id); + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + const during = await refusesSelection(page, id); + await driver.play([{t: 0, down: []}]); + return [before, during, await refusesSelection(page, id)]; + }; + expect(await heldSelection('switchInput')).toEqual([false, true, false]); + expect(await heldSelection('check')).toEqual([false, false, false]); +}); + +test('a scroll the page makes under a still finger keeps the touch and its tap', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.locator('#feed').scrollIntoViewIfNeeded(); + const at = await center(page, 'feedRow'); + await clearLog(page); + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + // The page scrolls the feed under the resting finger (scrollIntoView, a + // pinned transcript): the finger never travelled, so the touch stays. + await page.evaluate(() => { + const feed = document.getElementById('feed'); + if (feed != null) { + feed.scrollTop += 6; + } + }); + await page.evaluate(async () => { + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + }); + await driver.play([{t: 0, down: []}]); + expect(states(await log(page), 'feedTap')).toEqual(['ended', 'possible']); +}); + +test('a drag that scrolls the feed natively is the browser’s: no tap', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.locator('#feed').scrollIntoViewIfNeeded(); + await page.evaluate(() => { + const feed = document.getElementById('feed'); + if (feed != null) { + feed.scrollTop = 0; + } + }); + const start = await center(page, 'feedRow'); + await clearLog(page); + await driver.play(drag(start, {x: start.x, y: start.y - 60}, 30)); + const scrolled = await page.evaluate( + () => document.getElementById('feed')?.scrollTop ?? 0, + ); + expect(scrolled).toBeGreaterThan(20); + expect(states(await log(page), 'feedTap')).not.toContain('ended'); +}); + +test('a second finger before any claim fails a one-finger pan and leaves the pinch to the page', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.locator('#onePan').scrollIntoViewIfNeeded(); + const at = await center(page, 'onePan'); + await clearLog(page); + await logTouchMoves(page); + const pair = (spread: number) => [ + {id: 1, x: at.x - spread, y: at.y}, + {id: 2, x: at.x + spread, y: at.y}, + ]; + await driver.play([ + {t: 0, down: [{id: 1, x: at.x - 10, y: at.y}]}, + {t: 30, down: pair(10)}, + {t: 60, down: pair(30)}, + {t: 90, down: pair(50)}, + {t: 120, down: pair(70)}, + {t: 150, down: []}, + ]); + const pan = states(await log(page), 'onePan'); + expect(pan).toContain('failed'); + expect(pan).not.toContain('began'); + // No move of the pinch is cancelled: the page may zoom. + const moves = await readTouchMoves(page); + expect(moves.length).toBeGreaterThan(0); + expect(moves.some(Boolean)).toBe(false); +}); + +test('a page hold keeps the page still under a one-finger pan, and leaves a pinch alone', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.locator('#plainArea').scrollIntoViewIfNeeded(); + await page.evaluate(() => { + const own = window as unknown as { + __holdPage: () => () => void; + __releaseHold: () => void; + }; + own.__releaseHold = own.__holdPage(); + }); + await logTouchMoves(page); + const start = await center(page, 'plainArea'); + const before = await page.evaluate(() => window.scrollY); + await driver.play(drag(start, {x: start.x, y: start.y - 80}, 30)); + expect((await readTouchMoves(page)).some(Boolean)).toBe(true); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + // A second finger is a pinch: never cancelled. + await logTouchMoves(page); + await driver.play([ + { + t: 0, + down: [ + {id: 1, x: start.x - 10, y: start.y}, + {id: 2, x: start.x + 10, y: start.y}, + ], + }, + { + t: 30, + down: [ + {id: 1, x: start.x - 40, y: start.y}, + {id: 2, x: start.x + 40, y: start.y}, + ], + }, + {t: 60, down: []}, + ]); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + await page.evaluate(() => + (window as unknown as {__releaseHold: () => void}).__releaseHold(), + ); +}); + +test('a control that keeps its press through a drag holds the page still and stays pressed', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await page.locator('#kept').scrollIntoViewIfNeeded(); + await clearLog(page); + const start = await center(page, 'kept'); + const before = await page.evaluate(() => window.scrollY); + // The finger rests until the press is delivered: a control in a scroller + // waits before it highlights, and a drag that starts sooner is a scroll. + await driver.play([{t: 0, down: [{id: 1, ...start}]}]); + await page.waitForFunction(() => + window.__gestures.some( + entry => entry.name === 'kept' && entry.state === 'highlight', + ), + ); + await logTouchMoves(page); + await driver.play(drag(start, {x: start.x, y: start.y - 40}, 20, false)); + let kept = states(await log(page), 'kept'); + expect(kept).toContain('touchDown'); + expect(kept).not.toContain('touchCancel'); + expect(kept).not.toContain('unhighlight'); + // Its press was delivered, so every move is cancelled: the page stays. + const moves = await readTouchMoves(page); + expect(moves.length).toBeGreaterThan(0); + expect(moves.every(Boolean)).toBe(true); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + await driver.play([{t: 0, down: []}]); + kept = states(await log(page), 'kept'); + expect(kept).toContain('touchUpInside'); + expect(kept).not.toContain('touchCancel'); +}); + +test('where the primary pointer is fine, the host writes no touch-action and registers no touch listener (spec:AST-094 FR19, FR24)', async ({ + browser, +}) => { + // A desktop: a mouse is the primary pointer. + const context = await browser.newContext({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + isMobile: false, + }); + try { + const page = await context.newPage(); + await page.goto('about:blank'); + await page.setContent(''); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, LongPressGestureRecognizer, PanGestureRecognizer} = + window.__api; + document.body.innerHTML = + '
drag
hold
'; + host.attach( + document.getElementById('drag') as HTMLElement, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + host.attach( + document.getElementById('hold') as HTMLElement, + new LongPressGestureRecognizer(), + ); + }); + expect(await footprint(page, 'drag')).toEqual({ + touchAction: '', + pageTouchListeners: 0, + }); + expect((await footprint(page, 'hold')).touchAction).toBe(''); + } finally { + await context.close(); + } +}); + +test("the host's touch-action applies from the attach itself, an author rule outranks it from the first frame, and a shadow root gets it too (spec:AST-094 FR18, DEC-8)", async ({ + page, + browserName, +}) => { + await openBare(page, browserName); + const actions = await page.evaluate(() => { + const {host, PanGestureRecognizer, LongPressGestureRecognizer} = + window.__api; + const author = document.createElement('style'); + author.textContent = '#authored { touch-action: none; }'; + document.head.append(author); + document.body.innerHTML = + '
'; + const plain = document.getElementById('plain') as HTMLElement; + const authored = document.getElementById('authored') as HTMLElement; + const inner = document.createElement('div'); + inner.style.height = '60px'; + (document.getElementById('shadowed') as HTMLElement) + .attachShadow({mode: 'open'}) + .append(inner); + host.attach(plain, new PanGestureRecognizer({direction: 'horizontal'})); + host.attach(authored, new LongPressGestureRecognizer()); + host.attach(inner, new LongPressGestureRecognizer()); + // Read in the attach's own task, before any frame. + return { + plain: getComputedStyle(plain).touchAction, + authored: getComputedStyle(authored).touchAction, + inner: getComputedStyle(inner).touchAction, + }; + }); + expect(actions).toEqual({ + plain: 'pan-y pinch-zoom', + authored: 'none', + inner: 'manipulation', + }); +}); + +test("an author rule in any cascade layer outranks the host's touch-action (spec:AST-094 FR18, DEC-8)", async ({ + page, + browserName, +}) => { + await openBare(page, browserName); + const actions = await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + // The layer order a package stylesheet declares (spec:AST-080 FR3), and + // author rules in Astryx's base layer and in the product's layer. + const author = document.createElement('style'); + author.textContent = `@layer reset, astryx-base, astryx-theme, product; + @layer astryx-base { #base { touch-action: none; } } + @layer product { #product { touch-action: pan-x; } }`; + document.head.append(author); + document.body.innerHTML = + '
'; + const base = document.getElementById('base') as HTMLElement; + const product = document.getElementById('product') as HTMLElement; + host.attach(base, new PanGestureRecognizer({direction: 'horizontal'})); + host.attach(product, new PanGestureRecognizer({direction: 'horizontal'})); + return { + base: getComputedStyle(base).touchAction, + product: getComputedStyle(product).touchAction, + }; + }); + expect(actions).toEqual({base: 'none', product: 'pan-x'}); +}); + +test('a subtree that mounts with a recognizer is styled once: the host adds no second recalc of it (spec:AST-094 FR18)', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'counts style recalcs in a Chromium trace', + ); + await openBare(page, browserName); + const cdp = await page.context().newCDPSession(page); + const events: {name: string; args?: {elementCount?: number}}[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as typeof events)); + }); + const complete = new Promise(resolve => { + cdp.once('Tracing.tracingComplete', resolve); + }); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + ], + excludedCategories: ['*'], + }, + }); + await page.evaluate(async () => { + const {host, PanGestureRecognizer} = window.__api; + const wheel = document.createElement('div'); + for (let i = 0; i < 300; i += 1) { + const row = document.createElement('div'); + row.textContent = `Row ${i}`; + wheel.append(row); + } + document.body.append(wheel); + host.attach(wheel, new PanGestureRecognizer({direction: 'vertical'})); + for (let i = 0; i < 4; i += 1) { + await new Promise(resolve => { + requestAnimationFrame(resolve); + }); + } + }); + await cdp.send('Tracing.end'); + await complete; + // The subtree's first styling, and no recalc of it after: a touch-action + // written later would restyle every row (each one's effective value). + const subtreeRecalcs = events.filter( + event => + event.name === 'UpdateLayoutTree' && + (event.args?.elementCount ?? 0) >= 300, + ); + expect(subtreeRecalcs).toHaveLength(1); +}); + +test('a recognizer registers no touch listener and writes no touch-action while it is disabled, and does once it is enabled (spec:AST-094 FR24)', async ({ + page, + browserName, +}) => { + await openBare(page, browserName); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = '
solo
'; + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + isEnabled: false, + }); + (window as unknown as {__solo: typeof pan}).__solo = pan; + host.attach(document.getElementById('solo') as HTMLElement, pan); + }); + expect(await footprint(page, 'solo')).toEqual({ + touchAction: '', + pageTouchListeners: 0, + }); + const setEnabled = async (isEnabled: boolean) => + page.evaluate(next => { + const solo = ( + window as unknown as { + __solo: {setEnabled(value: boolean): void}; + } + ).__solo; + solo.setEnabled(next); + window.__api.host.update(document.getElementById('solo') as HTMLElement); + }, isEnabled); + await setEnabled(true); + const enabled = await footprint(page, 'solo'); + expect(enabled.touchAction).toBe('pan-y pinch-zoom'); + expect(enabled.pageTouchListeners).toBeGreaterThan(0); + await setEnabled(false); + expect(await footprint(page, 'solo')).toEqual({ + touchAction: '', + pageTouchListeners: 0, + }); +}); + +test('a zero-delay hold that brings its own lock turns selection off only for a touch it takes: a touch it declines keeps native selection (spec:AST-094 FR41)', async ({ + page, + browserName, +}) => { + const driver = await openBare(page, browserName); + await page.evaluate(() => { + const {host, LongPressGestureRecognizer, lockSelection} = window.__api; + // A tab bar: its lens is a hold at 0 ms, and the search tab beside it + // is a button the lens's recognizer declines. + document.body.innerHTML = + '
lens
'; + const lens = document.getElementById('lens') as HTMLElement; + host.attach( + document.getElementById('bar') as HTMLElement, + new LongPressGestureRecognizer({ + minimumPressDuration: 0, + selectionLock: lockSelection, + shouldReceiveTouch: (_recognizer, touch) => + touch.target instanceof Node && lens.contains(touch.target), + }), + ); + }); + expect(await refusesSelection(page, 'lens')).toBe(false); + const held: Record = {}; + for (const id of ['search', 'lens']) { + const at = await center(page, id); + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + const during = await refusesSelection(page, 'lens'); + await driver.play([{t: 0, down: []}]); + held[id] = [during, await refusesSelection(page, 'lens')]; + } + expect(held.search).toEqual([false, false]); + expect(held.lens).toEqual([true, false]); +}); + +test('a tap recognizer replaces the click: no ghost click, no compatibility mouse events', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.tap(await center(page, 'tapper')); + let entries = await log(page); + expect(states(entries, 'tap')).toEqual(['ended', 'possible']); + expect(entries.filter(entry => entry.state === 'dom')).toEqual([]); + + // A tap the pan does not take keeps its click (exactly one). + await clearLog(page); + await driver.tap(await center(page, 'hbutton')); + entries = await log(page); + expect(entries.filter(entry => entry.name === 'click:hbutton')).toHaveLength( + 1, + ); +}); + +test('a finger the window blur cancelled lifts on nothing: its lift gets no click, and a later tap there keeps its own (spec:AST-094 FR28)', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const at = await center(page, 'hbutton'); + await clearLog(page); + await driver.play([{t: 0, down: [{id: 1, ...at}]}]); + // A headless page keeps its focus, so the blur is dispatched to the window. + await page.evaluate(() => window.dispatchEvent(new FocusEvent('blur'))); + await driver.play([{t: 0, down: []}]); + await page.waitForTimeout(100); + const clicks = async () => + (await log(page)).filter(entry => entry.name === 'click:hbutton'); + expect(await clicks()).toEqual([]); + await driver.tap(at); + expect(await clicks()).toHaveLength(1); +}); + +test('scroll edge: at the top the pull is the sheet’s at once', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'sheetBody'); + await driver.play(drag(start, {x: start.x, y: start.y + 96})); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + expect(sheet[0]?.state).toBe('began'); + expect( + sheet.filter(entry => entry.state === 'changed').at(-1)?.y, + ).toBeCloseTo(96 - 10, 0); + expect(await scrollTop(page, 'sheetBody')).toBe(0); +}); + +test("scroll edge: at the top the sheet cancels the pull's moves from the first one, before it begins (spec:AST-094 FR19, DEC-18)", async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await logTouchMoves(page); + const start = await center(page, 'sheetBody'); + await driver.play(drag(start, {x: start.x, y: start.y + 24})); + const moves = await readTouchMoves(page); + // WebKit sends the first move at 2 px, well before the sheet's 10 px begin; + // Chromium sends none inside its own 15 px slop. Every move the page sees + // is cancelled, the first one included. + expect(moves.length).toBeGreaterThan(0); + expect(moves).toEqual(moves.map(() => true)); + const sheet = (await log(page)).filter(entry => entry.name === 'sheet'); + expect(sheet[0]?.state).toBe('began'); + expect(await scrollTop(page, 'sheetBody')).toBe(0); +}); + +test('a drag that began as a scroll stays the browser’s (AST-094 FR14): the content scrolls to its top and the sheet never begins', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await page.evaluate(() => { + (document.getElementById('sheetBody') as HTMLElement).scrollTop = 60; + }); + const start = await center(page, 'sheetBody'); + start.y -= 60; + await driver.play(drag(start, {x: start.x, y: start.y + 200}, 25)); + const entries = await log(page); + const sheet = entries.filter(entry => entry.name === 'sheet'); + // The content takes the whole drag: it scrolls to its top, and the travel + // past that point never becomes the sheet's. + expect(await scrollTop(page, 'sheetBody')).toBe(0); + expect(sheet.some(entry => entry.state === 'began')).toBe(false); +}); + +/** Two fingers that land one after the other and spread apart. */ +function spread(at: Point, from: number, by: number, drift = 0): Step[] { + const steps: Step[] = [ + {t: 0, down: [{x: at.x - from, y: at.y, id: 1}]}, + { + t: 30, + down: [ + {x: at.x - from, y: at.y, id: 1}, + {x: at.x + from, y: at.y, id: 2}, + ], + }, + ]; + for (let i = 1; i <= 12; i++) { + const half = from + (by * i) / 12; + steps.push({ + t: 30 + i * 16, + down: [ + {x: at.x - half + drift * i, y: at.y, id: 1}, + {x: at.x + half + drift * i, y: at.y, id: 2}, + ], + }); + } + steps.push({t: 30 + 13 * 16, down: []}); + return steps; +} + +test('pinch-zoom stays the browser’s on an element without a pinch recognizer', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'tapper'), 20, 96)); + await page.waitForTimeout(400); + expect( + await page.evaluate(() => window.visualViewport?.scale ?? 1), + ).toBeGreaterThan(1.2); + expect(states(await log(page), 'tap')).not.toContain('ended'); +}); + +test('a pinch recognizer takes the two-finger gesture on its element only, alongside its pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + await driver.play(spread(await center(page, 'pinchable'), 20, 40, 1)); + const entries = await log(page); + expect(states(entries, 'pinch')[0]).toBe('began'); + const scale = entries + .filter(entry => entry.name === 'pinch' && entry.state === 'changed') + .at(-1)?.scale; + // The span grew from 40 to 120; as in UIKit, the scale is measured from + // the starting span moved by the 8 px hysteresis. + expect(scale).toBeCloseTo(120 / (40 + 8), 1); + expect(states(entries, 'pinchPan')).toContain('began'); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a second finger never breaks a claimed one-finger pan', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const start = await center(page, 'hbutton'); + const other = await center(page, 'tapper'); + const steps: Step[] = [{t: 0, down: [{...start, id: 1}]}]; + for (let i = 1; i <= 4; i++) { + steps.push({t: i * 16, down: [{x: start.x + i * 10, y: start.y, id: 1}]}); + } + const first = {x: start.x + 40, y: start.y, id: 1}; + steps.push({t: 80, down: [first, {...other, id: 2}]}); + for (let i = 1; i <= 6; i++) { + steps.push({ + t: 80 + i * 16, + down: [ + {x: first.x + i * 5, y: first.y, id: 1}, + {x: other.x + i * 15, y: other.y + i * 15, id: 2}, + ], + }); + } + steps.push({ + t: 200, + down: [{x: other.x + 90, y: other.y + 90, id: 2}], + }); + steps.push({t: 216, down: []}); + await driver.play(steps); + const entries = await log(page); + const hpan = entries.filter(entry => entry.name === 'hpan'); + expect(hpan.map(entry => entry.state)).not.toContain('cancelled'); + expect(hpan.map(entry => entry.state)).toContain('ended'); + expect(hpan.filter(entry => entry.state === 'changed').at(-1)?.x).toBeCloseTo( + 70 - 10, + 0, + ); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(1); +}); + +test('a long press begins at touch-down + 500 ms on the event clock, even when the main thread was blocked', async ({ + page, + browserName, +}) => { + const driver = await open(page, browserName); + const at = await center(page, 'presser'); + const base = await driver.play([{t: 0, down: [{...at, id: 1}]}]); + // Block the main thread well past the press duration. + await page.evaluate(() => { + const end = performance.now() + 800; + while (performance.now() < end) { + // busy + } + }); + await page.waitForTimeout(100); + await driver.play([{t: 0, down: []}]); + const entries = await log(page); + const began = entries.find( + entry => entry.name === 'press' && entry.state === 'began', + ); + if (driver.engine === 'chromium') { + // The touch carries the timestamp CDP gave it (to the browser's 0.1 ms + // timestamp precision). + expect(Math.abs((began?.startedAt ?? 0) - base)).toBeLessThan(0.5); + } + // The press began exactly 500 ms after the touch, though the timer that + // noticed it ran 300 ms late. + expect((began?.beganAt ?? 0) - (began?.startedAt ?? 0)).toBeCloseTo(500, 6); + expect(states(entries, 'press')).toEqual(['began', 'ended', 'possible']); + expect(entries.some(entry => entry.name.startsWith('click:'))).toBe(false); +}); + +test.describe('where the primary pointer is fine', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the host registers no touch listener and writes no style, though the page attaches recognizers (spec:AST-094 FR19)', async ({ + page, + }) => { + const warnings: string[] = []; + page.on('console', message => { + if (message.type() === 'warning') { + warnings.push(message.text()); + } + }); + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + const seen = await page.evaluate(() => ({ + isCoarse: matchMedia('(pointer: coarse)').matches, + page: window.__pageTouchListenersAtLoad, + elements: window.__listeners.map(entry => entry.id), + // What the host would write: none of it, in any engine's spelling. + styles: ['hpan', 'presser', 'slider', 'card'].map(id => { + const style = document.getElementById(id)?.style; + return ['touch-action', '-webkit-user-select', '-webkit-touch-callout'] + .map(property => style?.getPropertyValue(property) ?? 'missing') + .join('|'); + }), + })); + expect(seen.isCoarse).toBe(false); + expect(seen.page).toEqual([]); + expect(seen.elements).toEqual([]); + expect(seen.styles).toEqual(['||', '||', '||', '||']); + // The page's attaches are named once, in development. + expect( + warnings.filter(text => text.includes('primary pointer is fine')), + ).toHaveLength(1); + }); +}); + +/** + * A page with a scroller of rows and a tap recognizer on one row; every + * transition of the tap goes to `window.__taps`. + */ +async function openTapInScroller(page: Page, engine: string): Promise { + const driver = await openBare(page, engine); + await page.evaluate(() => { + const {host, TapGestureRecognizer} = window.__api; + document.body.innerHTML = ` +
+
row
+
more rows
+
`; + const taps: string[] = []; + (window as unknown as {__taps: string[]}).__taps = taps; + const tap = new TapGestureRecognizer({name: 'tap'}); + tap.addTransitionListener((_current, _from, to) => taps.push(to)); + host.attach(document.getElementById('row') as HTMLElement, tap); + }); + return driver; +} + +test('a still finger with sub-pixel jitter over a scroll view keeps its tap and scrolls nothing (spec:AST-094 FR18–FR22)', async ({ + page, + browserName, +}) => { + const driver = await openTapInScroller(page, browserName); + const at = await center(page, 'row'); + // A resting fingertip: a few tenths of a pixel back and forth. + const jitter = [ + [0.3, 0.2], + [-0.2, 0.4], + [0.4, -0.3], + [-0.3, -0.2], + [0.2, 0.3], + [0, 0], + ]; + const steps: Step[] = [{t: 0, down: [{...at, id: 1}]}]; + jitter.forEach(([dx, dy], i) => + steps.push({ + t: (i + 1) * 16, + down: [{x: at.x + dx, y: at.y + dy, id: 1}], + }), + ); + steps.push({t: (jitter.length + 1) * 16, down: []}); + await driver.play(steps); + await page.waitForTimeout(100); + const taps = await page.evaluate( + () => (window as unknown as {__taps: string[]}).__taps, + ); + expect(taps).toContain('ended'); + expect(await scrollTop(page, 'list')).toBe(0); +}); + +for (const stop of [ + { + label: "an app's window-capture stopPropagation", + install: 'window' as const, + }, + { + label: + "a document-capture stopImmediatePropagation registered before core's", + install: 'document' as const, + }, +]) { + test(`a tap is still recognized under ${stop.label}, since the recognizers hear touches on the window's capture phase`, async ({ + page, + browserName, + }) => { + const driver = await openBare(page, browserName); + await page.evaluate(install => { + // The app's stop comes first, before any recognizer attaches. + for (const type of ['touchstart', 'touchmove', 'touchend']) { + if (install === 'window') { + window.addEventListener(type, event => event.stopPropagation(), { + capture: true, + }); + } else { + document.addEventListener( + type, + event => event.stopImmediatePropagation(), + {capture: true}, + ); + } + } + const {host, TapGestureRecognizer} = window.__api; + document.body.innerHTML = + '
row
'; + const taps: string[] = []; + (window as unknown as {__taps: string[]}).__taps = taps; + const tap = new TapGestureRecognizer({name: 'tap'}); + tap.addTransitionListener((_current, _from, to) => taps.push(to)); + host.attach(document.getElementById('row') as HTMLElement, tap); + }, stop.install); + const at = await center(page, 'row'); + await driver.play([ + {t: 0, down: [{...at, id: 1}]}, + {t: 60, down: []}, + ]); + await page.waitForTimeout(100); + const taps = await page.evaluate( + () => (window as unknown as {__taps: string[]}).__taps, + ); + expect(taps).toContain('ended'); + }); +} + +for (const travel of [ + {dx: 0, dy: 9, label: '9 px straight', keeps: true}, + {dx: 0, dy: 10, label: '10 px straight', keeps: false}, + {dx: 7, dy: 7, label: '(7, 7), 9.9 px diagonally', keeps: true}, + {dx: 8, dy: 8, label: '(8, 8), 11.3 px diagonally', keeps: false}, +]) { + test(`a finger moved ${travel.label} over a scroll view ${travel.keeps ? 'keeps its tap and scrolls nothing' : 'is a tap or a scroll, never both'} (spec:AST-094 FR18–FR22)`, async ({ + page, + browserName, + }) => { + const driver = await openTapInScroller(page, browserName); + await logTouchMoves(page); + const at = await center(page, 'row'); + await driver.play(drag(at, {x: at.x + travel.dx, y: at.y + travel.dy}, 6)); + await page.waitForTimeout(100); + const taps = await page.evaluate( + () => (window as unknown as {__taps: string[]}).__taps, + ); + const scrolled = (await scrollTop(page, 'list')) > 0; + if (travel.keeps) { + // Under 10 px of travel: a tap, and no scroll took the touch. + expect(taps).toContain('ended'); + expect(scrolled).toBe(false); + } else { + // From 10 px the browser may scroll (WebKit on a phone starts at 10; + // Chromium waits longer): if it does, the scroll takes the touch and + // there is no tap; if it does not, the tap stands. + expect(scrolled && taps.includes('ended')).toBe(false); + } + // A tap never claims a moving touch: no touchmove is cancelled, so the + // browser's scroll is never blocked. + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + }); +} + +/** + * A screen-edge pan on a strip along the left edge as wide as its band + * (40.2 px, which lays out at 40.203), over a horizontal scroller that + * fills the page; every + * transition of the pan goes to `window.__edge`. + */ +async function openEdgePan(page: Page, engine: string): Promise { + const driver = await openBare(page, engine); + await page.evaluate(() => { + const {host, ScreenEdgePanGestureRecognizer} = window.__api; + document.body.innerHTML = ` +
+
wide
+
+
`; + const edges: string[] = []; + (window as unknown as {__edge: string[]}).__edge = edges; + const pan = new ScreenEdgePanGestureRecognizer({ + name: 'edge', + edges: ['left'], + }); + pan.addTransitionListener((_current, _from, to) => edges.push(to)); + host.attach(document.getElementById('edge') as HTMLElement, pan); + }); + return driver; +} + +for (const run of [ + {travel: 14, begins: false}, + {travel: 15, begins: true}, +]) { + test(`a screen-edge pan landing 40 px from the edge ${run.begins ? 'claims at' : 'has not claimed at'} ${run.travel} px of travel (spec:AST-094 FR18–FR22)`, async ({ + page, + browserName, + }) => { + const driver = await openEdgePan(page, browserName); + await logTouchMoves(page); + await driver.play( + drag({x: 40, y: 150}, {x: 40 + run.travel, y: 150}, 7, false), + ); + await page.waitForTimeout(100); + const edges = await page.evaluate( + () => (window as unknown as {__edge: string[]}).__edge, + ); + expect(edges.includes('began')).toBe(run.begins); + await driver.play([{t: 0, down: []}]); + }); +} + +test('a touch landing 41 px from the edge is outside the band: the edge pan never begins, and the horizontal scroll it starts is not blocked (spec:AST-094 FR18–FR22)', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await openEdgePan(page, browserName); + await page.evaluate(() => { + (document.getElementById('hscroll') as HTMLElement).scrollLeft = 600; + }); + await logTouchMoves(page); + await driver.play(drag({x: 41, y: 150}, {x: 201, y: 150}, 12)); + await page.waitForTimeout(300); + const edges = await page.evaluate( + () => (window as unknown as {__edge: string[]}).__edge, + ); + expect(edges).not.toContain('began'); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + expect( + await page.evaluate( + () => (document.getElementById('hscroll') as HTMLElement).scrollLeft, + ), + ).toBeLessThan(600); +}); + +/** A watcher's transition, with how far the list had scrolled then. */ +interface WatchTransition { + to: string; + scrollTop: number; +} + +test('a watcher follows a touch the browser scrolls with to the lift, and cancels none of its moves (spec:AST-094 FR20)', async ({ + page, + browserName, +}) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + const driver = await openBare(page, browserName); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.innerHTML = ` +
+
row
+
more rows
+
`; + const list = document.getElementById('list') as HTMLElement; + const states: WatchTransition[] = []; + (window as unknown as {__watch: WatchTransition[]}).__watch = states; + const watcher = new PanGestureRecognizer({ + name: 'watch', + direction: 'vertical', + claimsTouches: false, + }); + // Each transition with how far the list had scrolled then. + watcher.addTransitionListener((_current, _from, to) => + states.push({to, scrollTop: list.scrollTop}), + ); + host.attach(document.getElementById('list') as HTMLElement, watcher, { + manageStyle: false, + }); + }); + await logTouchMoves(page); + const at = await center(page, 'row'); + await driver.play(drag(at, {x: at.x, y: at.y - 120}, 30)); + await page.waitForTimeout(100); + const states = await page.evaluate( + () => + ( + window as unknown as { + __watch: WatchTransition[]; + } + ).__watch, + ); + // The browser scrolled the list: the watcher took nothing from it. + expect(await scrollTop(page, 'list')).toBeGreaterThan(20); + expect((await readTouchMoves(page)).some(Boolean)).toBe(false); + // And it followed the scroll from its start to the lift: it still + // reported moves once the browser had taken the touch and was scrolling. + expect(states.map(entry => entry.to)).toContain('began'); + expect( + states.some(entry => entry.to === 'changed' && entry.scrollTop > 0), + ).toBe(true); + // It ended there, then the arena reset it to possible, as UIKit resets a + // recognizer after it ends. + expect(states.slice(-2).map(entry => entry.to)).toEqual([ + 'ended', + 'possible', + ]); +}); diff --git a/packages/core/src/gestures/__tests__/browser/playwright.config.ts b/packages/core/src/gestures/__tests__/browser/playwright.config.ts new file mode 100644 index 0000000000000..9664a60b03fb7 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/playwright.config.ts @@ -0,0 +1,49 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file playwright.config.ts + * @input Uses @playwright/test + * @output The gesture system's browser proof on a phone-sized touch screen, + * in Chromium (touch input through CDP) and WebKit (its own input command) + * @position Both-engine config for packages/core/src/gestures (CI runs the + * same specs in Chromium through playwright.interaction.config.ts); run with + * `pnpm exec playwright test --config packages/core/src/gestures/__tests__/browser/playwright.config.ts`. + * Where WebKit cannot launch on the host, run its project inside the + * official Playwright image (the repository mounted at the same path): + * `podman run --rm --network=host --ipc=host -v "$PWD:$PWD" -w "$PWD" + * mcr.microsoft.com/playwright:v1.61.1-noble node + * node_modules/@playwright/test/cli.js test --config --project webkit` + */ + +import os from 'node:os'; +import path from 'node:path'; +import {defineConfig, devices} from '@playwright/test'; + +const phone = { + viewport: {width: 390, height: 844}, + deviceScaleFactor: 3, + hasTouch: true, + isMobile: true, +} as const; + +export default defineConfig({ + testDir: '.', + // Traces and screenshots of failures stay out of the source tree. + outputDir: path.join(os.tmpdir(), 'astryx-gesture-proof'), + testMatch: ['*.browser.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + reporter: [['list']], + projects: [ + { + name: 'chromium', + use: { + ...devices['Pixel 7'], + browserName: 'chromium', + ...phone, + }, + }, + {name: 'webkit', use: {browserName: 'webkit', ...phone}}, + ], +}); diff --git a/packages/core/src/gestures/__tests__/browser/presentedFixture.ts b/packages/core/src/gestures/__tests__/browser/presentedFixture.ts new file mode 100644 index 0000000000000..94900f1002ac0 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/presentedFixture.ts @@ -0,0 +1,162 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file presentedFixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A scrim and a sheet with a Done button; `window.__dismiss()` moves + * the sheet down off the screen over 1.2 s with the real animator. `__offsets()` + * reads the animator's presented offset beside the drawn top and the top + * the animation started from. `__mimicIos()` makes hit tests answer where + * the engine last resolved the sheet (its start, until a style write), as + * iOS WebKit does mid-animation, and + * `__tap(x, y)` sends a synthetic tap whose target is what such a hit test + * returns; transitions and clicks go to `__log` + * @position Browser fixture of presentedHitTesting.browser.spec.ts; test-only + */ + +import {createAnimator} from '../../../motion/animator'; +import {presentedOffsetOf} from '../../../motion/presentation'; +import {GestureHost} from '../../GestureHost'; +import {PanGestureRecognizer} from '../../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../../TapGestureRecognizer'; + +declare global { + interface Window { + __log: string[]; + __dismiss: () => void; + __offsets: () => {offset: number; drawnTop: number; startTop: number}; + __mimicIos: () => void; + __tap: (x: number, y: number) => void; + } +} + +window.__log = []; +document.body.style.cssText = 'margin:0;font:15px system-ui,sans-serif'; +document.body.innerHTML = ` +
+
+ +
`; + +const element = (id: string): HTMLElement => { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +}; +const scrim = element('scrim'); +const sheet = element('sheet'); +const done = element('done'); + +const host = new GestureHost(document, {presentedHitTesting: 'always'}); +const scrimTap = new TapGestureRecognizer({name: 'scrim'}); +const sheetDrag = new PanGestureRecognizer({name: 'sheet', direction: 'down'}); +for (const recognizer of [scrimTap, sheetDrag]) { + recognizer.addTransitionListener((current, _from, to) => { + window.__log.push(`${current.name}:${to}`); + }); +} +host.attach(scrim, scrimTap); +host.attach(sheet, sheetDrag); +for (const [name, target] of [ + ['scrim', scrim], + ['done', done], +] as const) { + target.addEventListener('click', () => window.__log.push(`click:${name}`)); +} + +const animator = createAnimator(sheet); +window.__dismiss = () => { + animator.play({ + y: {duration: 1.2, at: t => (444 * t) / 1.2, velocity: () => 370}, + }); +}; + +/** The dismissal starts from translate 0. */ +const START_Y = 0; + +/** Where `target`'s top is with the sheet at the translate it started from. */ +const startTop = (target: HTMLElement): number => { + let top = 0; + for ( + let node: HTMLElement | null = target; + node != null; + node = node.offsetParent as HTMLElement | null + ) { + top += node.offsetTop; + } + return top + START_Y; +}; + +window.__offsets = () => ({ + offset: presentedOffsetOf(sheet).y, + drawnTop: sheet.getBoundingClientRect().top, + startTop: startTop(sheet), +}); + +window.__mimicIos = () => { + // iOS keeps the transform it resolved when the animation started, until a + // style write on the element resolves it again to the value drawn then. + const real = Element.prototype.getBoundingClientRect; + let resolved = {top: startTop(sheet), key: ''}; + const resolvedTop = (): number => { + const key = sheet.style.getPropertyValue('--astryx-presented'); + if (key !== resolved.key) { + resolved = {top: real.call(sheet).top, key}; + } + return resolved.top; + }; + for (const target of [sheet, done]) { + const fromSheetTop = startTop(target) - startTop(sheet); + const box = () => { + const drawn = real.call(target); + return new DOMRect( + drawn.left, + resolvedTop() + fromSheetTop, + drawn.width, + drawn.height, + ); + }; + Object.defineProperty(target, 'getBoundingClientRect', {value: box}); + } + const stack = (x: number, y: number): Element[] => { + const inside = (target: HTMLElement) => { + const box = target.getBoundingClientRect(); + return x >= box.left && x < box.right && y >= box.top && y < box.bottom; + }; + return [ + ...(inside(done) ? [done] : []), + ...(inside(sheet) ? [sheet] : []), + scrim, + document.body, + document.documentElement, + ]; + }; + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: stack, + }); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: (x: number, y: number) => stack(x, y)[0], + }); +}; + +window.__tap = (x, y) => { + const target = document.elementFromPoint(x, y) ?? document.body; + const send = (type: 'touchstart' | 'touchend') => { + const event = new Event(type, {bubbles: true, cancelable: true}); + const contact = {identifier: 1, clientX: x, clientY: y, target}; + Object.defineProperty(event, 'changedTouches', {value: [contact]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [contact], + }); + target.dispatchEvent(event); + window.__log.push( + `${type}:${event.defaultPrevented ? 'prevented' : 'open'}`, + ); + }; + send('touchstart'); + send('touchend'); +}; diff --git a/packages/core/src/gestures/__tests__/browser/presentedHitTesting.browser.spec.ts b/packages/core/src/gestures/__tests__/browser/presentedHitTesting.browser.spec.ts new file mode 100644 index 0000000000000..3c075582504b5 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/presentedHitTesting.browser.spec.ts @@ -0,0 +1,87 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file presentedHitTesting.browser.spec.ts + * @input presentedFixture.ts bundled with esbuild: a sheet the real animator + * moves on the compositor, over a scrim + * @output Browser proof that the animator's presented offset is how far the + * engine draws the sheet from where its animation started, and that with + * hit testing answering at that start, as iOS WebKit does mid-animation, a + * tap on the drawn Done button clicks Done once, never the sheet's body + * or the scrim + * @position Browser tests of the presented hit testing in GestureHost.ts and + * motion/presentation.ts (see playwright.config.ts beside this file); the + * real iOS behaviour is proven on the simulator + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {expect, test} from '@playwright/test'; + +let bundle = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'presentedFixture.ts', + ), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + }); + bundle = result.outputFiles[0].text; +}); + +test('a tap on a sheet the compositor is moving reaches what is drawn, once', async ({ + page, +}) => { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bundle}); + await page.evaluate(() => window.__dismiss()); + await page.waitForTimeout(300); + const samples: {offset: number; drawnTop: number; startTop: number}[] = []; + for (let i = 0; i < 3; i++) { + samples.push(await page.evaluate(() => window.__offsets())); + await page.waitForTimeout(150); + } + for (const sample of samples) { + // The sheet is drawn below its start, by what the animator reports (one + // frame of motion apart at most: 370 px/s). + expect(sample.offset).toBeGreaterThan(20); + expect( + Math.abs(sample.drawnTop - sample.startTop - sample.offset), + ).toBeLessThan(8); + } + + // iOS WebKit's hit testing, mimicked: the drawn Done button sits over the + // sheet's body where the sheet started. + await page.evaluate(() => window.__mimicIos()); + const onDone = await page.evaluate(() => { + const done = document.getElementById('done') as HTMLElement; + const box = done.getBoundingClientRect(); + return { + x: box.left + box.width / 2, + y: box.top + box.height / 2 + window.__offsets().offset, + }; + }); + const hit = await page.evaluate( + ({x, y}) => document.elementFromPoint(x, y)?.id, + onDone, + ); + expect(hit).toBe('sheet'); + await page.evaluate(({x, y}) => window.__tap(x, y), onDone); + const log = await page.evaluate(() => window.__log); + expect(log.filter(entry => entry === 'click:done')).toHaveLength(1); + expect(log).not.toContain('click:scrim'); + expect(log.some(entry => entry.startsWith('scrim:'))).toBe(false); + expect(log).toContain('touchend:prevented'); +}); diff --git a/packages/core/src/gestures/__tests__/browserNegotiation.test.ts b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts new file mode 100644 index 0000000000000..381eb8ef37a3c --- /dev/null +++ b/packages/core/src/gestures/__tests__/browserNegotiation.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file browserNegotiation.test.ts + * @input Recognizer sets, touch-action strings, and jsdom elements with + * scripted scroll metrics + * @output What the browser is told (touch-action and long-press styles) and + * what the native scroll probe answers (edges, touch-action, chaining, + * RTL) + * @position Unit tests of touchAction.ts and nativeScroll.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {createNativeScrollProbe, parseTouchAction} from '../nativeScroll'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PinchGestureRecognizer} from '../PinchGestureRecognizer'; +import {RotationGestureRecognizer} from '../RotationGestureRecognizer'; +import {ScreenEdgePanGestureRecognizer} from '../ScreenEdgePanGestureRecognizer'; +import {SwipeGestureRecognizer} from '../SwipeGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {deriveTouchAction, gestureHostStyle} from '../touchAction'; +import {HoverGestureRecognizer} from '../HoverGestureRecognizer'; + +describe('deriveTouchAction', () => { + it('needs nothing without touch recognizers', () => { + expect(deriveTouchAction([])).toBeNull(); + expect(deriveTouchAction([new HoverGestureRecognizer()])).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({allowedPointerTypes: ['mouse']}), + ]), + ).toBeNull(); + }); + + it('removes only double-tap zoom for taps and long presses', () => { + expect(deriveTouchAction([new TapGestureRecognizer()])).toBe( + 'manipulation', + ); + expect(deriveTouchAction([new LongPressGestureRecognizer()])).toBe( + 'manipulation', + ); + }); + + it('gives the browser the other axis of a one-axis pan or swipe, and keeps pinch-zoom', () => { + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'horizontal'})]), + ).toBe('pan-y pinch-zoom'); + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'vertical'})]), + ).toBe('pan-x pinch-zoom'); + expect(deriveTouchAction([new PanGestureRecognizer()])).toBe('pinch-zoom'); + expect( + deriveTouchAction([new SwipeGestureRecognizer({direction: 'left'})]), + ).toBe('pan-y pinch-zoom'); + }); + + it('leaves the browser everything for a one-way pan, or one that shares native scrolling', () => { + // The other direction keeps scrolling natively; the pan claims its own + // by cancelling touchmove when it begins. + expect( + deriveTouchAction([new PanGestureRecognizer({direction: 'down'})]), + ).toBeNull(); + // A screen-edge pan from one edge goes one way, inward (spec:AST-094 FR18). + expect( + deriveTouchAction([ + new ScreenEdgePanGestureRecognizer({edges: ['leading']}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + ]), + ).toBeNull(); + expect( + deriveTouchAction([ + new PanGestureRecognizer({direction: 'down', scrollEdge: true}), + new TapGestureRecognizer(), + ]), + ).toBe('manipulation'); + }); + + it('takes pinch-zoom only for a pinch or rotation recognizer', () => { + expect(deriveTouchAction([new PinchGestureRecognizer()])).toBe( + 'pan-x pan-y', + ); + expect(deriveTouchAction([new RotationGestureRecognizer()])).toBe( + 'pan-x pan-y', + ); + expect( + deriveTouchAction([ + new PinchGestureRecognizer(), + new PanGestureRecognizer(), + ]), + ).toBe('none'); + }); + + it('intersects every recognizer on the element', () => { + expect( + deriveTouchAction([ + new TapGestureRecognizer(), + new PanGestureRecognizer({direction: 'horizontal'}), + ]), + ).toBe('pan-y pinch-zoom'); + }); + + it('attaches the touch-action alone, never a selection lock (spec:AST-094 FR41)', () => { + // A mouse and a keyboard keep selecting (spec:AST-017 FR59); the lock + // for one held press is the long press's own selectionLock. + expect(gestureHostStyle([new LongPressGestureRecognizer()])).toEqual({ + touchAction: 'manipulation', + }); + }); +}); + +describe('parseTouchAction', () => { + it('reads the pans each value allows', () => { + expect(parseTouchAction('auto')).toEqual({x: true, y: true}); + expect(parseTouchAction('manipulation')).toEqual({x: true, y: true}); + expect(parseTouchAction('none')).toEqual({x: false, y: false}); + expect(parseTouchAction('pan-y pinch-zoom')).toEqual({x: false, y: true}); + expect(parseTouchAction('pan-left')).toEqual({x: true, y: false}); + expect(parseTouchAction('pinch-zoom')).toEqual({x: false, y: false}); + }); +}); + +/** Give an element the scroll metrics jsdom cannot lay out. */ +function scroller( + element: HTMLElement, + metrics: { + scrollTop?: number; + scrollHeight?: number; + clientHeight?: number; + scrollLeft?: number; + scrollWidth?: number; + clientWidth?: number; + }, +): void { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, {configurable: true, value}); + } +} + +/** + * jsdom drops `touch-action` from styles, so the elements under test carry + * it in `data-touch-action` and computed style reports it from there. + */ +function withTouchActionFromData(): () => void { + const original = window.getComputedStyle.bind(window); + const spy = vi + .spyOn(window, 'getComputedStyle') + .mockImplementation((element: Element, pseudo?: string | null) => { + const style = original(element, pseudo); + const touchAction = element.getAttribute('data-touch-action'); + if (touchAction == null) { + return style; + } + return new Proxy(style, { + get(target, key): string | number | object | null | undefined { + if (key === 'touchAction') { + return touchAction; + } + const value = Reflect.get(target, key) as + string | number | object | null | undefined; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }); + return () => spy.mockRestore(); +} + +describe('createNativeScrollProbe', () => { + let restore: () => void = () => {}; + beforeEach(() => { + restore = withTouchActionFromData(); + }); + afterEach(() => { + restore(); + document.body.innerHTML = ''; + document.documentElement.style.overflow = ''; + }); + + function setup(listStyle: string) { + // The page itself does not scroll in these cases. + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = `
`; + const list = document.getElementById('list') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + return {list, row}; + } + + it('answers by edge: at the top a pull down scrolls nothing, a pull up does', () => { + const {list, row} = setup('overflow-y: auto'); + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + scroller(list, {scrollTop: 600}); + expect(probe.canScroll(0, -20)).toBe(false); + expect(probe.canScroll(0, 20)).toBe(true); + }); + + it('finds a scroller in a shadow tree around the slot a light-DOM row is slotted into', () => { + document.documentElement.style.overflow = 'hidden'; + document.body.innerHTML = + '
'; + const host = document.getElementById('host') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + const shadow = host.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const list = shadow.getElementById('list') as HTMLElement; + scroller(list, {scrollTop: 0, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.isInsideScroller).toBe(true); + expect(probe.canScroll(0, -20)).toBe(true); + }); + + it('puts a control in a fixed bar outside the page it floats over (spec:AST-094 FR11)', () => { + document.body.innerHTML = + '
'; + const root = document.scrollingElement ?? document.documentElement; + scroller(root as HTMLElement, { + scrollTop: 0, + scrollHeight: 3000, + clientHeight: 800, + }); + const probe = createNativeScrollProbe(document.getElementById('row')); + // The page still scrolls under a drag that starts on the bar, but the bar + // is not in a scroll view, so its control gets no content-touch delay. + expect(probe.isInsideScroller).toBe(false); + expect(probe.canScroll(0, -20)).toBe(true); + for (const key of ['scrollTop', 'scrollHeight', 'clientHeight']) { + Reflect.deleteProperty(root, key); + } + }); + + it('honors touch-action between the touch and the scroller', () => { + const {list, row} = setup('overflow-y: auto'); + row.setAttribute('data-touch-action', 'pan-x'); + scroller(list, {scrollTop: 100, scrollHeight: 1000, clientHeight: 400}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(0, 20)).toBe(false); + expect(probe.allowsNativePan).toBe(true); + row.setAttribute('data-touch-action', 'none'); + expect(createNativeScrollProbe(row).allowsNativePan).toBe(false); + }); + + it('ignores the wobble of a mostly straight drag', () => { + const {list, row} = setup('overflow-x: auto; overflow-y: hidden'); + scroller(list, {scrollLeft: 50, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(2, 30)).toBe(false); + expect(probe.canScroll(30, 2)).toBe(true); + }); + + it('follows the RTL scrollLeft range', () => { + const {list, row} = setup( + 'overflow-x: auto; overflow-y: hidden; direction: rtl', + ); + // RTL starts at 0 and runs negative toward the inline end (the left). + scroller(list, {scrollLeft: 0, scrollWidth: 1000, clientWidth: 300}); + const probe = createNativeScrollProbe(row); + expect(probe.canScroll(30, 0)).toBe(true); // reveals content on the left + expect(probe.canScroll(-30, 0)).toBe(false); + scroller(list, {scrollLeft: -700}); + expect(probe.canScroll(30, 0)).toBe(false); + }); + + it('stops the chain at overscroll-behavior: contain', () => { + document.documentElement.style.overflow = ''; + document.body.innerHTML = `
x
`; + const outer = document.getElementById('outer') as HTMLElement; + const inner = document.getElementById('inner') as HTMLElement; + scroller(outer, {scrollTop: 300, scrollHeight: 2000, clientHeight: 500}); + scroller(inner, {scrollTop: 0, scrollHeight: 800, clientHeight: 400}); + const probe = createNativeScrollProbe(document.getElementById('t')); + // The inner list is at its top and contains its overscroll: a pull + // down reaches nothing, though the outer one could scroll. + expect(probe.canScroll(0, 20)).toBe(false); + inner.style.overscrollBehavior = 'auto'; + expect( + createNativeScrollProbe(document.getElementById('t')).canScroll(0, 20), + ).toBe(true); + }); +}); diff --git a/packages/core/src/gestures/__tests__/haptics.test.ts b/packages/core/src/gestures/__tests__/haptics.test.ts new file mode 100644 index 0000000000000..fe2bba423c5af --- /dev/null +++ b/packages/core/src/gestures/__tests__/haptics.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file haptics.test.ts + * @input playHaptic, attachTapHaptic and the support probes, with + * navigator.vibrate stubbed or absent, in jsdom + * @output Vibration patterns per feedback kind where the platform vibrates, + * nothing where it does not; the hidden switch iOS Safari plays its tap + * haptic on: hidden from layout, touch and assistive technology, its click + * and change kept inside, removed on release + * @position Unit tests of packages/core/src/gestures/haptics.ts + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import { + HAPTIC_FEEDBACK_KINDS, + HAPTIC_VIBRATION_PATTERNS, +} from '../../motion/uikit/gestures'; +import { + attachTapHaptic, + canVibrate, + HAPTIC_SWITCH_ATTRIBUTE, + playHaptic, + supportsSwitchControl, +} from '../haptics'; + +const original = Object.getOwnPropertyDescriptor(navigator, 'vibrate'); + +function stubVibrate(implementation: (pattern: number[]) => boolean) { + const vibrate = vi.fn(implementation); + Object.defineProperty(navigator, 'vibrate', { + configurable: true, + value: vibrate, + }); + return vibrate; +} + +afterEach(() => { + if (original == null) { + delete (navigator as {vibrate?: unknown}).vibrate; + } else { + Object.defineProperty(navigator, 'vibrate', original); + } + document.body.innerHTML = ''; +}); + +describe('playHaptic', () => { + it('vibrates each feedback kind with its own pattern', () => { + const vibrate = stubVibrate(() => true); + expect(canVibrate()).toBe(true); + for (const kind of HAPTIC_FEEDBACK_KINDS) { + expect(playHaptic(kind)).toBe(true); + expect(vibrate).toHaveBeenLastCalledWith([ + ...HAPTIC_VIBRATION_PATTERNS[kind].value, + ]); + } + }); + + it('plays nothing where the platform cannot vibrate, or refuses', () => { + delete (navigator as {vibrate?: unknown}).vibrate; + expect(canVibrate()).toBe(false); + expect(playHaptic('impactLight')).toBe(false); + stubVibrate(() => { + throw new Error('blocked'); + }); + expect(playHaptic('selection')).toBe(false); + }); +}); + +describe('attachTapHaptic', () => { + it('gives the label a hidden switch that keeps its own events inside', () => { + document.body.innerHTML = ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const onClick = vi.fn(); + const onChange = vi.fn(); + label.addEventListener('click', onClick); + label.addEventListener('change', onChange); + + const release = attachTapHaptic(label); + const input = label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`); + expect(input).toBeInstanceOf(HTMLInputElement); + const sw = input as HTMLInputElement; + expect(sw.type).toBe('checkbox'); + expect(sw.hasAttribute('switch')).toBe(true); + expect(sw.getAttribute('aria-hidden')).toBe('true'); + expect(sw.tabIndex).toBe(-1); + // It never takes the touch itself, so a pan starting on it still + // scrolls, and it takes no room. + expect(sw.style.pointerEvents).toBe('none'); + expect(sw.style.position).toBe('absolute'); + + // The switch toggling (the label's activation) does not reach the + // control as a second click or a change. + sw.click(); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + + release(); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + }); + + it('never leaves the focus on its hidden switch', () => { + document.body.innerHTML = + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + // Chromium focuses a label's control on its activation. + sw.focus(); + expect(document.activeElement).not.toBe(sw); + expect(document.activeElement).toBe(before); + release(); + }); + + it('keeps a label\u2019s own control: the tap plays and the control still toggles', () => { + document.body.innerHTML = + ''; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const onOwnClick = vi.fn(); + own.addEventListener('click', onOwnClick); + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + // The switch takes the label's activation, so a tap plays. + expect(label.control).toBe(sw); + // The checkbox keeps its name: the label's text, through aria-labelledby. + expect(own).toHaveAccessibleName('Remember me'); + sw.click(); + expect(sw.checked).toBe(true); + expect(own.checked).toBe(true); + expect(onOwnClick).toHaveBeenCalledTimes(1); + release(); + expect(label.control).toBe(own); + expect(own).not.toHaveAttribute('aria-labelledby'); + }); + + it('gives a disabled control no feedback and leaves no focus on the switch (FR23)', () => { + document.body.innerHTML = + '' + + ''; + const before = document.getElementById('before') as HTMLButtonElement; + const label = document.getElementById('tap') as HTMLLabelElement; + const own = document.getElementById('own') as HTMLInputElement; + const release = attachTapHaptic(label); + const sw = label.querySelector( + `[${HAPTIC_SWITCH_ATTRIBUTE}]`, + )!; + before.focus(); + sw.focus(); + sw.click(); + // The switch did not toggle (nothing plays), the control did not change, + // and focus is not stranded on the hidden switch. + expect(sw.checked).toBe(false); + expect(own.checked).toBe(false); + expect(document.activeElement).toBe(before); + release(); + }); + + it('leaves a label whose control is already a switch, or tied by for, as it is', () => { + document.body.innerHTML = + '' + + ''; + for (const id of ['a', 'b']) { + const label = document.getElementById(id) as HTMLLabelElement; + const release = attachTapHaptic(label); + expect(label.querySelector(`[${HAPTIC_SWITCH_ATTRIBUTE}]`)).toBeNull(); + release(); + } + }); + + it('reports whether the browser knows the switch control', () => { + // jsdom has no `switch` property on checkbox inputs, like browsers + // before Safari 17.4. + expect(supportsSwitchControl(document)).toBe(false); + }); +}); diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index a54549412f7e0..b3416fc5be785 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -4,8 +4,9 @@ * @file harness.ts * @input Nothing * @output Exports the synthetic-input harness the gesture tests share: a - * manual clock, an arena wired to a transition log, and fingers that land, - * move and lift at explicit timestamps + * manual clock, an arena wired to a transition log, fingers that land, + * move and lift at explicit timestamps, and a coarse primary pointer for + * tests that go through the document's host * @position Test-only helper of packages/core/src/gestures (excluded from the * build with the rest of __tests__) */ @@ -40,7 +41,13 @@ export interface Harness { claims: number[]; /** Deferred clicks as they resolved. */ deferredClicks: {id: number; allowed: boolean; time: number}[]; - environment: {width: number; height: number; rtl: boolean}; + environment: { + width: number; + height: number; + rtl: boolean; + /** The size every recognizer's view measures, when a test sets one. */ + view?: {width: number; height: number}; + }; finger(id: number): Finger; /** Advance the clock and run every timer due by `time`. */ advance(time: number): void; @@ -51,7 +58,11 @@ export function createHarness(): Harness { const log: string[] = []; const claims: number[] = []; const deferredClicks: Harness['deferredClicks'] = []; - const environment = {width: 390, height: 844, rtl: false}; + const environment: Harness['environment'] = { + width: 390, + height: 844, + rtl: false, + }; const fingers = new Map(); const env: GestureEnvironment = { viewportSize: () => ({ @@ -59,6 +70,7 @@ export function createHarness(): Harness { height: environment.height, }), isRtl: () => environment.rtl, + viewSize: () => environment.view ?? null, }; const arena: GestureArena = new GestureArena({ clock, @@ -202,3 +214,31 @@ export function scrollProbe( ): NativeScrollProbe { return {canScroll, isInsideScroller, allowsNativePan: true}; } + +/** + * Declare a phone's primary pointer for a test that goes through the + * document's host (`getGestureHost`): the host listens only where + * `(pointer: coarse)` matches (`spec:AST-094` FR19, FR24), and jsdom has no + * `matchMedia`. Returns the undo. + */ +export function coarsePrimaryPointer(): () => void { + const hadOwn = Object.prototype.hasOwnProperty.call(window, 'matchMedia'); + const original = window.matchMedia; + window.matchMedia = (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }); + return () => { + if (hadOwn) { + window.matchMedia = original; + } else { + delete (window as {matchMedia?: unknown}).matchMedia; + } + }; +} diff --git a/packages/core/src/gestures/__tests__/importGraph.test.ts b/packages/core/src/gestures/__tests__/importGraph.test.ts new file mode 100644 index 0000000000000..61a5d91ec8a9a --- /dev/null +++ b/packages/core/src/gestures/__tests__/importGraph.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file importGraph.test.ts + * @input Uses vitest and node:fs over the gestures sources + * @output A gesture-only component's imports (useGestureRecognizer, the pan + * recognizers, the gesture host) reach neither the press tracker nor + * haptics, nor the hover recognizer, the page hold or the finger-scroll + * watch; each of those stays reachable from the entry that uses it; the + * host and touch-action reach no recognizer class + * @position Unit test of packages/core/src/gestures' module graph + * + * Static imports only: a type-only import is erased and costs nothing, and + * nothing here loads lazily. Run from the source, the graph is what a bundler + * that keeps every reachable module would ship. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +const GESTURES = path.resolve(__dirname, '..'); + +/** The relative modules `file` imports for their values. */ +function valueImports(file: string): string[] { + const source = fs.readFileSync(file, 'utf8'); + const specifiers: string[] = []; + const statement = + /^\s*(import|export)\s+(type\s+)?([^'";]*?\s+from\s+)?'([^']+)';/gms; + for (const match of source.matchAll(statement)) { + const [, , isTypeOnly, , specifier] = match; + if (isTypeOnly == null && specifier.startsWith('.')) { + specifiers.push(specifier); + } + } + return specifiers.map(specifier => resolveModule(file, specifier)); +} + +function resolveModule(from: string, specifier: string): string { + const base = path.resolve(path.dirname(from), specifier); + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + path.join(base, 'index.ts'), + ]) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + throw new Error( + `${path.relative(GESTURES, from)}: cannot resolve ${specifier}`, + ); +} + +/** Every module `entry` reaches through value imports, as names relative to gestures/. */ +function reachable(entry: string): Set { + const seen = new Set(); + const queue = [path.join(GESTURES, entry)]; + while (queue.length > 0) { + const file = queue.pop() as string; + if (seen.has(file)) { + continue; + } + seen.add(file); + queue.push(...valueImports(file)); + } + return new Set([...seen].map(file => path.relative(GESTURES, file))); +} + +describe('the gestures module graph', () => { + it.each([ + 'useGestureRecognizer.ts', + 'PanGestureRecognizer.ts', + 'GestureHost.ts', + 'useFingerScroll.ts', + ])('%s reaches neither the press tracker nor haptics', entry => { + const modules = reachable(entry); + expect(modules.has(entry)).toBe(true); + expect(modules).not.toContain('PressTracker.ts'); + expect(modules).not.toContain('usePressTracker.ts'); + expect(modules).not.toContain('haptics.ts'); + }); + + it('reaches the press tracker from usePressTracker, and haptics from its own entry', () => { + expect(reachable('usePressTracker.ts')).toContain('PressTracker.ts'); + // The host shares only the switch's attribute with haptics, from utils/, + // where the click guard reads it too. + expect(reachable('haptics.ts')).toContain('../utils/hapticSwitch.ts'); + expect(reachable('GestureHost.ts')).toContain('../utils/hapticSwitch.ts'); + expect(reachable('GestureHost.ts')).not.toContain('hapticSwitch.ts'); + }); + + it.each([ + 'useGestureRecognizer.ts', + 'PanGestureRecognizer.ts', + 'GestureHost.ts', + ])( + '%s reaches neither the hover recognizer, the page hold nor the finger-scroll watch', + entry => { + const modules = reachable(entry); + expect(modules).not.toContain('HoverGestureRecognizer.ts'); + expect(modules).not.toContain('pageHold.ts'); + expect(modules).not.toContain('fingerScroll.ts'); + }, + ); + + it('reaches the finger-scroll watch from useFingerScroll, and the page hold from its own entry', () => { + expect(reachable('useFingerScroll.ts')).toContain('fingerScroll.ts'); + // The page hold takes the host it holds for: it imports none of it. + expect(reachable('pageHold.ts')).not.toContain('GestureHost.ts'); + expect(reachable('HoverGestureRecognizer.ts')).toContain( + 'GestureRecognizer.ts', + ); + }); + + it.each(['GestureHost.ts', 'touchAction.ts', 'PressTracker.ts'])( + '%s reaches no recognizer class: each brands its family', + entry => { + const modules = reachable(entry); + expect(modules).not.toContain('TapGestureRecognizer.ts'); + expect(modules).not.toContain('LongPressGestureRecognizer.ts'); + expect(modules).not.toContain('PanGestureRecognizer.ts'); + expect(modules).not.toContain('HoverGestureRecognizer.ts'); + }, + ); + + it('reads type-only imports as free', () => { + // GestureRecognizer imports GestureArena for its types alone. + expect(reachable('GestureRecognizer.ts')).not.toContain('GestureArena.ts'); + }); +}); diff --git a/packages/core/src/gestures/__tests__/passiveByDefault.test.tsx b/packages/core/src/gestures/__tests__/passiveByDefault.test.tsx new file mode 100644 index 0000000000000..8d553dd3ac358 --- /dev/null +++ b/packages/core/src/gestures/__tests__/passiveByDefault.test.tsx @@ -0,0 +1,88 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file passiveByDefault.test.tsx + * @input Uses vitest, Testing Library, Button, Item, GestureHost and + * PressTracker + * @output Checks that plain pressables never get a blocking touchmove + * listener, and that only a press tracker created with `keepsTouch: true` + * does + * @position Guards the 60 fps scroll goal: a scroll that starts on a plain + * button or a list cell never waits on the main thread + */ + +import {render} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Button} from '../../Button/Button'; +import {Item} from '../../Item/Item'; +import {GestureHost} from '../GestureHost'; +import {PressTracker} from '../PressTracker'; +import {ManualClock} from './harness'; +import {coarsePrimaryPointer} from './pointerQuery'; + +function touchStart(target: Element): void { + const event = new Event('touchstart', {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: 5, clientY: 5, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: [entry]}); + target.dispatchEvent(event); +} + +function blockingTouchMoves(spy: {mock: {calls: unknown[][]}}): number { + return spy.mock.calls.filter( + ([type, , options]) => + type === 'touchmove' && + (options as AddEventListenerOptions | undefined)?.passive === false, + ).length; +} + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +// A phone: the host attaches only where the primary pointer is coarse. +let restorePointer: () => void; +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + restorePointer(); +}); + +describe('blocking touchmove listeners', () => { + it('never come with a plain Button or an Item cell', () => { + const spy = vi.spyOn(EventTarget.prototype, 'addEventListener'); + const {getByRole, getByText} = render( +
+
, + ); + touchStart(getByRole('button', {name: 'Save'})); + touchStart(getByText('Row')); + expect(blockingTouchMoves(spy)).toBe(0); + }); + + it('come only with a press tracker that keeps its touch', () => { + document.body.innerHTML = '
'; + const plain = document.getElementById('plain') as HTMLElement; + const stepper = document.getElementById('stepper') as HTMLElement; + const host = new GestureHost(document, {clock: new ManualClock()}); + const onPlain = vi.spyOn(plain, 'addEventListener'); + const onStepper = vi.spyOn(stepper, 'addEventListener'); + const detachPlain = host.attachConsumer( + plain, + new PressTracker({getBounds: () => null}), + ); + const detachStepper = host.attachConsumer( + stepper, + new PressTracker({getBounds: () => null, keepsTouch: true}), + ); + touchStart(plain); + expect(blockingTouchMoves(onPlain)).toBe(0); + expect(blockingTouchMoves(onStepper)).toBe(1); + detachPlain(); + detachStepper(); + }); +}); diff --git a/packages/core/src/gestures/__tests__/pointerQuery.ts b/packages/core/src/gestures/__tests__/pointerQuery.ts new file mode 100644 index 0000000000000..a260bef9bfb5f --- /dev/null +++ b/packages/core/src/gestures/__tests__/pointerQuery.ts @@ -0,0 +1,75 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file pointerQuery.ts + * @input jsdom's window.matchMedia + * @output coarsePrimaryPointer() and pointerQuery(): the primary pointer a + * test needs, until restored + * @position Test helper of packages/core/src/gestures + * + * jsdom's matchMedia models a desktop, whose primary pointer is fine, and + * the gesture host attaches nothing there (`spec:AST-094` FR19). A test of + * touch behaviour models a phone with coarsePrimaryPointer(); a test of the + * gate itself drives pointerQuery() and fires its change. + */ + +import {COARSE_POINTER_QUERY} from '../../utils/coarsePointer'; + +/** The primary pointer as a test sets it. */ +export interface PointerQuery { + /** Turn the primary pointer coarse or fine, firing `change`. */ + set(isCoarse: boolean): void; + /** Put jsdom's matchMedia back. */ + readonly restore: () => void; +} + +/** + * The primary pointer is coarse (`isCoarse`) or fine until `restore` runs; + * `set` changes it as a tablet docking a mouse would. + */ +export function pointerQuery(isCoarse: boolean): PointerQuery { + const matchMedia = window.matchMedia; + const listeners = new Set<() => void>(); + let coarse = isCoarse; + const query = { + get matches() { + return coarse; + }, + media: COARSE_POINTER_QUERY, + onchange: null, + addEventListener: (_type: string, listener: () => void) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: () => void) => { + listeners.delete(listener); + }, + addListener: (listener: () => void) => { + listeners.add(listener); + }, + removeListener: (listener: () => void) => { + listeners.delete(listener); + }, + dispatchEvent: () => false, + } as unknown as MediaQueryList; + window.matchMedia = (media: string) => + media.trim() === COARSE_POINTER_QUERY ? query : matchMedia(media); + return { + set(next) { + if (next === coarse) { + return; + } + coarse = next; + for (const listener of [...listeners]) { + listener(); + } + }, + restore: () => { + window.matchMedia = matchMedia; + }, + }; +} + +/** The primary pointer is coarse (a phone, a tablet) until the restore runs. */ +export function coarsePrimaryPointer(): () => void { + return pointerQuery(true).restore; +} diff --git a/packages/core/src/gestures/__tests__/presentedHitTesting.test.ts b/packages/core/src/gestures/__tests__/presentedHitTesting.test.ts new file mode 100644 index 0000000000000..6bc72950e2b6e --- /dev/null +++ b/packages/core/src/gestures/__tests__/presentedHitTesting.test.ts @@ -0,0 +1,387 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file presentedHitTesting.test.ts + * @input GestureHost on jsdom with WebKit's iOS hit testing mimicked: + * `elementFromPoint`, `elementsFromPoint` and `getBoundingClientRect` + * answer where a compositor animation started while a registered offset + * draws the sheet elsewhere (motion/presentation.ts) + * @output A finger on the drawn sheet reaches the sheet, not the scrim under + * its start, and its tap clicks the drawn button once (the engine's click + * cancelled, a late one swallowed); a finger where the sheet was but is no + * longer drawn reaches what is drawn there; press bounds follow the drawn + * box; nothing changes with the gate off or nothing moving + * @position Unit tests of the presented hit testing in + * packages/core/src/gestures/GestureHost.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {registerPresentedOffset} from '../../motion/presentation'; +import {GestureHost} from '../GestureHost'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {ManualClock} from './harness'; +import {coarsePrimaryPointer} from './pointerQuery'; + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +const rect = ({left, top, right, bottom}: Box): DOMRect => ({ + left, + top, + right, + bottom, + x: left, + y: top, + width: right - left, + height: bottom - top, + toJSON: () => ({}), +}); + +function touch( + type: 'touchstart' | 'touchend', + target: EventTarget, + at: {x: number; y: number}, + time: number, +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const contact = {identifier: 1, clientX: at.x, clientY: at.y, target}; + Object.defineProperty(event, 'changedTouches', {value: [contact]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [contact], + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + return event; +} + +let clock: ManualClock; +let cleanups: (() => void)[]; +let scrim: HTMLElement; +let sheet: HTMLElement; +let done: HTMLElement; +let clicks: string[]; +/** Where the engine hit-tests the sheet, relative to its start, px. */ +let resolvedY: number; + +/** + * A sheet whose animation started at `sheetBox` and is drawn `drawnY` px + * from it, with a Done button at its top right, over a full-page scrim. Hit + * tests answer where the engine last resolved the sheet, as iOS WebKit does + * mid-animation: at the start until a style write (the registry's + * `resolve`) moves it to where the sheet is drawn. + */ +function mount(sheetBox: Box, drawnY: number): void { + document.body.innerHTML = + '
'; + scrim = document.getElementById('scrim') as HTMLElement; + sheet = document.getElementById('sheet') as HTMLElement; + done = document.getElementById('done') as HTMLElement; + resolvedY = 0; + const moved = (box: Box): Box => ({ + ...box, + top: box.top + resolvedY, + bottom: box.bottom + resolvedY, + }); + const doneBox: Box = { + left: 300, + top: sheetBox.top + 10, + right: 380, + bottom: sheetBox.top + 50, + }; + const inside = (box: Box, x: number, y: number) => + x >= box.left && x < box.right && y >= box.top && y < box.bottom; + vi.spyOn(sheet, 'getBoundingClientRect').mockImplementation(() => + rect(moved(sheetBox)), + ); + vi.spyOn(done, 'getBoundingClientRect').mockImplementation(() => + rect(moved(doneBox)), + ); + const stack = (x: number, y: number): Element[] => [ + ...(inside(moved(doneBox), x, y) ? [done] : []), + ...(inside(moved(sheetBox), x, y) ? [sheet] : []), + scrim, + document.body, + document.documentElement, + ]; + Object.defineProperty(document, 'elementsFromPoint', { + configurable: true, + value: stack, + }); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: (x: number, y: number) => stack(x, y)[0], + }); + for (const [name, element] of [ + ['scrim', scrim], + ['done', done], + ] as const) { + element.addEventListener('click', () => clicks.push(name)); + } + cleanups.push( + registerPresentedOffset(sheet, { + offset: () => ({x: 0, y: drawnY - resolvedY}), + resolve: () => { + resolvedY = drawnY; + }, + }), + ); +} + +// A phone: the host attaches only where the primary pointer is coarse. +let restorePointer: () => void; +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + restorePointer(); +}); + +beforeEach(() => { + clock = new ManualClock(); + cleanups = []; + clicks = []; +}); + +afterEach(() => { + for (const cleanup of cleanups) { + cleanup(); + } + vi.restoreAllMocks(); + Reflect.deleteProperty(document, 'elementsFromPoint'); + Reflect.deleteProperty(document, 'elementFromPoint'); + document.body.innerHTML = ''; +}); + +function host(presentedHitTesting: 'always' | 'never' = 'always') { + const gestures = new GestureHost(document, {clock, presentedHitTesting}); + const scrimTap = new TapGestureRecognizer({name: 'scrim'}); + const sheetDrag = new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + }); + cleanups.push( + gestures.attach(scrim, scrimTap), + gestures.attach(sheet, sheetDrag), + ); + return {gestures, scrimTap, sheetDrag}; +} + +describe('presented hit testing (iOS WebKit hit-tests compositor motion at layout)', () => { + it('sends a finger on the drawn sheet to the sheet, and clicks the drawn button once', () => { + // Presenting: the sheet started below the screen, where iOS still + // hit-tests it, and is drawn 544 px higher. + mount({left: 0, top: 844, right: 390, bottom: 1244}, -544); + const {scrimTap, sheetDrag} = host(); + const onDone = {x: 340, y: 330}; + // The engine hit the scrim, under the drawn button at the sheet's start. + touch('touchstart', scrim, onDone, 0); + expect(sheetDrag.numberOfTouches).toBe(1); + expect(scrimTap.numberOfTouches).toBe(0); + const end = touch('touchend', scrim, onDone, 80); + expect(end.defaultPrevented).toBe(true); + expect(clicks).toEqual(['done']); + // The engine's own click, however late, is swallowed by the shared + // click guard: one activation. + const late = () => { + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + }); + scrim.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(late()).toBe(true); + expect(clicks).toEqual(['done']); + // One click only: the guard is spent. + expect(late()).toBe(false); + }); + + it("clicks the drawn button once with only watchers attached: the shared click guard swallows the engine's own click", () => { + mount({left: 0, top: 844, right: 390, bottom: 1244}, -544); + // Nothing that claims: the host's touchend listener is passive, so the + // tap's single activation rests on the click guard alone. + const gestures = new GestureHost(document, { + clock, + presentedHitTesting: 'always', + }); + cleanups.push( + gestures.attach( + scrim, + new TapGestureRecognizer({name: 'scrim', claimsTouches: false}), + ), + gestures.attach( + sheet, + new PanGestureRecognizer({ + name: 'sheet', + direction: 'down', + claimsTouches: false, + }), + ), + ); + const onDone = {x: 340, y: 330}; + touch('touchstart', scrim, onDone, 0); + touch('touchend', scrim, onDone, 80); + expect(clicks).toEqual(['done']); + const late = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail: 1, + }); + scrim.dispatchEvent(late); + expect(late.defaultPrevented).toBe(true); + expect(clicks).toEqual(['done']); + }); + + it('descends into an open shadow root on the drawn sheet: the tap reaches and clicks the control inside it', () => { + // A sheet whose content lives in an open shadow root: the document's hit + // test answers its host, the root's answers the control. + document.body.innerHTML = '
'; + scrim = document.getElementById('scrim') as HTMLElement; + sheet = document.getElementById('sheet') as HTMLElement; + const shadow = sheet.attachShadow({mode: 'open'}); + shadow.innerHTML = ''; + const inner = shadow.getElementById('done') as HTMLElement; + let drawnOffset = -544; + let isResolved = false; + const isOnSheet = (y: number) => (isResolved ? y >= 300 : y >= 844); + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: (_x: number, y: number) => (isOnSheet(y) ? sheet : scrim), + }); + Object.defineProperty(shadow, 'elementFromPoint', { + configurable: true, + value: () => inner, + }); + inner.addEventListener('click', () => clicks.push('inner')); + sheet.addEventListener('click', () => clicks.push('sheet')); + scrim.addEventListener('click', () => clicks.push('scrim')); + cleanups.push( + registerPresentedOffset(sheet, { + offset: () => ({x: 0, y: drawnOffset}), + resolve: () => { + isResolved = true; + drawnOffset = 0; + }, + }), + ); + const gestures = new GestureHost(document, { + clock, + presentedHitTesting: 'always', + }); + // A recognizer inside the root that watches without claiming, so the tap + // still earns its click. + const innerWatch = new PanGestureRecognizer({ + name: 'inner', + direction: 'down', + claimsTouches: false, + }); + cleanups.push( + gestures.attach(scrim, new TapGestureRecognizer({name: 'scrim'})), + gestures.attach(inner, innerWatch), + ); + const onDone = {x: 340, y: 330}; + touch('touchstart', scrim, onDone, 0); + expect(innerWatch.numberOfTouches).toBe(1); + touch('touchend', scrim, onDone, 80); + expect(clicks).toEqual(['inner', 'sheet']); + }); + + it('sends a finger where the sheet is going, but not yet drawn, to what is drawn there', () => { + // Dismissing: the sheet started on screen, where iOS still hit-tests it, + // and is drawn 544 px lower. + mount({left: 0, top: 300, right: 390, bottom: 700}, 544); + const {scrimTap, sheetDrag} = host(); + const point = {x: 340, y: 330}; + // The engine hit the button where it started. + touch('touchstart', done, point, 0); + expect(scrimTap.numberOfTouches).toBe(1); + expect(sheetDrag.numberOfTouches).toBe(0); + touch('touchend', done, point, 80); + expect(clicks).not.toContain('done'); + }); + + it('gives press trackers the drawn box, before and after a touch resolves the sheet', () => { + mount({left: 0, top: 844, right: 390, bottom: 1244}, -544); + const {gestures} = host(); + const drawn = {left: 300, top: 310, right: 380, bottom: 350}; + expect(gestures.presentedRect(done)).toMatchObject(drawn); + touch('touchstart', scrim, {x: 340, y: 330}, 0); + expect(resolvedY).toBe(-544); + expect(gestures.presentedRect(done)).toMatchObject(drawn); + touch('touchend', scrim, {x: 340, y: 330}, 80); + expect( + new GestureHost(document, {presentedHitTesting: 'never'}).presentedRect( + done, + ), + ).toMatchObject({top: 310, bottom: 350}); + }); + + it("keeps a blocking touchmove on the document only while an element a claiming recognizer lives in is moving: Safari honours one only when registered before the touch, on the engine's own target (spec:AST-094 FR19)", () => { + document.body.innerHTML = + '
'; + const moving = document.getElementById('moving') as HTMLElement; + const body = document.getElementById('body') as HTMLElement; + const still = document.getElementById('still') as HTMLElement; + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + const blocking = (calls: unknown[][]) => + calls.filter( + ([type, , options]) => + type === 'touchmove' && + typeof options === 'object' && + options != null && + (options as AddEventListenerOptions).passive === false, + ).length; + const gestures = new GestureHost(document, { + clock, + presentedHitTesting: 'always', + }); + cleanups.push( + gestures.attach(body, new PanGestureRecognizer({direction: 'down'})), + gestures.attach(still, new TapGestureRecognizer()), + ); + expect(blocking(add.mock.calls)).toBe(0); + // A tap target moving claims nothing while the finger moves: no listener. + const stopStill = registerPresentedOffset(still, { + offset: () => ({x: 0, y: 30}), + resolve: () => {}, + }); + expect(blocking(add.mock.calls)).toBe(0); + stopStill(); + // The sheet the pan lives in moves: the document blocks until it stops. + const stop = registerPresentedOffset(moving, { + offset: () => ({x: 0, y: 40}), + resolve: () => {}, + }); + expect(blocking(add.mock.calls)).toBe(1); + stop(); + expect( + remove.mock.calls.filter(([type]) => type === 'touchmove').length, + ).toBeGreaterThan(0); + expect(blocking(add.mock.calls)).toBe(1); + }); + + it('changes nothing with the gate off or nothing moving', () => { + mount({left: 0, top: 844, right: 390, bottom: 1244}, -544); + const off = host('never'); + touch('touchstart', scrim, {x: 340, y: 330}, 0); + expect(off.scrimTap.numberOfTouches).toBe(1); + expect(off.sheetDrag.numberOfTouches).toBe(0); + expect(resolvedY).toBe(0); + touch('touchend', scrim, {x: 340, y: 330}, 80); + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } + const still = host('always'); + touch('touchstart', scrim, {x: 340, y: 330}, 200); + expect(still.scrimTap.numberOfTouches).toBe(1); + expect(still.sheetDrag.numberOfTouches).toBe(0); + touch('touchend', scrim, {x: 340, y: 330}, 280); + }); +}); diff --git a/packages/core/src/gestures/__tests__/recognizerBundle.test.ts b/packages/core/src/gestures/__tests__/recognizerBundle.test.ts new file mode 100644 index 0000000000000..7cf7e94bc66bc --- /dev/null +++ b/packages/core/src/gestures/__tests__/recognizerBundle.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file recognizerBundle.test.ts + * @input Uses vitest, node:child_process and esbuild over the gestures + * sources + * @output A production bundle of a page that constructs only a pan ships the + * gesture host and the pan, and none of the tap or long-press recognizers; + * press tracking ships no recognizer + * @position Unit test of packages/core/src/gestures' bundled size + * + * The host and touch-action tell recognizers apart by an internal family + * brand, never `instanceof`, so a bundler drops every recognizer a page does + * not construct. Read from esbuild's metafile: the modules with bytes in the + * minified, tree-shaken output. esbuild cannot run in this suite's jsdom + * environment (it asserts on a native `TextEncoder`), so it runs in a child + * process. + */ + +import {execFileSync} from 'node:child_process'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; + +const GESTURES = path.resolve(__dirname, '..'); + +const BUNDLE_SCRIPT = ` +import {build} from 'esbuild'; +import path from 'node:path'; +const [contents, resolveDir] = process.argv.slice(1); +const result = await build({ + stdin: {contents, resolveDir, loader: 'ts'}, + bundle: true, + write: false, + minify: true, + treeShaking: true, + format: 'esm', + platform: 'browser', + packages: 'external', + metafile: true, + logLevel: 'silent', + define: {'process.env.NODE_ENV': '"production"'}, +}); +const [output] = Object.values(result.metafile.outputs); +const modules = Object.entries(output.inputs) + .filter(([, input]) => input.bytesInOutput > 0) + .map(([file]) => path.basename(file)); +process.stdout.write(JSON.stringify(modules)); +`; + +/** The gestures modules with bytes in a production bundle of `entry`. */ +function bundledModules(entry: string): string[] { + const stdout = execFileSync( + process.execPath, + ['--input-type=module', '-e', BUNDLE_SCRIPT, entry, GESTURES], + {cwd: GESTURES, encoding: 'utf8', timeout: 120_000}, + ); + return JSON.parse(stdout) as string[]; +} + +describe('a gesture bundle', () => { + it('ships only the recognizers a page constructs: a pan-only page has no tap or long press', () => { + const modules = bundledModules(` + import {getGestureHost} from './GestureHost'; + import {PanGestureRecognizer} from './PanGestureRecognizer'; + export function mount(element: Element): () => void { + return getGestureHost(element.ownerDocument).attach( + element, + new PanGestureRecognizer(), + ); + } + `); + expect(modules).toContain('GestureHost.ts'); + expect(modules).toContain('PanGestureRecognizer.ts'); + expect(modules).not.toContain('TapGestureRecognizer.ts'); + expect(modules).not.toContain('LongPressGestureRecognizer.ts'); + }); + + it('ships the pan only when a page constructs one', () => { + const modules = bundledModules(` + import {getGestureHost} from './GestureHost'; + import {TapGestureRecognizer} from './TapGestureRecognizer'; + export function mount(element: Element): () => void { + return getGestureHost(element.ownerDocument).attach( + element, + new TapGestureRecognizer(), + ); + } + `); + expect(modules).toContain('TapGestureRecognizer.ts'); + expect(modules).not.toContain('PanGestureRecognizer.ts'); + expect(modules).not.toContain('LongPressGestureRecognizer.ts'); + }); + + it('ships press tracking without the tap recognizer, which it only reads', () => { + const modules = bundledModules(` + import {getGestureHost} from './GestureHost'; + import {PressTracker} from './PressTracker'; + export function mount(element: Element): () => void { + const tracker = new PressTracker({ + getBounds: () => element.getBoundingClientRect(), + view: element, + }); + return getGestureHost(element.ownerDocument).attachConsumer( + element, + tracker, + ); + } + `); + expect(modules).toContain('PressTracker.ts'); + expect(modules).not.toContain('TapGestureRecognizer.ts'); + expect(modules).not.toContain('PanGestureRecognizer.ts'); + expect(modules).not.toContain('LongPressGestureRecognizer.ts'); + }); +}); diff --git a/packages/core/src/gestures/__tests__/recognizerInternals.test.ts b/packages/core/src/gestures/__tests__/recognizerInternals.test.ts new file mode 100644 index 0000000000000..84fb080627426 --- /dev/null +++ b/packages/core/src/gestures/__tests__/recognizerInternals.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file recognizerInternals.test.ts + * @input Uses vitest, TapGestureRecognizer and recognizerInternals + * @output Checks that a recognizer's arena plumbing is off its public type + * and reachable through internals() + * @position Test for packages/core/src/gestures/recognizerInternals.ts + * + * The `@ts-expect-error` lines are the assertion: `tsc` fails if any of + * these members is public again. + */ + +import {describe, expect, it} from 'vitest'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {internals} from '../recognizerInternals'; + +describe('recognizer internals', () => { + it('keeps the arena plumbing protected on the published class', () => { + const tap = new TapGestureRecognizer({name: 'tap'}); + function reachesPlumbing(): void { + // @ts-expect-error protected: the arena assigns it + tap.arena = null; + // @ts-expect-error protected + void tap.pending; + // @ts-expect-error protected + void tap.seenTouchIds; + // @ts-expect-error protected + void tap.acceptsTouchInput; + // @ts-expect-error protected + tap.requiresFailureOf(tap); + // @ts-expect-error protected + tap.allowsSimultaneous(tap); + // @ts-expect-error protected + tap.shouldBegin(); + // @ts-expect-error protected + tap.applyState('began'); + // @ts-expect-error protected + tap.resetToPossible(); + // @ts-expect-error protected + tap.deliverCancelled([], {time: 0, allTouches: []}); + } + expect(typeof reachesPlumbing).toBe('function'); + }); + + it('lets the gesture system reach them', () => { + const tap = new TapGestureRecognizer({name: 'tap'}); + expect(internals(tap).arena).toBeNull(); + expect(internals(tap).acceptsTouchInput).toBe(true); + expect(internals(tap).shouldBegin()).toBe(true); + }); +}); diff --git a/packages/core/src/gestures/__tests__/selectionLockReexport.test.ts b/packages/core/src/gestures/__tests__/selectionLockReexport.test.ts new file mode 100644 index 0000000000000..03affb76431f0 --- /dev/null +++ b/packages/core/src/gestures/__tests__/selectionLockReexport.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file selectionLockReexport.test.ts + * @input The selection lock's old gestures path and its utils module + * @output The transition re-export at gestures/selectionLock.ts hands out the + * same functions as utils/selectionLock.ts + * @position Unit test of packages/core/src/gestures/selectionLock.ts + */ + +import {describe, expect, it} from 'vitest'; +import * as atUtils from '../../utils/selectionLock'; +import * as atGestures from '../selectionLock'; + +describe('gestures/selectionLock, a transition re-export', () => { + it('hands out the lock from utils, so a caller importing the old path keeps working', () => { + expect(atGestures.lockSelection).toBe(atUtils.lockSelection); + expect(atGestures.locksSelectionFor).toBe(atUtils.locksSelectionFor); + }); +}); diff --git a/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx new file mode 100644 index 0000000000000..deff82ea60586 --- /dev/null +++ b/packages/core/src/gestures/__tests__/useFingerScroll.test.tsx @@ -0,0 +1,122 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useFingerScroll.test.tsx + * @input Uses vitest, Testing Library and touch-shaped events through the + * document's gesture host + * @output A finger's scroll reads true until the scroller's scrollend; a + * scroll with no finger down reads false; the page's own scroll through + * the document; the watch follows the ref's node and ends on unmount + * @position Unit tests of packages/core/src/gestures/useFingerScroll.ts + */ + +import {render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {getGestureHost} from '../GestureHost'; +import {useFingerScroll} from '../useFingerScroll'; +import {coarsePrimaryPointer} from './pointerQuery'; + +function touch(type: string, target: Element, down: boolean): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: 5, clientY: 5, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: down ? [entry] : []}); + target.dispatchEvent(event); +} + +let read: () => boolean = () => false; + +function List({other = false}: {other?: boolean}) { + const ref = useRef(null); + read = useFingerScroll(ref); + return other ? ( +
+

row

+
+ ) : ( +
+

row

+
+ ); +} + +function Page() { + read = useFingerScroll(document); + return

row

; +} + +// A phone: the finger-scroll watch listens only where the primary pointer is +// coarse. +let restorePointer: () => void; +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + restorePointer(); +}); + +afterEach(() => { + read = () => false; +}); + +describe('useFingerScroll', () => { + it('is internal: the gestures barrel exports neither the hook nor the watch', async () => { + const gestures: Record = await import('../index'); + expect(gestures).not.toHaveProperty('useFingerScroll'); + expect(gestures).not.toHaveProperty('isFingerScroll'); + expect(gestures).not.toHaveProperty('watchFingerScrolls'); + }); + + it("reads a finger's scroll until the scroller's scrollend, and the program's as false", () => { + const {getByTestId} = render(); + const list = getByTestId('list'); + list.dispatchEvent(new Event('scroll')); + expect(read()).toBe(false); + touch('touchstart', getByTestId('row'), true); + list.dispatchEvent(new Event('scroll')); + expect(read()).toBe(true); + // The lift starts the momentum: still the finger's. + touch('touchend', getByTestId('row'), false); + list.dispatchEvent(new Event('scroll')); + expect(read()).toBe(true); + list.dispatchEvent(new Event('scrollend')); + expect(read()).toBe(false); + }); + + it("reads a recognizer's own scroll as the program's, even with a finger resting inside", () => { + const {getByTestId} = render(); + const list = getByTestId('list'); + touch('touchstart', getByTestId('row'), true); + // A menu's edge autoscroll under the resting finger. + getGestureHost(document).ownScroll(list); + list.dispatchEvent(new Event('scroll')); + expect(read()).toBe(false); + touch('touchend', getByTestId('row'), false); + }); + + it('reads the page’s own scroll through the document', () => { + const {getByTestId} = render(); + touch('touchstart', getByTestId('row'), true); + document.dispatchEvent(new Event('scroll')); + expect(read()).toBe(true); + touch('touchend', getByTestId('row'), false); + document.dispatchEvent(new Event('scrollend')); + expect(read()).toBe(false); + }); + + it("follows the ref's node, and stops watching on unmount", () => { + const {getByTestId, rerender, unmount} = render(); + rerender(); + const list = getByTestId('list'); + expect(list.tagName).toBe('SECTION'); + touch('touchstart', getByTestId('row'), true); + list.dispatchEvent(new Event('scroll')); + expect(read()).toBe(true); + touch('touchend', getByTestId('row'), false); + list.dispatchEvent(new Event('scrollend')); + const reader = read; + unmount(); + expect(reader()).toBe(false); + }); +}); diff --git a/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx b/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx new file mode 100644 index 0000000000000..0c00c1a2c7c19 --- /dev/null +++ b/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx @@ -0,0 +1,1235 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useGestureRecognizer.test.tsx + * @input React components using useGestureRecognizer / usePressTracker, and + * touch events dispatched on their elements + * @output The hooks keep one recognizer per component, attach before the + * first touch, run the latest listener without rendering per move, and + * detach on unmount + * @position jsdom tests of packages/core/src/gestures/useGestureRecognizer.ts + */ + +import {act, render} from '@testing-library/react'; +import {StrictMode, useEffect, useRef} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import type {SelectionLock} from '../GestureRecognizer'; +import {getGestureHost} from '../GestureHost'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import type {ControlEvent} from '../PressTracker'; +import {lockSelection} from '../../utils/selectionLock'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {useGestureRecognizer} from '../useGestureRecognizer'; +import {usePressTracker} from '../usePressTracker'; +import {coarsePrimaryPointer, pointerQuery} from './pointerQuery'; + +function touch( + type: string, + target: EventTarget, + contact: {id: number; x: number; y: number}, + time: number, + isLift = false, +): void { + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = { + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [entry]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); +} + +let renders = 0; + +function Draggable({ + onPan, + onRecognizer, + isEnabled = true, +}: { + onPan: (recognizer: PanGestureRecognizer) => void; + onRecognizer?: (recognizer: PanGestureRecognizer) => void; + isEnabled?: boolean; +}) { + renders += 1; + const ref = useRef(null); + const pan = useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + {isEnabled}, + ); + onRecognizer?.(pan); + return
; +} + +// A phone: the host attaches only where the primary pointer is coarse. +let restorePointer: () => void = () => {}; +beforeEach(() => { + restorePointer = coarsePrimaryPointer(); +}); +afterEach(() => { + restorePointer(); +}); + +/** Wait for the next animation frame, where the host writes touch-action. */ +async function nextFrame(): Promise { + await act(async () => { + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + }); +} +describe('useGestureRecognizer', () => { + afterEach(() => { + renders = 0; + }); + + it('drives the latest listener with no render per move', () => { + const seen: string[] = []; + const {getByTestId} = render( + { + seen.push(`${recognizer.state}:${recognizer.translation().x}`); + }} + />, + ); + const surface = getByTestId('surface'); + expect(renders).toBe(1); + touch('touchstart', surface, {id: 1, x: 0, y: 0}, 0); + for (let x = 6; x <= 36; x += 6) { + touch('touchmove', surface, {id: 1, x, y: 0}, x * 2); + } + touch('touchend', surface, {id: 1, x: 36, y: 0}, 80, true); + // The 10 px hysteresis is left out of the translation, as in UIKit. + expect(seen).toEqual([ + 'began:2', + 'changed:8', + 'changed:14', + 'changed:20', + 'changed:26', + 'ended:26', + ]); + expect(renders).toBe(1); + }); + + it('keeps one recognizer across renders and reads the newest listener', () => { + const identities = new Set(); + const first = vi.fn(); + const second = vi.fn(); + const {getByTestId, rerender} = render( + identities.add(r)} />, + ); + rerender( + identities.add(r)} />, + ); + expect(identities.size).toBe(1); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 2, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 2, x: 30, y: 0}, 16); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + touch('touchend', surface, {id: 2, x: 30, y: 0}, 32, true); + }); + + it('cancels a gesture in flight when it is disabled, without calling the listener from the effect', () => { + const states: string[] = []; + // A listener of the recognizer's own, outside the hook, hears the cancel. + const heard: string[] = []; + let pan: PanGestureRecognizer | null = null; + const {getByTestId, rerender} = render( + states.push(r.state)} + onRecognizer={r => { + if (pan == null) { + pan = r; + r.addListener(current => heard.push(current.state)); + } + }} + />, + ); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 3, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 3, x: 30, y: 0}, 16); + act(() => { + rerender( + states.push(r.state)} isEnabled={false} />, + ); + }); + // Disabling cancels the gesture; its attachment then goes (FR24), which + // leaves the recognizer at rest. The hook reports nothing from the effect. + expect(heard).toContain('cancelled'); + expect(pan!.state).not.toBe('changed'); + expect(states).toEqual(['began']); + touch('touchend', surface, {id: 3, x: 30, y: 0}, 32, true); + expect(states).toEqual(['began']); + }); + + it('follows the element when the node behind the ref is replaced', () => { + const onPan = vi.fn(); + function Swappable({tag}: {tag: 'div' | 'section'}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + ); + const Tag = tag; + return ; + } + const {getByTestId, rerender} = render(); + const first = getByTestId('surface'); + rerender(); + const second = getByTestId('surface'); + expect(second).not.toBe(first); + touch('touchstart', first, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', first, {id: 6, x: 30, y: 0}, 16); + touch('touchend', first, {id: 6, x: 30, y: 0}, 32, true); + expect(onPan).not.toHaveBeenCalled(); + touch('touchstart', second, {id: 7, x: 0, y: 0}, 100); + touch('touchmove', second, {id: 7, x: 30, y: 0}, 116); + expect(onPan).toHaveBeenCalled(); + touch('touchend', second, {id: 7, x: 30, y: 0}, 132, true); + }); + + it('reports cancelled to a handler that disables the recognizer itself, from that handler', () => { + const states: string[] = []; + let pan: PanGestureRecognizer | null = null; + const {getByTestId} = render( + states.push(r.state)} + onRecognizer={r => { + pan = r; + }} + />, + ); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 6, x: 30, y: 0}, 16); + // A handler (here, the test) turns it off: UIKit's .cancelled, at once. + (pan as PanGestureRecognizer | null)?.setEnabled(false); + expect(states).toEqual(['began', 'cancelled']); + touch('touchend', surface, {id: 6, x: 30, y: 0}, 32, true); + }); + + it("attaches a recognizer that reads each event's pointer where the primary pointer is fine (spec:AST-057)", () => { + const pointer = pointerQuery(false); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const onPan = vi.fn(); + function Swipe() { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + {perEventPointer: true}, + ); + return
; + } + const {getByTestId, unmount} = render(); + const surface = getByTestId('surface'); + touch('touchstart', surface, {id: 9, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 9, x: 30, y: 0}, 16); + expect(onPan).toHaveBeenCalled(); + touch('touchend', surface, {id: 9, x: 30, y: 0}, 32, true); + unmount(); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + pointer.restore(); + } + }); + + it('reports a recognizer that required the disabled one to fail after the isEnabled Effect, not inside it, in its recognized state (react-component-runtime INV2)', async () => { + const states: string[] = []; + const onSingle = vi.fn((recognizer: TapGestureRecognizer) => { + states.push(recognizer.state); + }); + function Taps({isDoubleEnabled}: {isDoubleEnabled: boolean}) { + const ref = useRef(null); + const double = useGestureRecognizer( + ref, + () => new TapGestureRecognizer({numberOfTapsRequired: 2}), + undefined, + {isEnabled: isDoubleEnabled}, + ); + const single = useGestureRecognizer( + ref, + () => new TapGestureRecognizer(), + onSingle, + ); + useEffect(() => { + single.requireToFail(double); + }, [single, double]); + return
; + } + const {getByTestId, rerender} = render(); + const surface = getByTestId('taps'); + touch('touchstart', surface, {id: 1, x: 5, y: 5}, 0); + touch('touchend', surface, {id: 1, x: 5, y: 5}, 40, true); + // The single tap waits for the double tap to fail. + expect(onSingle).not.toHaveBeenCalled(); + // Turning the double tap off fails it in the Effect; the single tap + // waiting on it recognizes in a pass just after, so its caller hears it + // outside the Effect, reading the recognized state. + rerender(); + expect(onSingle).not.toHaveBeenCalled(); + await act(async () => { + await Promise.resolve(); + }); + expect(states).toEqual(['ended']); + }); + + it('reports a recognizer that required one a replaced node cancelled after that layout Effect, not inside it (react-component-runtime INV2)', async () => { + const states: string[] = []; + function Nested({innerKey}: {innerKey: string}) { + const outerRef = useRef(null); + const innerRef = useRef(null); + const double = useGestureRecognizer( + innerRef, + () => new TapGestureRecognizer({numberOfTapsRequired: 2}), + ); + const single = useGestureRecognizer( + outerRef, + () => new TapGestureRecognizer(), + recognizer => { + states.push(recognizer.state); + }, + ); + useEffect(() => { + single.requireToFail(double); + }, [single, double]); + return ( +
+
+
+ ); + } + const {getByTestId, rerender} = render(); + const inner = getByTestId('inner'); + touch('touchstart', inner, {id: 2, x: 5, y: 5}, 0); + touch('touchend', inner, {id: 2, x: 5, y: 5}, 40, true); + expect(states).toEqual([]); + // The double tap's node is replaced: detaching it cancels its wait in a + // layout Effect, and the outer single tap recognizes just after. + rerender(); + expect(states).toEqual([]); + await act(async () => { + await Promise.resolve(); + }); + expect(states).toEqual(['ended']); + }); + + it('attaches an action group, which the host serves under a fine pointer: a mouse press on one of its actions reaches the recognizer (family:action-groups FR5)', () => { + // A document whose host was never told the pointer is coarse (jsdom's + // frames have no matchMedia): a fine primary pointer. + const frame = document.createElement('iframe'); + document.body.append(frame); + const frameDocument = frame.contentDocument as Document; + const frameWindow = frameDocument.defaultView as Window & typeof globalThis; + const states: string[] = []; + function Group({actionGroup}: {actionGroup: boolean}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new TapGestureRecognizer(), + recognizer => { + states.push(recognizer.state); + }, + {actionGroup}, + ); + return ( +
+ +
+ ); + } + const press = (target: Element, pointerId: number, time: number) => { + for (const [type, buttons] of [ + ['pointerdown', 1], + ['pointerup', 0], + ] as const) { + const event = new frameWindow.PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId, + pointerType: 'mouse', + clientX: 5, + clientY: 5, + button: 0, + buttons, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + target.dispatchEvent(event); + } + }; + try { + const {getByTestId, rerender, unmount} = render( + , + {container: frameDocument.body, baseElement: frameDocument.body}, + ); + const action = getByTestId('action'); + // Not a group: under a fine pointer the host serves nothing. + press(action, 1, 0); + expect(states).toEqual([]); + act(() => { + rerender(); + }); + press(action, 2, 1000); + expect(states).toEqual(['ended']); + unmount(); + } finally { + frame.remove(); + } + }); + + it('attaches nothing while disabled: no touch-action, no gesture (FR24)', async () => { + const states: string[] = []; + const {getByTestId, rerender} = render( + states.push(r.state)} />, + ); + const surface = getByTestId('surface'); + // The host writes its touch-action in the next frame (FR18). + await nextFrame(); + const remove = vi.spyOn(surface.style, 'removeProperty'); + act(() => { + rerender( + states.push(r.state)} isEnabled={false} />, + ); + }); + // Disabling detaches: the host gives the element its touch-action back. + expect(remove).toHaveBeenCalledWith('touch-action'); + touch('touchstart', surface, {id: 6, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 6, x: 30, y: 0}, 16); + touch('touchend', surface, {id: 6, x: 30, y: 0}, 32, true); + expect(states).toEqual([]); + act(() => { + rerender( states.push(r.state)} />); + }); + touch('touchstart', surface, {id: 8, x: 0, y: 0}, 100); + touch('touchmove', surface, {id: 8, x: 30, y: 0}, 116); + expect(states).toContain('began'); + touch('touchend', surface, {id: 8, x: 30, y: 0}, 132, true); + }); + + it('follows the element behind the ref when it is replaced (INV6)', () => { + const states: string[] = []; + function Swapped({id}: {id: string}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + r => states.push(r.state), + ); + return
; + } + const {getByTestId, rerender} = render(); + act(() => { + rerender(); + }); + const second = getByTestId('second'); + touch('touchstart', second, {id: 9, x: 0, y: 0}, 0); + touch('touchmove', second, {id: 9, x: 30, y: 0}, 16); + expect(states).toContain('began'); + touch('touchend', second, {id: 9, x: 30, y: 0}, 32, true); + }); + + it('detaches on unmount', async () => { + const onPan = vi.fn(); + const {getByTestId, unmount} = render(); + const surface = getByTestId('surface'); + // The host writes its touch-action in the next frame (FR18). + await nextFrame(); + const remove = vi.spyOn(surface.style, 'removeProperty'); + unmount(); + expect(remove).toHaveBeenCalledWith('touch-action'); + touch('touchstart', surface, {id: 4, x: 0, y: 0}, 0); + touch('touchmove', surface, {id: 4, x: 30, y: 0}, 16); + expect(onPan).not.toHaveBeenCalled(); + }); +}); + +function Button({ + onEvent, + cancelTravel, + isBraking, + selectionLock, + isDisabled = false, +}: { + onEvent: (event: ControlEvent) => void; + cancelTravel?: number; + isBraking?: () => boolean; + selectionLock?: SelectionLock; + isDisabled?: boolean; +}) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => onEvent(event), + cancelTravel, + isBraking, + selectionLock, + isEnabled: () => !isDisabled, + }); + return ( + + ); +} + +describe('usePressTracker', () => { + it('gives the host no listener while the control is disabled (FR24)', () => { + // The host listens through the window's input stream (utils/inputStream). + const add = vi.spyOn(window, 'addEventListener'); + const remove = vi.spyOn(window, 'removeEventListener'); + const {rerender, unmount} = render( + + ); + } + function Pair({isDisabled}: {isDisabled: boolean}) { + return ( + <> + + ); + } + const {getByTestId, unmount} = render(); + touch('touchstart', getByTestId('held'), {id: 9, x: 1, y: 1}, 0); + const seenHighlights = [...highlights]; + const seenEvents = [...events]; + unmount(); + expect(highlights).toEqual(seenHighlights); + expect(events).toEqual(seenEvents); + }); + + it('attaches a per-event press tracker where the primary pointer is fine, which takes no mouse press there, beside one that stays inactive', () => { + const pointer = pointerQuery(false); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const held: ControlEvent[] = []; + const inactive: ControlEvent[] = []; + function Stepper({ + events, + perEventPointer, + testId, + }: { + events: ControlEvent[]; + perEventPointer: boolean; + testId: string; + }) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + perEventPointer, + }); + return ( + + ); + } + const {getByTestId} = render( + <> + + + , + ); + const mouse = (type: string, target: Element, id: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: id, + pointerType: 'mouse', + clientX: 5, + clientY: 5, + button: 0, + buttons: type === 'pointerdown' ? 1 : 0, + }); + target.dispatchEvent(event); + }; + const stepper = getByTestId('held'); + boundsOf(stepper, 44, 44); + mouse('pointerdown', stepper, 1); + mouse('pointerup', stepper, 1); + // Its press feedback is for a finger (spec:AST-094 FR19): a mouse + // keeps the desktop's handling. + expect(held).toEqual([]); + const other = getByTestId('other'); + boundsOf(other, 44, 44); + mouse('pointerdown', other, 2); + mouse('pointerup', other, 2); + expect(inactive).toEqual([]); + } finally { + warn.mockRestore(); + pointer.restore(); + } + }); + + it('passes isBraking through to the tracker', () => { + const events: ControlEvent[] = []; + const isBraking = vi.fn(() => true); + function BrakingButton() { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + isBraking, + }); + return ( + + ); + } + const {getByTestId} = render(); + const button = getByTestId('braking'); + touch('touchstart', button, {id: 8, x: 1, y: 1}, 0); + touch('touchend', button, {id: 8, x: 1, y: 1}, 50, true); + expect(isBraking).toHaveBeenCalled(); + expect(events).not.toContain('touchDown'); + }); + + it("uses the caller's scroll brake", () => { + const events: ControlEvent[] = []; + const {getByTestId} = render( +
+ ); + } + + it.each([ + ['alone', false], + ['with a single tap waiting on it', true], + ])( + 'ends a press that waits on an ancestor double tap after, not inside, the isEnabled Effect that turns it off (%s; react-component-runtime INV2)', + async (_event, hasSingle) => { + const events: (ControlEvent | 'highlight' | 'unhighlight')[] = []; + const {getByTestId, rerender} = render( + events.push(event)} + />, + ); + const button = getByTestId('press'); + touch('touchstart', button, {id: 21, x: 1, y: 1}, 0); + touch('touchend', button, {id: 21, x: 1, y: 1}, 40, true); + const before = [...events]; + // Turning the double tap off fails it in the Effect: whatever that + // arena pass resolves for the press reaches no caller callback. + rerender( + events.push(event)} + />, + ); + expect(events).toEqual(before); + // Just after it, the press ends and un-highlights: nothing is left + // highlighted. + await act(async () => { + await Promise.resolve(); + }); + expect(events.slice(before.length)).toEqual([ + 'touchUpInside', + 'unhighlight', + ]); + }, + ); + + it('tracks the element behind the ref when it is replaced (INV6)', () => { + const events: ControlEvent[] = []; + function Swapped({id}: {id: string}) { + const ref = useRef(null); + usePressTracker(ref, {onControlEvent: event => events.push(event)}); + return + ) : null; +} + +describe('usePressTracker follows its element and its options', () => { + it('tracks a node rendered after mount', () => { + const events: ControlEvent[] = []; + const {getByTestId, rerender} = render( + events.push(event)} />, + ); + rerender( events.push(event)} />); + const stepper = getByTestId('stepper'); + touch('touchstart', stepper, {id: 1, x: 2, y: 2}, 0); + touch('touchend', stepper, {id: 1, x: 2, y: 2}, 60, true); + expect(events).toContain('touchUpInside'); + }); + + it("uses the caller's brake", () => { + const events: ControlEvent[] = []; + const {getByTestId} = render( + true} + onEvent={event => events.push(event)} + />, + ); + const stepper = getByTestId('stepper'); + touch('touchstart', stepper, {id: 1, x: 2, y: 2}, 0); + touch('touchend', stepper, {id: 1, x: 2, y: 2}, 60, true); + expect(events).not.toContain('touchDown'); + expect(events).not.toContain('touchUpInside'); + }); + + it('takes a highlight delay changed after mount from the next touch', () => { + vi.useFakeTimers(); + const highlights: boolean[] = []; + const {getByTestId, rerender} = render( + highlights.push(isOn)} + />, + ); + rerender( + highlights.push(isOn)} + />, + ); + const stepper = getByTestId('stepper'); + touch('touchstart', stepper, {id: 1, x: 2, y: 2}, 0); + // 'always' waits the content-touch delay before highlighting. + expect(highlights).toEqual([]); + touch('touchend', stepper, {id: 1, x: 2, y: 2}, 400, true); + vi.useRealTimers(); + }); + + it('calls no callback from cleanup when it unmounts while highlighted', () => { + const highlights: boolean[] = []; + const {getByTestId, unmount} = render( + highlights.push(isOn)} + />, + ); + const stepper = getByTestId('stepper'); + touch('touchstart', stepper, {id: 1, x: 2, y: 2}, 0); + expect(highlights).toEqual([true]); + unmount(); + expect(highlights).toEqual([true]); + }); +}); + +function boundsOf(element: HTMLElement, width: number, height: number): void { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 0, + top: 0, + right: width, + bottom: height, + width, + height, + x: 0, + y: 0, + toJSON: () => ({}), + }), + }); +} + +function SwappedColumn({ + which, + freshRef, + onEvent, +}: { + which: 'a' | 'b'; + freshRef: boolean; + onEvent: (event: ControlEvent) => void; +}) { + const stableRef = useRef(null); + const fresh = {current: null as HTMLButtonElement | null}; + const ref = freshRef ? fresh : stableRef; + usePressTracker(ref, {onControlEvent: event => onEvent(event)}); + return which === 'a' ? ( + + ) : ( + + ); +} + +describe('usePressTracker hit-tests the element behind its ref now', () => { + for (const freshRef of [false, true]) { + it(`judges a lift against the swapped node's bounds (${freshRef ? 'a new ref object' : 'the same ref'})`, () => { + const events: ControlEvent[] = []; + const {getByTestId, rerender} = render( + events.push(event)} + />, + ); + rerender( + events.push(event)} + />, + ); + const b = getByTestId('b'); + boundsOf(b, 100, 40); + touch('touchstart', b, {id: 1, x: 10, y: 10}, 0); + // Far outside B, past the tracking expansion. + touch('touchmove', b, {id: 1, x: 400, y: 400}, 16); + touch('touchend', b, {id: 1, x: 400, y: 400}, 32, true); + expect(events).toContain('touchDown'); + expect(events).toContain('touchUpOutside'); + expect(events).not.toContain('touchUpInside'); + }); + } +}); + +function SwappedSurface({ + which, + onPan, +}: { + which: 'a' | 'b'; + onPan: (recognizer: PanGestureRecognizer) => void; +}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + onPan, + ); + return which === 'a' ? ( +
+ ) : ( +
+ ); +} + +describe('useGestureRecognizer follows a replaced node', () => { + for (const strict of [false, true]) { + it(`cancels a gesture in flight without calling the listener from the effect${strict ? ' (StrictMode)' : ''}`, () => { + const states: string[] = []; + const wrap = (node: React.ReactElement) => + strict ? {node} : node; + const {getByTestId, rerender} = render( + wrap( states.push(r.state)} />), + ); + const a = getByTestId('a'); + touch('touchstart', a, {id: 3, x: 0, y: 0}, 0); + touch('touchmove', a, {id: 3, x: 30, y: 0}, 16); + expect(states).toEqual(['began']); + rerender( + wrap( states.push(r.state)} />), + ); + // The replaced node's cancel comes from a layout effect: no callback. + expect(states).toEqual(['began']); + // The new node drives the same recognizer. + const b = getByTestId('b'); + touch('touchstart', b, {id: 4, x: 0, y: 0}, 100); + touch('touchmove', b, {id: 4, x: 30, y: 0}, 116); + expect(states).toEqual(['began', 'began']); + touch('touchend', b, {id: 4, x: 30, y: 0}, 132, true); + }); + } +}); + +describe('usePressTracker keeps its press through an option change', () => { + for (const strict of [false, true]) { + it(`a press in progress survives a rerender with new options${strict ? ' (StrictMode)' : ''}`, () => { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + function Held({cancelTravel}: {cancelTravel?: number}) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + onHighlightChange: isHighlighted => highlights.push(isHighlighted), + cancelTravel, + }); + return ( + + ); + } + const wrap = (node: React.ReactElement) => + strict ? {node} : node; + const {getByTestId, rerender} = render(wrap()); + const button = getByTestId('held'); + touch('touchstart', button, {id: 6, x: 1, y: 1}, 0); + expect(highlights).toEqual([true]); + rerender(wrap()); + // Still pressed: the tracker was not rebuilt or disposed. + expect(highlights).toEqual([true]); + touch('touchend', button, {id: 6, x: 1, y: 1}, 50, true); + expect(events).toEqual(['touchDown', 'touchUpInside']); + }); + } +}); + +describe('the hooks let go of an element removed while they stay mounted', () => { + it('useGestureRecognizer cancels its gesture without calling back, and works on a new element', () => { + const states: string[] = []; + function Maybe({shown}: {shown: boolean}) { + const ref = useRef(null); + useGestureRecognizer( + ref, + () => new PanGestureRecognizer({direction: 'horizontal'}), + r => states.push(r.state), + ); + return shown ?
: null; + } + const {getByTestId, rerender} = render(); + const first = getByTestId('maybe'); + touch('touchstart', first, {id: 11, x: 0, y: 0}, 0); + touch('touchmove', first, {id: 11, x: 30, y: 0}, 16); + expect(states).toEqual(['began']); + rerender(); + expect(states).toEqual(['began']); + rerender(); + const second = getByTestId('maybe'); + touch('touchstart', second, {id: 12, x: 0, y: 0}, 100); + touch('touchmove', second, {id: 12, x: 30, y: 0}, 116); + expect(states).toEqual(['began', 'began']); + touch('touchend', second, {id: 12, x: 30, y: 0}, 132, true); + }); + + it('usePressTracker ends its press without calling back, and presses a new element', () => { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + function Maybe({shown}: {shown: boolean}) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + onHighlightChange: isHighlighted => highlights.push(isHighlighted), + }); + return shown ? ( + + ) : null; + } + const {getByTestId, rerender} = render(); + touch('touchstart', getByTestId('maybe'), {id: 13, x: 1, y: 1}, 0); + expect(highlights).toEqual([true]); + rerender(); + expect(highlights).toEqual([true]); + expect(events).toEqual(['touchDown']); + rerender(); + const button = getByTestId('maybe'); + touch('touchstart', button, {id: 14, x: 1, y: 1}, 100); + touch('touchend', button, {id: 14, x: 1, y: 1}, 150, true); + expect(events).toEqual(['touchDown', 'touchDown', 'touchUpInside']); + }); +}); + +describe('usePressTracker reads trackingExpansion as it is now', () => { + it('a smaller expansion takes effect without rebuilding the tracker', () => { + const events: ControlEvent[] = []; + function Tracked({expansion}: {expansion: number}) { + const ref = useRef(null); + usePressTracker(ref, { + onControlEvent: event => events.push(event), + trackingExpansion: expansion, + }); + return ( + + ); + } + const {getByTestId, rerender} = render(); + const button = getByTestId('tracked'); + boundsOf(button, 100, 40); + touch('touchstart', button, {id: 15, x: 10, y: 10}, 0); + // 30 px below the bottom edge: inside a 70 px expansion. + touch('touchmove', button, {id: 15, x: 10, y: 70}, 16); + expect(events).not.toContain('touchDragExit'); + rerender(); + // The same point is outside a 10 px expansion, mid-press. + touch('touchmove', button, {id: 15, x: 10, y: 71}, 32); + expect(events).toContain('touchDragExit'); + touch('touchend', button, {id: 15, x: 10, y: 71}, 48, true); + expect(events).toContain('touchUpOutside'); + }); +}); diff --git a/packages/core/src/gestures/__tests__/watcherRecognizer.test.ts b/packages/core/src/gestures/__tests__/watcherRecognizer.test.ts new file mode 100644 index 0000000000000..06cf3b523ad9b --- /dev/null +++ b/packages/core/src/gestures/__tests__/watcherRecognizer.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file watcherRecognizer.test.ts + * @input Uses vitest, the arena harness, GestureHost and the pan recognizer + * @output Checks the `claimsTouches: false` watcher: it never claims or + * blocks a scroll, prevents nothing and is prevented by nothing, and + * follows a touch the browser takes for a scroll to the lift + * @position Test for GestureRecognizerOptions.claimsTouches + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../GestureHost'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import {createHarness, ManualClock, RecordingConsumer} from './harness'; +import {coarsePrimaryPointer} from './pointerQuery'; + +describe('a recognizer that never claims (a watcher)', () => { + let detach: (() => void)[] = []; + // A phone: the host attaches only where the primary pointer is coarse. + let restorePointer: () => void; + beforeEach(() => { + restorePointer = coarsePrimaryPointer(); + }); + afterEach(() => { + detach.forEach(release => release()); + detach = []; + document.body.innerHTML = ''; + restorePointer(); + }); + + it('runs alongside a claiming pan on the same touch, and claims nothing', () => { + const h = createHarness(); + const watcher = new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }); + const sheet = new PanGestureRecognizer({ + name: 'sheet', + direction: 'vertical', + }); + const finger = h + .finger(1) + .down(0, 100, 0, {recognizers: [sheet, watcher]}) + .move(0, 115, 16); + expect(watcher.state).toBe('began'); + expect(sheet.state).toBe('began'); + expect(h.claims).toEqual([1]); + finger.up(32); + }); + + it('cancels no press under it when it begins: a held control keeps its touch (spec:AST-094 FR15, FR19)', () => { + const h = createHarness(); + const watcher = new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }); + const press = new RecordingConsumer(); + const finger = h + .finger(1) + .down(0, 100, 0, {recognizers: [watcher], consumer: press}) + .move(0, 130, 16); + expect(watcher.state).toBe('began'); + expect(press.events).not.toContain('cancelled:1'); + finger.up(32); + expect(press.events).toContain('ended:1'); + }); + + it('follows a touch the browser took for a scroll until the lift', () => { + const h = createHarness(); + const watcher = new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }); + const finger = h + .finger(1) + .down(0, 100, 0, {recognizers: [watcher]}) + .move(0, 115, 16); + expect(watcher.state).toBe('began'); + finger.takenByBrowser(20); + finger.move(0, 160, 32); + expect(watcher.state).toBe('changed'); + finger.up(48); + expect(h.log).toContain('watcher:ended@48'); + }); + + it('gets only passive touchmove listeners from the host', () => { + document.body.innerHTML = '
'; + const page = document.getElementById('page') as HTMLElement; + const host = new GestureHost(document, {clock: new ManualClock()}); + const added = vi.spyOn(page, 'addEventListener'); + detach.push( + host.attach( + page, + new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }), + {manageStyle: false}, + ), + ); + const event = new Event('touchstart', {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: 10, clientY: 10, target: page}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: [entry]}); + page.dispatchEvent(event); + const blocking = added.mock.calls.filter( + ([type, , options]) => + type === 'touchmove' && + (options as AddEventListenerOptions | undefined)?.passive === false, + ); + expect(blocking).toHaveLength(0); + }); + + it('gets only passive target listeners for its touch, touchend included, while a tap on the same kind of touch gets a cancelable touchend (spec:AST-094 FR20)', () => { + document.body.innerHTML = '
'; + const page = document.getElementById('page') as HTMLElement; + const button = document.getElementById('button') as HTMLElement; + const host = new GestureHost(document, {clock: new ManualClock()}); + const onPage = vi.spyOn(page, 'addEventListener'); + const onButton = vi.spyOn(button, 'addEventListener'); + detach.push( + host.attach( + page, + new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }), + {manageStyle: false}, + ), + host.attach(button, new TapGestureRecognizer({name: 'tap'})), + ); + const touchDown = (target: HTMLElement, identifier: number) => { + const event = new Event('touchstart', {bubbles: true, cancelable: true}); + const entry = {identifier, clientX: 10, clientY: 10, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', {value: [entry]}); + target.dispatchEvent(event); + }; + const passivity = (spy: typeof onPage, type: string) => + spy.mock.calls + .filter(([listened]) => listened === type) + .map( + ([, , options]) => + (options as AddEventListenerOptions | undefined)?.passive, + ); + touchDown(page, 1); + expect(passivity(onPage, 'touchmove')).toEqual([true]); + expect(passivity(onPage, 'touchend')).toEqual([true]); + expect(passivity(onPage, 'touchcancel')).toEqual([true]); + touchDown(button, 2); + expect(passivity(onButton, 'touchend')).toEqual([false]); + }); + + it('alone, leaves the host only passive document and window listeners; a claiming recognizer brings the cancelable touchend, contextmenu and click (spec:AST-094 FR20)', () => { + document.body.innerHTML = '
'; + const page = document.getElementById('page') as HTMLElement; + const strip = document.getElementById('strip') as HTMLElement; + const host = new GestureHost(document, {clock: new ManualClock()}); + const active = () => { + const listed: string[] = []; + for (const [target, spy] of [ + ['document', onDocument], + ['window', onWindow], + ] as const) { + for (const [type, , options] of spy.mock.calls) { + if (typeof options === 'object' && options.passive === false) { + listed.push(`${target} ${type}`); + } + } + } + return listed; + }; + const onDocument = vi.spyOn(document, 'addEventListener'); + const onWindow = vi.spyOn(window, 'addEventListener'); + detach.push( + host.attach( + page, + new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }), + {manageStyle: false}, + ), + ); + expect(active()).toEqual([]); + detach.push( + host.attach( + strip, + new PanGestureRecognizer({name: 'claimer', direction: 'horizontal'}), + ), + ); + // At this layer every host listener comes through the shared input + // stream (utils/inputStream.ts), on the window's capture phase; clicks + // are the click guard's, whose listener comes only once a gesture acts. + expect(active().sort()).toEqual(['window contextmenu', 'window touchend']); + }); + + it('leaves the browser every touch behavior: its direction writes no touch-action', () => { + document.body.innerHTML = '
'; + const list = document.getElementById('list') as HTMLElement; + const row = document.getElementById('row') as HTMLElement; + const host = new GestureHost(document, {clock: new ManualClock()}); + const listStyle = vi.spyOn(list.style, 'setProperty'); + detach.push( + host.attach( + list, + new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }), + ), + host.attach( + row, + new PanGestureRecognizer({ + name: 'watcher', + direction: 'vertical', + claimsTouches: false, + }), + ), + // A claiming pan beside it on the same element keeps its own. + host.attach( + row, + new PanGestureRecognizer({name: 'swipe', direction: 'horizontal'}), + ), + ); + expect(list.hasAttribute('data-astryx-touch-action')).toBe(false); + expect(row.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(listStyle).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + }); +}); diff --git a/packages/core/src/gestures/fingerScroll.ts b/packages/core/src/gestures/fingerScroll.ts new file mode 100644 index 0000000000000..1e429e2a220da --- /dev/null +++ b/packages/core/src/gestures/fingerScroll.ts @@ -0,0 +1,208 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file fingerScroll.ts + * @input A scroller (an element, or the document for the page's own scroll), + * its document's scroll and scrollend events, and the gesture host's + * finger record + * @output Exports watchFingerScrolls and isFingerScroll + * @position Finger-scroll watch of packages/core/src/gestures (useFingerScroll + * reads it), in its own module so a page that never asks never ships it; + * internal to the touch module, not in the gestures barrel + * + * A watched scroller's scroll is a finger's from a scroll event while a + * finger that landed inside it is down, until the scroller's own `scrollend` + * (the momentum after the lift is still the finger's). A scroll that ends + * while its finger still rests there stays the finger's until the lift. A + * scroll the program starts while no finger is down there (`scrollTo`, a + * status-bar tap) is not. Where the browser sends no `scrollend`, a quiet + * spell of `SCROLL_END_FALLBACK_MS` after its last scroll event ends a + * finger's scroll instead. The host keeps the one finger record; this module + * adds only passive scroll listeners, and only while something is watched. + */ + +import {subscribeInput} from '../utils/inputStream'; +import {isCoarsePointer, watchCoarsePointer} from '../utils/coarsePointer'; +import type {GestureHost} from './GestureHost'; + +/** + * Where the browser sends no `scrollend`, how long a finger-driven scroller + * stays quiet (no scroll event) before its scroll counts as ended, in ms: + * longer than the gap between a momentum scroll's events, short enough that + * a program's scroll right after it is not read as the finger's. + */ +const SCROLL_END_FALLBACK_MS = 150; + +interface Watch { + /** Watched scrollers, and how many watchers each has. */ + readonly scrollers: Map; + /** Watched scrollers whose current scroll is a finger's. */ + readonly fingerScrollers: Set; + /** Finger scrolls whose `scrollend` came while the finger still rested. */ + readonly endedWhileDown: Set; + stop: (() => void) | null; +} + +const watches = new WeakMap(); + +function start(host: GestureHost, watch: Watch): () => void { + const doc = host.document; + const scrollerOf = (event: Event): Node | null => { + const target = event.target === doc ? doc : event.target; + return target instanceof Node ? target : null; + }; + // A finger's scroll ends at the scroller's scrollend, or, where the + // browser sends none, after a quiet spell. + const endScroll = (scroller: Node): void => { + if (!watch.fingerScrollers.has(scroller)) { + return; + } + if (host.isFingerDownIn(scroller)) { + // The finger still rests there: the scroll is its until the lift. + watch.endedWhileDown.add(scroller); + } else { + watch.fingerScrollers.delete(scroller); + } + }; + const scrollEndFallbacks = new Map>(); + const clearScrollEndFallback = (scroller: Node): void => { + const timer = scrollEndFallbacks.get(scroller); + if (timer != null) { + clearTimeout(timer); + scrollEndFallbacks.delete(scroller); + } + }; + const hasScrollEnd = (): boolean => { + const view = doc.defaultView; + return view != null && 'onscrollend' in view; + }; + const onScroll = (event: Event): void => { + const scroller = scrollerOf(event); + // A recognizer's own scroll (a menu's edge autoscroll) is never the + // finger's, even with a finger resting inside. + if ( + scroller == null || + !watch.scrollers.has(scroller) || + host.isOwnScroll(scroller) + ) { + return; + } + watch.endedWhileDown.delete(scroller); + if (host.isFingerDownIn(scroller)) { + watch.fingerScrollers.add(scroller); + } + if (watch.fingerScrollers.has(scroller) && !hasScrollEnd()) { + clearScrollEndFallback(scroller); + scrollEndFallbacks.set( + scroller, + setTimeout(() => { + scrollEndFallbacks.delete(scroller); + endScroll(scroller); + }, SCROLL_END_FALLBACK_MS), + ); + } + }; + const onScrollEnd = (event: Event): void => { + const scroller = scrollerOf(event); + if (scroller != null) { + endScroll(scroller); + } + }; + // A scroll that ended while its finger rested ends with the lift. + const onFingers = (): void => { + for (const scroller of watch.endedWhileDown) { + if (!host.isFingerDownIn(scroller)) { + watch.endedWhileDown.delete(scroller); + watch.fingerScrollers.delete(scroller); + } + } + }; + // Its listeners are on only while the primary pointer is coarse + // (`spec:AST-094` FR19, FR24): elsewhere no finger scrolls, as far as the + // touch presentation is concerned. Passive, on the window's capture phase + // through the shared input stream. + const view = doc.defaultView; + let unsubscribers: (() => void)[] = []; + const listen = (on: boolean): void => { + if (view == null || on === unsubscribers.length > 0) { + return; + } + if (on) { + unsubscribers = [ + subscribeInput(view, 'scroll', onScroll), + subscribeInput(view, 'scrollend', onScrollEnd), + ]; + } else { + for (const stop of unsubscribers.splice(0)) { + stop(); + } + for (const scroller of [...scrollEndFallbacks.keys()]) { + clearScrollEndFallback(scroller); + } + watch.fingerScrollers.clear(); + watch.endedWhileDown.clear(); + } + }; + const stopWatchingPointer = watchCoarsePointer(view, listen); + listen(isCoarsePointer(view)); + const unsubscribe = host.onFingersChange(onFingers); + const release = host.retain(); + return () => { + stopWatchingPointer(); + listen(false); + unsubscribe(); + release(); + }; +} + +/** + * Keep track of whether the scrolls of `scroller` (an element in `host`'s + * document, or the document itself) are a finger's, for isFingerScroll, + * until the returned function runs. + * Internal: not exported from the package. + */ +export function watchFingerScrolls( + host: GestureHost, + scroller: Node, +): () => void { + let watch = watches.get(host); + if (watch == null) { + watch = { + scrollers: new Map(), + fingerScrollers: new Set(), + endedWhileDown: new Set(), + stop: null, + }; + watches.set(host, watch); + } + const owned = watch; + owned.scrollers.set(scroller, (owned.scrollers.get(scroller) ?? 0) + 1); + owned.stop ??= start(host, owned); + let isWatching = true; + return () => { + if (!isWatching) { + return; + } + isWatching = false; + const count = (owned.scrollers.get(scroller) ?? 1) - 1; + if (count > 0) { + owned.scrollers.set(scroller, count); + return; + } + owned.scrollers.delete(scroller); + owned.fingerScrollers.delete(scroller); + owned.endedWhileDown.delete(scroller); + if (owned.scrollers.size === 0) { + owned.stop?.(); + owned.stop = null; + } + }; +} + +/** + * Whether the scroll now moving a watched `scroller` is a finger's. + * Internal: not exported from the package. + */ +export function isFingerScroll(host: GestureHost, scroller: Node): boolean { + return watches.get(host)?.fingerScrollers.has(scroller) === true; +} diff --git a/packages/core/src/gestures/hapticSwitch.ts b/packages/core/src/gestures/hapticSwitch.ts new file mode 100644 index 0000000000000..ccd8416897160 --- /dev/null +++ b/packages/core/src/gestures/hapticSwitch.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file hapticSwitch.ts + * @input utils/hapticSwitch.ts + * @output Re-exports HAPTIC_SWITCH_ATTRIBUTE + * @position Kept for importers written before the attribute moved to + * utils/hapticSwitch.ts, where Core's own modules read it + * @deprecated Import HAPTIC_SWITCH_ATTRIBUTE from utils/hapticSwitch.ts. + */ + +export {HAPTIC_SWITCH_ATTRIBUTE} from '../utils/hapticSwitch'; diff --git a/packages/core/src/gestures/haptics.ts b/packages/core/src/gestures/haptics.ts new file mode 100644 index 0000000000000..7fc54d7b3b1b4 --- /dev/null +++ b/packages/core/src/gestures/haptics.ts @@ -0,0 +1,212 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file haptics.ts + * @input A feedback kind, or a label element a real finger will tap + * @output Exports playHaptic (vibration where the platform has it), + * attachTapHaptic (iOS Safari's tap-moment light impact) and the support + * probes + * @position Haptics of packages/core/src/gestures; the kinds and the + * vibration patterns live in the constants table (motion/uikit/gestures.ts) + * + * The web has one haptics API, `navigator.vibrate`, and iOS Safari does not + * implement it. iOS Safari does play a haptic in exactly one place: when a + * REAL tap toggles an ``, by landing on the + * switch or on a `