diff --git a/.changeset/bottom-sheet-catch-no-selection.md b/.changeset/bottom-sheet-catch-no-selection.md new file mode 100644 index 0000000000000..6c3359fdf33cd --- /dev/null +++ b/.changeset/bottom-sheet-catch-no-selection.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A finger that catches a settling BottomSheet and holds it no longer starts selecting the sheet's text. Where a long press fires `selectstart`, the sheet cancels it and changes no style, so nothing repaints; on iOS, whose long press fires none, selection and the touch callout are off on the sheet while a finger holds or drags it. + +@vjeux diff --git a/.changeset/bottom-sheet-drag-restore-height.md b/.changeset/bottom-sheet-drag-restore-height.md new file mode 100644 index 0000000000000..b61e85d826663 --- /dev/null +++ b/.changeset/bottom-sheet-drag-restore-height.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A BottomSheet dragged up from a detent stays under the finger while it restores its full height, instead of drawing lower for the frames before that render lands. + +@vjeux diff --git a/.changeset/bottom-sheet-entrance-hold.md b/.changeset/bottom-sheet-entrance-hold.md new file mode 100644 index 0000000000000..8789958c5de95 --- /dev/null +++ b/.changeset/bottom-sheet-entrance-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under a finger, a BottomSheet takes no tap or drag while it enters, and a BottomSheetSwitcher's sheets now don't either; keys and assistive technology stay live. + +@vjeux diff --git a/.changeset/bottom-sheet-uikit-dim.md b/.changeset/bottom-sheet-uikit-dim.md new file mode 100644 index 0000000000000..9f1102754a00d --- /dev/null +++ b/.changeset/bottom-sheet-uikit-dim.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under a finger, on every touch device, a BottomSheet's scrim follows the sheet the way an iOS sheet's dim does. Dragged below its shortest detent, the dim thins in a straight line with the sheet's position and clears only when the sheet reaches the bottom of the screen; it used to clear 40 % of the shortest detent's height below it. A standalone sheet's dim follows the finger at once instead of easing behind it over the theme's medium duration (410 ms by default). When the sheet settles or is flung away, the dim rides the same spring frame for frame, on the compositor. Under a mouse the dim is unchanged. A peek detent's lighter scrim is unchanged. + +@vjeux diff --git a/.changeset/bottom-sheet-uikit-present.md b/.changeset/bottom-sheet-uikit-present.md new file mode 100644 index 0000000000000..ebf813a8c6a1c --- /dev/null +++ b/.changeset/bottom-sheet-uikit-present.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A BottomSheet opened or closed under a finger, on every touch device, moves the way an iOS sheet does: on iOS's sheet spring, from below the screen to its detent and back, with the scrim fading on the same spring, on the compositor so a busy page doesn't stall it. Under reduced motion it fades in and out in place, in 200 ms or less, instead of sliding. While it presents, touches are held and a close waits for it to land, as on iOS. Under a mouse its entrance and exit are unchanged. + +@vjeux diff --git a/.changeset/bottom-sheet-uikit-release.md b/.changeset/bottom-sheet-uikit-release.md new file mode 100644 index 0000000000000..1237c567d7ccb --- /dev/null +++ b/.changeset/bottom-sheet-uikit-release.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A BottomSheet let go under a finger moves the way an iOS sheet does, on every touch device. It settles on the stop nearest where its speed carries it, a quick flick going at least to the next stop, with gone below the screen counting as a stop (one moving back toward where the drag began returns there), and on iOS's own release spring, starting at the finger's speed instead of from a standstill; under reduced motion it fades in place instead. Under a mouse a release keeps today's rule and the theme's duration and easing, on the same release speed as a finger's: a mouse held still for more than 40 ms before the release no longer throws the sheet. Everywhere, the settle runs on the compositor, so a busy page no longer stalls it, and a finger can catch the sheet while it settles, on iOS too. The finger's speed is read from when each touch happened rather than when the page got to it. + +@vjeux diff --git a/.changeset/bottom-sheet-uikit-tracking.md b/.changeset/bottom-sheet-uikit-tracking.md new file mode 100644 index 0000000000000..d2a43ab441086 --- /dev/null +++ b/.changeset/bottom-sheet-uikit-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A BottomSheet drag follows the finger the way an iOS sheet does. The sheet stays exactly under the finger between detents, instead of being pulled toward a nearby detent in the last 40 px; the release picks where it lands. Pulled up past its tallest detent, it stretches by UIKit's rubber band, no farther than the space it reserves below. This applies to a finger or a pen where the primary pointer is coarse; a mouse, and any drag where the primary pointer is fine, is unchanged: it still eases onto a nearby detent and stretches a little past the tallest one. + +@vjeux 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..35dbb9e8e9ad6 --- /dev/null +++ b/.changeset/gesture-arena-moving-cancel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a claimed gesture cancelled while one of its fingers is moving keeps that moving 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..ea6ee44bda14a --- /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. The host follows the primary pointer when it changes; what it attaches under a fine pointer is in the every-pointer changeset (`gestures-every-pointer.md`). 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/gestures-every-pointer.md b/.changeset/gestures-every-pointer.md new file mode 100644 index 0000000000000..205a9cde31590 --- /dev/null +++ b/.changeset/gestures-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gestures attach for every pointer, a mouse, a pen and a finger alike (AST-094 FR43). Under a fine primary pointer an element that uses a gesture keeps one passive `pointerdown` listener (a recognizer that claims a moving finger also keeps its blocking `touchmove`, and a kept press gets its own only during a press), the page's listeners last only a press and go at a window blur, and a hovering mouse or pen reaches a hover recognizer through two passive listeners on its element. A pen's barrel button, like a right click, starts no press. A right click or a ctrl-click on macOS starts no press. A finger's or a pen's hold that does something keeps text unselected under any primary pointer, a touchscreen laptop's included, while a mouse keeps its released behavior (FR41). A mouse or pen drag that a recognizer claims takes no text selection from then on, anywhere on the page, and a finger is read once where the browser sends Touch Events without exposing `ontouchstart`. A press tracker takes a mouse press as a press, painting at once with no scroll brake. The screen-edge pan is a finger's only: it stays off under a mouse, a trackpad and a pen, and takes no `allowedPointerTypes`, and `perEventPointer` is accepted and does nothing. 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/layer-input-hold.md b/.changeset/layer-input-hold.md new file mode 100644 index 0000000000000..cd00243f77ff0 --- /dev/null +++ b/.changeset/layer-input-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act, and a pinch still zooms the page); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates, and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point. + +@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/motion-interactive-transition.md b/.changeset/motion-interactive-transition.md new file mode 100644 index 0000000000000..a87d10fbef2d5 --- /dev/null +++ b/.changeset/motion-interactive-transition.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] BottomSheet's releases, entrance, exit and dim run on one interactive-transition driver inside core's motion foundation, with no change in how they look or move. Its rules decide a release once, from where it projects (spec:AST-094), on the release velocity of spec:AST-100's tracker. Nothing is exported. + +@vjeux diff --git a/.changeset/motion-morph-path.md b/.changeset/motion-morph-path.md new file mode 100644 index 0000000000000..0d0e272fecc67 --- /dev/null +++ b/.changeset/motion-morph-path.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rect morph's paths, the geometry touch presentations share when they grow out of a source rect and shrink back into it. `lerpPath` (a straight line between two rects) and `edgeGrowPath` (UIKit's popover, growing out of a point on its edge) name the rect an element shows at each progress, and say how an interrupted motion continues in their own law; `morphCurves` turns a path into compositor curves (`translate`, `scale` and a companion `opacity`). Not exported from the package entry point; no component uses them yet. + +@vjeux diff --git a/.changeset/motion-progress.md b/.changeset/motion-progress.md new file mode 100644 index 0000000000000..d7d9d896ba1ff --- /dev/null +++ b/.changeset/motion-progress.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: one progress module for the motion foundation. `progressCurve` gives a spring's progress from 0 to 1 as a closed-form curve, `mapProgress` maps any curve from another (a companion's dim following a sheet, a clamped range), `mapRange` is the clamped linear map they share, and `presentedProgressVelocity` reads the velocity an interrupted motion continues from. One velocity unit throughout: progress per second. Nothing public changes; no component imports it yet. + +@vjeux diff --git a/.changeset/motion-rect-morph.md b/.changeset/motion-rect-morph.md new file mode 100644 index 0000000000000..f4e9c4b63b444 --- /dev/null +++ b/.changeset/motion-rect-morph.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rect morph, one way for a touch presentation to grow out of a source rect and shrink back into it. `createRectMorph` presents an element along a path (`lerpPath`, `edgeGrowPath`) and dismisses it back on a copy or on itself, each continuing the other from the rect on screen with its velocity, with Reduced Motion left to the animator. It runs on new progress helpers (`progressCurve`, `mapProgress`, `mapRange`, `presentedProgressVelocity`). Not exported from the package entry point; no component uses it yet. + +@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-sheet-release-rule.md b/.changeset/uikit-sheet-release-rule.md new file mode 100644 index 0000000000000..d71634effe33d --- /dev/null +++ b/.changeset/uikit-sheet-release-rule.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the rule a BottomSheet release will follow, measured from UIKit on iOS 26.2, as a pure function. It picks the stop nearest the sheet's offset plus its velocity × 0.1 s (below 250 pt/s, the stop nearest its offset), counting gone below the screen as a stop; from 1008 pt/s a flick goes at least to the next stop in its direction, on the 0.8-damped release spring. A release moving back toward where its drag began returns to the detent nearest that start, and never dismisses. It lands where UIKit did on 149 of 164 measured releases; the other 15 moved back toward their start, where UIKit settled 14 on the stop nearest them and flicked one to large. 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/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 41244a1b58789..e687e20dbd5b4 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -468,7 +468,11 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and own record. Keyboard and assistive-technology selection are unchanged. A pen's hold acts as a finger's; a mouse makes the context request by a right click, not a long press (FR43), and otherwise keeps its released behavior - (`spec:AST-017` FR59; DEC-9). + (`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims + takes no new text selection for that drag only. A claim made after the + drag has moved clears the page's selection; a press claimed at its own + pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its + released behavior. Guidance: a caller whose menu element carries text a person may want offers a Copy action in its menu. @@ -656,7 +660,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and | FR31–FR33 | Browser tests and storyboards under a finger | a menu, a Popover and a ContextMenu lift: open, close, a pan outside, a tap outside, a pinch over the surface; a mouse | a surface that does not grow from and shrink into its trigger, a pan outside that leaves a menu open, a Popover whose dismissal changed, a pinch blocked other than by the author's `touch-action`, a tap outside that reaches the page, or a changed mouse path fails | | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse; a tap and a drag during an alert's, a sheet's, and a dialog's entrance; a key and a screen reader during an entrance; a pinch during an alert's and a sheet's entrance; a tap and a scroll on the page beside an entering non-modal sheet, which pass | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path, a held key or assistive-technology activation, a hidden content tree, or a tap or drag during an entrance that closes the presentation, scrolls a held page, moves focus or reaches anything beneath, fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | -| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | +| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request; a mouse drag a recognizer claims after it moves, and a click on an action group's button with text selected | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, a change to native selection elsewhere, to keyboard or assistive-technology selection, or to mouse selection outside a drag a recognizer claims, or a claimed press that clears the page's selection before it moves fails | | FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails | | FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 4c67256a261b7..d220707793809 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -8,11 +8,14 @@ unpublished.** Specs live next to their component as a phone-sized touch screen (402 × 714 CSS px page, 402 × 874 screen, 3 device pixels per CSS px, touch, mobile viewport rules), in Chromium and WebKit. +The exemplar to copy is +[`BottomSheetDetent.interaction.browser.spec.ts`](../../packages/core/src/BottomSheet/__tests__/BottomSheetDetent.interaction.browser.spec.ts). 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 @@ -20,7 +23,7 @@ check an instrument against a known answer. pnpm storybook:build # the specs read stories from the built Storybook pnpm exec playwright install chromium webkit pnpm test:interaction --project chromium # everything, Chromium -pnpm test:interaction # one spec, both engines +pnpm test:interaction BottomSheetDetent # one spec, both engines pnpm storyboards # only the tests tagged @storyboard ``` @@ -34,6 +37,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 +50,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 +75,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 +156,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 @@ -154,9 +173,50 @@ not moved. The proof reports `stepPx` and `pxPerFrame` and notes motion under 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 two-core runner the software compositor and the screencast share the core +the block spins on, so every compositor animation presents fewer frames there: +Lightbox's double-tap zoom, pinned to two cores, missed a display-rate bound in +7 of 10 runs while it moved on every frame the compositor presented. Judge such +a proof against a control instead of a rate: `startBusyControl(page)` shows a +small `CONTROL_RGB` box in the top layer (a manual popover, so above a modal +dialog opened before it; `raise()` after one opened later) whose transform +moves it along the top edge for as long as the page runs. In WebKit a popover +above an open modal dialog takes the dialog's `::backdrop` animation off the +compositor, so a proof of a backdrop mounts the control inside the dialog with +`startBusyControl(page, {within: dialog})`, a plain fixed box with no top-layer +entry. Track it from the +same screencast (`trackMarker(page, frames, {rgb: CONTROL_RGB})`) and pass it +as `control`: the component passes when it moved on every frame the control +moved and its longest stall is no longer than the control's. Both are read from +the same screencast frames, so a compositor animation misses none. A window in +which the control moved on fewer than four frames fails, since the machine +presented too little to judge. A run of frames with no new position counts as +moved when the change that ends it is a single cast step: the component moves +under one step a frame there, as on an easing tail, which the cast cannot show +on every frame. The run must also read as a slowing curve, at most one frame +longer than the component's run before it. A freeze ends in a catch-up of more +than one step, a stall in a frame-counted animation is longer than its runs +before it, and a run that no change ends stays a miss. The component still has +to show a new position on at least four of the control's frames. The cast step (`stepPx`) +is read from the value's changes in the judged part, so a position the page +commits off the grid once the block ends does not shrink it. +A main-thread animation still fails: the control moves and it does not. + +An animation that lasts a fraction of a second, such as a 0.2 s fade, leaves a +block only a few frames to judge: in WebKit, or on a loaded machine, the control +can present fewer than four, and one run cannot decide. Play it several times +and judge the runs pooled (`pooledBusyProof(runs)`, each run with its own +tracked frames, control and window): the control must move on at least four +frames over all the runs, the component on every one of them, and in each run +its longest stall may be no longer than that run's control's, since a stall +belongs to its run. + 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 +225,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 +269,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 +293,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 @@ -210,3 +305,26 @@ simulator. Skip such a proof on the driver's flag, citing the reason: The WebKit input command is reached through Playwright's in-process server object, so it needs a locally launched browser (the default), not `connect()`. Firefox has no touch input path and no project. + +WebKit's touches carry their real dispatch time, so a velocity reaches the +page only as well as the dispatch keeps pace. `play` sends each event at its +scripted time, and never sooner after the one before it than the script spaces +them: a stall stretches one gap instead of sending the events behind it +together. With `{checkPacing: true}`, `play` then reads the `timeStamp`s the +page received and throws `TouchPacingError` where they decide a release: an +event missing, either of the last two move intervals (what a release velocity +reads) more than `TOUCH_PACING_TOLERANCE_MS` (4 ms) off the script, or the +lift `TOUCH_LIFT_STILLNESS_MS` (40 ms) or more after the last move. A +gesture's first moves and its lift reach the page with its own handling time +(a burst after `touchstart`, a lift a frame late), and no release reads them. +On one worker of a loaded machine, 440 gaps over 40 flicks moved at most 4 ms; +under eight workers, 4% of flicks had a gap further off, up to 21 ms. Wrap a +proof whose outcome follows a velocity in +`retakeOnTouchPacing(testInfo, attempt)`: +`attempt` builds the state, plays with `checkPacing` and asserts, and runs once +more only on a `TouchPacingError`, listed as a `touch-retake` annotation; a +second one fails the test with that cause. + +A tap that must click uses `driver.tap` in both engines. WebKit synthesises no +click from a scripted touch sequence, so a proof that needs that click skips +with `test.skip(!driver.synthesizesClicks, SCRIPTED_CLICK_SKIP)`. 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.control.test.ts b/internal/interaction-harness/src/busy.control.test.ts new file mode 100644 index 0000000000000..4f619ddd7dd47 --- /dev/null +++ b/internal/interaction-harness/src/busy.control.test.ts @@ -0,0 +1,437 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file busy.control.test.ts + * @input Synthetic tracked frames (no browser) + * @output The busy-main-thread verdict against a control: a component that + * moves on the control's frames passes through a stall the compositor itself + * makes, and a frozen one, or one the control outpaces by a single frame, + * still fails. Unchanged frames under one cast step a frame count as + * moved; a freeze, whose catch-up is more than one step, and a stall + * longer than the component's runs before it do not. Runs + * of a block too short to judge alone are judged pooled: the control over + * all of them, each run's stall against that run's control. + * @position Unit test of ./busy's control judgment; the frames are + * busy.test.ts's fixtures, and the browser proof with a real compositor + * control is in harness.interaction.browser.spec.ts. + */ + +import {describe, expect, it} from 'vitest'; +import {busyWindowProof, pooledBusyProof} from './busy'; +import type {TrackedFrame} from './frames'; + +const FRAME = 1000 / 60; +const BLOCK = {from: 1000, to: 1700}; + +/** A marker moving 3 px per frame from t = 800 to 1900, minus `dropped` frame indices. */ +function moving(dropped: readonly number[] = []): TrackedFrame[] { + const frames: TrackedFrame[] = []; + for (let i = 0; 800 + i * FRAME <= 1900; i += 1) { + if (dropped.includes(i)) { + continue; + } + frames.push({ + t: 800 + i * FRAME, + box: {x: 0, y: 3 * i, width: 10, height: 10}, + } as TrackedFrame); + } + return frames; +} + +/** Index of the first frame at or after `t`. */ +const at = (t: number) => Math.ceil((t - 800) / FRAME); + +/** `moving()` with each frame in `repeats` presenting the previous frame's position. */ +function repeating(repeats: readonly number[]): TrackedFrame[] { + return moving().map((f, i) => + repeats.includes(i) + ? ({...f, box: {...f.box!, y: 3 * (i - 1)}} as TrackedFrame) + : f, + ); +} + +/** `frames` as the control's: the same frames and times, moving along x. */ +function asControl(frames: readonly TrackedFrame[]): TrackedFrame[] { + return frames.map( + f => + ({ + ...f, + box: f.box == null ? null : {x: f.box.y, y: 0, width: 12, height: 12}, + }) as TrackedFrame, + ); +} + +/** + * A frame-counted loop: `step` px a frame, standing still from `lag` ms into + * each window to its end, then going on without catching up. + */ +function frameCounted( + windows: readonly {from: number; to: number}[], + lag: number, + step = 1, +): TrackedFrame[] { + let y = 0; + return moving().map((f, i) => { + if (i > 0 && !windows.some(w => f.t >= w.from + lag && f.t < w.to)) { + y += step; + } + return {...f, box: {...f.box!, y}} as TrackedFrame; + }); +} + +describe('busyWindowProof against a control', () => { + it('passes a component that moves on every frame the control does, through a stall the compositor makes for both, which fails without the control', () => { + // Three frames in a row presented nothing, for the control too: the + // compositor shared the core the block spins on (a two-core runner). + const gap = [at(1300), at(1300) + 1, at(1300) + 2]; + const component = moving(gap); + const control = asControl(moving(gap)); + const alone = busyWindowProof(component, BLOCK); + expect(alone.verdict.failures.join('\n')).toMatch(/longest stall/); + const against = busyWindowProof(component, BLOCK, {control}); + expect(against.verdict.failures, against.summary).toEqual([]); + expect(against.control).toMatchObject({moved: against.control?.movedWith}); + expect(against.summary).toMatch( + /against the control: moved on \d+ of its \d+ frames/, + ); + }); + + it('still fails a frozen component while the control moves', () => { + const frozen = moving().map( + f => + ({ + ...f, + box: { + ...f.box!, + y: f.t >= BLOCK.from ? 3 * at(BLOCK.from) : f.box!.y, + }, + }) as TrackedFrame, + ); + const proof = busyWindowProof(frozen, BLOCK, { + control: asControl(moving()), + }); + expect(proof.verdict.pass).toBe(false); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on 0 of the \d+ frames the control moved/, + ); + expect(proof.verdict.failures.join('\n')).toMatch( + /longer than the control's/, + ); + }); + + it('fails a component the control outpaces: it repeats positions on frames the control moves', () => { + const repeats = [0, 1, 2, 3].map(k => at(1100) + 6 * k); + const proof = busyWindowProof(repeating(repeats), BLOCK, { + control: asControl(moving()), + }); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved/, + ); + }); + + it('fails a component that misses a single frame the control moved', () => { + // One repeated position: a frame the control moved on and the component + // did not, which a compositor animation never shows, since both are read + // from the same screencast frames. + const proof = busyWindowProof(repeating([at(1300)]), BLOCK, { + control: asControl(moving()), + }); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved/, + ); + expect(proof.control!.moved - proof.control!.movedWith).toBe(1); + }); + + it('counts unchanged frames on a tail under one cast step a frame as moved: each run of them ends in a single step', () => { + // 3 px a frame, then half a pixel a frame from 1400 ms: the whole-pixel + // positions change every other frame, one step at a time. + const k = at(1400); + const tail = moving().map((f, i) => + i < k + ? f + : ({ + ...f, + box: {...f.box!, y: 3 * k + Math.floor((i - k) / 2)}, + } as TrackedFrame), + ); + const proof = busyWindowProof(tail, BLOCK, {control: asControl(moving())}); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.control?.belowStep).toBeGreaterThan(0); + expect(proof.summary).toMatch(/under one cast step a frame/); + }); + + it('still fails a slow component frozen on frames the control moved on: the change that ends the run catches up more than one step', () => { + // Half a pixel a frame throughout, frozen for 12 frames from 1200 ms, then + // back where its clock says: the change that ends the run is 6 steps. + const k = at(1200); + const slow = moving().map( + (f, i) => + ({ + ...f, + box: { + ...f.box!, + y: Math.floor((i >= k && i < k + 12 ? k - 1 : i) / 2), + }, + }) as TrackedFrame, + ); + const proof = busyWindowProof(slow, BLOCK, {control: asControl(moving())}); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved/, + ); + }); + + it('still fails a component that stops for good while the control moves: no change ends its run', () => { + const k = at(1300); + const stopped = moving().map((f, i) => + i < k ? f : ({...f, box: {...f.box!, y: 3 * k}} as TrackedFrame), + ); + const proof = busyWindowProof(stopped, BLOCK, { + control: asControl(moving()), + }); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved/, + ); + }); + + it('still fails a frame-counted component that stands still through the block and resumes one step a frame: it shows too little motion to judge', () => { + // 1 px a frame, counted per frame rather than by the clock: it stands + // still from the block's start to its end, then goes on one step a + // frame, so the change that ends the run is a single step. + const s0 = at(BLOCK.from); + const s1 = at(BLOCK.to); + const counted = moving().map( + (f, i) => + ({ + ...f, + box: { + ...f.box!, + y: i < s0 ? i : i <= s1 ? s0 - 1 : s0 - 1 + (i - s1), + }, + }) as TrackedFrame, + ); + const proof = busyWindowProof(counted, BLOCK, { + control: asControl(moving()), + }); + expect(proof.verdict.failures.join('\n')).toMatch( + /too little motion in this window to judge/, + ); + }); + + it('still fails a frame-counted loop that stalls for a few frames inside the block: its run is longer than the runs before it', () => { + // 1 px a frame, so each step is one cast step, standing still for three + // frames from 1300 ms and going on from there without catching up. + const proof = busyWindowProof( + frameCounted([{from: 1300, to: 1350}], 0), + BLOCK, + {control: asControl(moving())}, + ); + expect(proof.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved/, + ); + expect(proof.control?.belowStep).toBe(0); + }); + + it('reads the finest cast step when the judged part shows no change, so a larger jump never passes for one step', () => { + const frozen = moving().map( + f => + ({...f, box: {...f.box!, y: f.t < BLOCK.to ? 0 : 2}}) as TrackedFrame, + ); + const proof = busyWindowProof(frozen, BLOCK, { + control: asControl(moving()), + }); + expect(proof.stepPx).toBe(0.5); + expect(proof.control?.belowStep).toBe(0); + }); + + it('reads the cast step from changes, so a position committed off the grid once the block ends does not shrink it', () => { + // An ease-out on a 2 px grid, as WebKit casts one, that ends on 401 once + // the block is over: its tail shows runs of unchanged frames that end in + // a single 2 px step. + const eased = moving().map( + (f, i) => + ({ + ...f, + box: { + ...f.box!, + y: + f.t > BLOCK.to + 20 + ? 401 + : 2 * Math.round((400 - 400 * 0.93 ** i) / 2), + }, + }) as TrackedFrame, + ); + const proof = busyWindowProof(eased, BLOCK, { + control: asControl(moving()), + }); + expect(proof.stepPx).toBe(2); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.control?.belowStep).toBeGreaterThan(0); + }); + + it('fails a window in which the control itself presented too little to judge', () => { + const still = asControl( + moving().map(f => ({...f, box: {...f.box!, y: 0}}) as TrackedFrame), + ); + const proof = busyWindowProof(moving(), BLOCK, {control: still}); + expect(proof.verdict.failures.join('\n')).toMatch( + /the control moved on 0 frames in the judged part/, + ); + }); +}); + +/** A 90 ms block from `from`: its judged part holds three 60 Hz frames. */ +const short = (from: number) => ({from, to: from + 90}); + +describe('pooledBusyProof', () => { + it('judges a block too short for one run over pooled runs: one run cannot decide, three runs of a component that moves with the control pass', () => { + const control = asControl(moving()); + const single = busyWindowProof(moving(), short(1000), {control}); + expect(single.verdict.failures.join('\n')).toMatch( + /the control moved on 3 frames in the judged part, under 4/, + ); + const runs = [1000, 1300, 1600].map(from => ({ + tracked: moving(), + control, + window: short(from), + })); + const pooled = pooledBusyProof(runs); + expect(pooled.verdict.failures, pooled.summary).toEqual([]); + expect(pooled.control).toEqual({moved: 9, movedWith: 9}); + expect(pooled.runs).toHaveLength(3); + expect(pooled.summary).toMatch( + /^3 runs pooled: moved on 9 of the control's 9 frames; run 1: /, + ); + }); + + it('still fails a component that is frozen in every run while the control moves', () => { + const frozen = moving().map( + f => ({...f, box: {...f.box!, y: 0}}) as TrackedFrame, + ); + const pooled = pooledBusyProof( + [1000, 1300, 1600].map(from => ({ + tracked: frozen, + control: asControl(moving()), + window: short(from), + })), + ); + expect(pooled.verdict.pass).toBe(false); + expect(pooled.verdict.failures.join('\n')).toMatch( + /moved on 0 of the 9 frames the control moved over 3 runs/, + ); + expect(pooled.verdict.failures.join('\n')).toMatch( + /run 1: longest stall .* longer than the control's/, + ); + }); + + it("judges each run's longest stall against that run's control, never pooled", () => { + // Two frames in a row present no new position, inside run 2 only. + const k = at(1320); + const stalled = moving().map((f, i) => + i === k || i === k + 1 + ? ({...f, box: {...f.box!, y: 3 * (k - 1)}} as TrackedFrame) + : f, + ); + const pooled = pooledBusyProof( + [1000, 1300, 1600].map(from => ({ + tracked: stalled, + control: asControl(moving()), + window: short(from), + })), + ); + const failures = pooled.verdict.failures.join('\n'); + expect(failures).toMatch( + /run 2: longest stall .* longer than the control's/, + ); + expect(failures).not.toMatch(/run [13]: longest stall/); + }); + + it('fails a pool in which the component misses a single frame the control moved', () => { + const pooled = pooledBusyProof( + [1000, 1300, 1600].map(from => ({ + tracked: repeating([at(1320)]), + control: asControl(moving()), + window: short(from), + })), + ); + expect(pooled.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved over 3 runs/, + ); + expect(pooled.control.moved - pooled.control.movedWith).toBe(1); + }); + + it("counts a pooled run's unchanged frames under one cast step a frame as moved, as one window does", () => { + const k = at(1000); + const tail = moving().map((f, i) => + i < k + ? f + : ({ + ...f, + box: {...f.box!, y: 3 * k + Math.floor((i - k) / 2)}, + } as TrackedFrame), + ); + const pooled = pooledBusyProof( + [1000, 1300, 1600].map(from => ({ + tracked: tail, + control: asControl(moving()), + window: short(from), + })), + ); + expect(pooled.verdict.failures, pooled.summary).toEqual([]); + }); + + it('still fails a pooled frame-counted loop that the cast shows moving early in each judged part', () => { + // Six 70 ms blocks; the cast shows the loop's last frames up to 35 ms into + // each, then it stands still to the block's end and goes on one step a + // frame: a 1 px loop's steps are one cast step, and a 2 px loop's read + // as one when its changes are all 2 px. + const windows = [1000, 1150, 1300, 1450, 1600, 1750].map(from => ({ + from, + to: from + 70, + })); + for (const step of [1, 2]) { + const pooled = pooledBusyProof( + windows.map(window => ({ + tracked: frameCounted(windows, 35, step), + control: asControl(moving()), + window, + })), + ); + expect(pooled.verdict.failures.join('\n'), `${step} px`).toMatch( + /moved on \d+ of the \d+ frames the control moved over 6 runs/, + ); + } + }); + + it('judges the control over the pool too: runs in which it presented too little fail', () => { + const still = asControl( + moving().map(f => ({...f, box: {...f.box!, y: 0}}) as TrackedFrame), + ); + const pooled = pooledBusyProof( + [1000, 1300].map(from => ({ + tracked: moving(), + control: still, + window: short(from), + })), + ); + expect(pooled.verdict.failures.join('\n')).toMatch( + /the control moved on 0 frames over 2 runs, under 4/, + ); + }); + + it('judges one long run as busyWindowProof judges it against the control', () => { + const control = asControl(moving()); + const pooled = pooledBusyProof([ + {tracked: moving(), control, window: BLOCK}, + ]); + const single = busyWindowProof(moving(), BLOCK, {control}); + expect(pooled.verdict).toEqual(single.verdict); + expect(pooled.control).toEqual({ + moved: single.control?.moved, + movedWith: single.control?.movedWith, + }); + }); + + it('throws on no runs', () => { + expect(() => pooledBusyProof([])).toThrow(/no runs/); + }); +}); 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..44e2b9581d798 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -4,8 +4,11 @@ * @file busy.ts * @input Uses a Playwright `Page` * @output `blockMainThread` — a synchronous busy loop on the page's main thread - * that reports exactly when it ran — and `busyWindowProof`, which reads frame - * evidence for that window. + * that reports exactly when it ran —, `startBusyControl` — a plain + * compositor-only control animation recorded in the same window —, + * `busyWindowProof`, which reads frame evidence for that window, against + * the control when it has one, and `pooledBusyProof`, which judges several + * short blocks as one. * @position The busy-main-thread proof: an animation that runs on the * compositor keeps advancing at display rate while JavaScript holds the main * thread; one driven from the main thread (rAF, style writes, layout @@ -18,6 +21,135 @@ import type {Locator, Page} from '@playwright/test'; import {frameStats, type FrameStats, type TrackedFrame} from './frames'; +/** The control's colour, apart from the component's marker (`MARKER_RGB`). */ +export const CONTROL_RGB: readonly [number, number, number] = [0, 255, 255]; + +/** The control box's side, and how far it travels along the top edge, CSS px. */ +const CONTROL_SIDE = 12; +const CONTROL_TRAVEL = 240; + +/** + * Starts a plain compositor-only control animation: a `CONTROL_RGB` box in the + * page's top-left corner whose transform moves it back and forth along the top + * edge (`CONTROL_TRAVEL` px every 400 ms, linear) for as long as the page + * runs. It shows what the compositor presented in a busy window on this + * machine, so a proof can judge a component against it rather than against a + * frame rate: track it from the same screencast (`trackMarker(page, frames, + * {rgb: CONTROL_RGB})`) and pass it to `busyWindowProof` as `control`. + * + * The box is a manual popover, so it shows in the top layer, above a modal + * dialog the page opened before it; `raise()` shows it again on top of one + * opened after. With `within`, it is mounted inside that element instead (an + * open modal dialog), as a plain fixed box with no top-layer entry: in WebKit + * a popover shown above an open modal dialog takes the dialog's `::backdrop` + * animation off the compositor, so a proof of a dialog's backdrop mounts its + * control inside the dialog. Start it before the block: it resolves once the + * animation is running and past its first frame (on the compositor; see + * `blockMainThread`). Keep the component's marker out of the strip it crosses + * (the top `CONTROL_SIDE` px, `CONTROL_SIDE + CONTROL_TRAVEL` px wide). + */ +export async function startBusyControl( + page: Page, + { + rgb = CONTROL_RGB, + within, + }: {rgb?: readonly [number, number, number]; within?: Locator} = {}, +): Promise<{ + readonly raise: () => Promise; + readonly remove: () => Promise; +}> { + if (within != null) { + await within.evaluate(host => + host.setAttribute('data-harness-control-host', ''), + ); + } + await page.evaluate( + ({color, side, travel}) => + new Promise((resolve, reject) => { + const host = document.querySelector('[data-harness-control-host]'); + host?.removeAttribute('data-harness-control-host'); + const box = document.createElement('div'); + box.setAttribute('data-harness-control', ''); + box.style.cssText = `position:fixed;inset:auto;left:0;top:0;margin:0;padding:0;border:0;overflow:visible;width:${side}px;height:${side}px;background:rgb(${color.join(',')});z-index:2147483647;pointer-events:none;`; + if (host == null) { + box.setAttribute('popover', 'manual'); + document.body.appendChild(box); + box.showPopover(); + } else { + host.appendChild(box); + } + const animation = box.animate( + [ + {transform: 'translateX(0px)'}, + {transform: `translateX(${travel}px)`}, + ], + { + duration: 400, + iterations: Infinity, + direction: 'alternate', + easing: 'linear', + }, + ); + // The harness's own waits run on the real timers (see blockMainThread). + const gate = ( + window as unknown as { + __harnessTimerGate?: {frame?: typeof requestAnimationFrame}; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); + const deadline = performance.now() + 5_000; + const tick = () => { + if (!animation.pending && Number(animation.currentTime ?? 0) >= 16) { + resolve(); + } else if (performance.now() > deadline) { + reject(new Error('startBusyControl: the control did not start')); + } else { + frame(tick); + } + }; + frame(tick); + }), + {color: [...rgb], side: CONTROL_SIDE, travel: CONTROL_TRAVEL}, + ); + return { + raise: () => + page.evaluate(() => { + for (const box of document.querySelectorAll( + '[data-harness-control][popover]', + )) { + box.hidePopover(); + box.showPopover(); + } + }), + remove: () => + page.evaluate(() => { + for (const box of document.querySelectorAll('[data-harness-control]')) { + box.remove(); + } + }), + }; +} + +/** What the control presented in the judged part (see `busyWindowProof`). */ +export interface BusyControlRead { + /** Frames in the judged part that presented a new control position. */ + readonly moved: number; + /** Of those, the frames that also presented a new component position. */ + readonly movedWith: number; + /** The control's longest time without a new position, the edges included. */ + readonly longestStallMs: number; + /** + * Of `movedWith`, the frames counted as moved because the component's motion + * there was under one cast step per frame (see `busyWindowProof`). + */ + readonly belowStep: number; + /** + * The component's longest time without a new position, read the same way: + * a run of unchanged frames under one cast step per frame is motion. + */ + readonly componentStallMs: number; +} + export interface BusyWindow { /** Epoch ms when the loop started and ended, on the page's own clock. */ readonly from: number; @@ -67,6 +199,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; @@ -147,7 +281,8 @@ export interface BusyProof extends FrameStats { readonly onScreenFromMs: number | null; /** * The cast's position step, CSS px: the largest of 0.5, 1 and 2 that every - * tracked position is a multiple of (WebKit's casts are 2: see the README). + * change of the tracked value in the judged part is a multiple of, or 0.5 + * when it does not change there (WebKit's casts step 2: see the README). */ readonly stepPx: number; /** The tracked value's mean motion per screencast interval in the judged part, px. */ @@ -204,6 +339,8 @@ export interface BusyProof extends FrameStats { * frames were delivered. */ readonly movedFraction: number; + /** With a `control`: what it presented in the judged part; null without one. */ + readonly control: BusyControlRead | null; /** The judgment against the limits (see `busyWindowProof`). */ readonly verdict: BusyVerdict; /** One line with the raw numbers, for an annotation or a failure message. */ @@ -252,14 +389,38 @@ 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 * that opens or closes the judged part, or holds a delivered frame that did * not move, is judged from the screencast as before, so a frozen element * fails whatever else the compositor draws. + * + * With a `control` (`startBusyControl`'s box, tracked from the same + * screencast), the window is judged against what the compositor presented in + * it rather than against a frame rate: on a two-core runner the software + * compositor and the screencast share the core the block spins on, so every + * compositor animation presents fewer frames there, the control too. The + * component passes when: + * - the control moved on at least `minJudgedFrames` frames of the judged part + * (fewer: the machine presented too little to judge, which fails); + * - the component moved on every frame the control moved: both are read from + * the same screencast frames, so a compositor animation misses none; + * - its longest stall is no longer than the control's. + * A run of frames with no new component position counts as moved when the + * change that ends it is a single cast step (`stepPx`): the component moves + * under one step per frame there, as on an easing tail, which the cast cannot + * show on every frame. The run also has to read as a slowing curve, at most + * one frame longer than the component's run before it. A freeze ends in a + * catch-up of more than one step, a stall in a frame-counted animation is + * longer than its runs before it, and a run that no change ends stays a miss. + * The component still has to show a new position on at least + * `minJudgedFrames` of the control's frames. + * A main-thread animation still fails: the control moves and it does not. + * `maxMedianFrameMs`, when given, still applies; `minMovedFraction` and the + * absolute stall, frame and motion limits do not. */ export function busyWindowProof( tracked: readonly TrackedFrame[], @@ -274,6 +435,7 @@ export function busyWindowProof( minMovedFraction = 0.9, minJudgedFrames = 4, presented, + control, }: { axis?: 'x' | 'y' | 'width' | 'height'; marginMs?: number; @@ -310,10 +472,15 @@ 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[]; + /** + * The control's frames (`startBusyControl`, tracked from the same + * screencast with `CONTROL_RGB`): judge the component against them. + */ + control?: readonly TrackedFrame[]; } = {}, ): BusyProof { let end = window.to - marginMs; @@ -477,12 +644,29 @@ export function busyWindowProof( ? expectedFrames : deliveredFrames; const movedFraction = (moved + recorderSkipped) / Math.max(1, judgedFrames); - // The cast's position step, and the motion per interval it has to resolve. - const positions = tracked.flatMap(f => (f.box == null ? [] : [f.box[axis]])); + // The cast's position step for the tracked value: the largest of 2, 1 and + // 0.5 CSS px that every change between its consecutive positions in the + // judged part is a multiple of. Changes rather than positions: a position + // the page commits once the block ends can sit off that grid. + const partValues = tracked.flatMap(f => + f.box != null && f.t >= judged.from && f.t <= judged.to + ? [f.box[axis]] + : [], + ); + const stepChanges = partValues + .slice(1) + .map((p, i) => Math.abs(p - partValues[i])) + .filter(d => d > 0.25); + // With no change to read, the finest step: a coarser one would let a larger + // jump pass for a single step. const stepPx = - [2, 1, 0.5].find(step => - positions.every(p => Math.abs(p / step - Math.round(p / step)) < 0.01), - ) ?? 0.5; + stepChanges.length === 0 + ? 0.5 + : ([2, 1, 0.5].find(step => + stepChanges.every( + d => Math.abs(d / step - Math.round(d / step)) < 0.01, + ), + ) ?? 0.5); const judgedValues = tracked.flatMap(f => f.box != null && f.t >= judged.from && f.t <= judged.to ? [{t: f.t, value: f.box[axis]}] @@ -510,20 +694,123 @@ export function busyWindowProof( `${pxPerFrame.toFixed(1)} px/frame is under two of the cast's ${stepPx} px position steps: a frame delivered early can show no step and read as not moved`, ); } + // The control's own new positions in the judged part, against its frame + // before the part, and of those the frames the component moved on too: + // both are tracked from the same screencast frames, so their times match. + let controlRead: BusyControlRead | null = null; + if (control != null) { + const baseline = [...control] + .reverse() + .find(f => f.box != null && f.t < inner.from); + let last: number | null = baseline?.box == null ? null : baseline.box.x; + const controlChanges: number[] = []; + for (const f of control) { + if (f.box == null || f.t < inner.from || f.t > judged.to) { + continue; + } + if (last != null && Math.abs(f.box.x - last) > 0.25) { + if (f.t > judged.from || onScreenFrom == null) { + controlChanges.push(f.t); + } + } + last = f.box.x; + } + const controlEdges = [judged.from, ...controlChanges, judged.to]; + // A frame with no new component position, in a run of such frames whose + // next change is a single cast step: the component moves under one step + // per frame there, which the cast cannot show on every frame, so the + // frame counts as moved. A freeze ends in a catch-up of more than one + // step, and a run that no change ends stays a miss. + const series = tracked.flatMap(f => + f.box == null ? [] : [{t: f.t, value: f.box[axis]}], + ); + const seriesIndex = new Map(series.map((f, i) => [f.t, i])); + const same = (a: number, b: number) => + Math.abs(series[a].value - series[b].value) <= 0.25; + // The first frame of the run of equal positions that holds frame i. + const runStart = (i: number): number => { + let s = i; + while (s > 0 && same(s - 1, i)) { + s -= 1; + } + return s; + }; + const underStep = (t: number): boolean => { + const i = seriesIndex.get(t); + if (i == null || changed.has(t)) { + return false; + } + const start = runStart(i); + let k = i + 1; + while (k < series.length && same(k, i)) { + k += 1; + } + if ( + k >= series.length || + Math.abs(series[k].value - series[i].value) >= 1.5 * stepPx + ) { + return false; + } + // A slowing curve lengthens its runs a frame at a time: a run more than + // one frame longer than the one before it is a stall. + const unchanged = k - 1 - start; + const before = start > 0 ? start - 1 - runStart(start - 1) : 0; + return unchanged <= before + 1; + }; + const componentEdges = [ + judged.from, + ...delivered.filter(t => changed.has(t) || underStep(t)), + judged.to, + ]; + controlRead = { + moved: controlChanges.length, + movedWith: controlChanges.filter(t => changed.has(t) || underStep(t)) + .length, + belowStep: controlChanges.filter(underStep).length, + longestStallMs: Math.max( + ...controlEdges.slice(1).map((t, i) => t - controlEdges[i]), + ), + componentStallMs: Math.max( + ...componentEdges.slice(1).map((t, i) => t - componentEdges[i]), + ), + }; + } const failures: string[] = []; - if (stats.frames === 0) { + if (stats.frames === 0 && controlRead == null) { failures.push('no frame presented in the judged part'); } else if (stats.medianFrameMs > maxMedianFrameMs) { failures.push( `median frame interval ${stats.medianFrameMs.toFixed(1)} ms > ${maxMedianFrameMs} ms (${stats.frames} frames presented)`, ); } - if (longestStallFrames > maxStallFrames) { + if (controlRead != null) { + if (controlRead.moved < minJudgedFrames) { + failures.push( + `the control moved on ${controlRead.moved} frames in the judged part, under ${minJudgedFrames}: the compositor presented too little in this window to judge`, + ); + } + const visible = controlRead.movedWith - controlRead.belowStep; + if (visible < minJudgedFrames) { + failures.push( + `the component showed a new position on ${visible} of the control's frames, under ${minJudgedFrames}: too little motion in this window to judge`, + ); + } + if (controlRead.movedWith < controlRead.moved) { + failures.push( + `moved on ${controlRead.movedWith} of the ${controlRead.moved} frames the control moved (${((controlRead.movedWith / Math.max(1, controlRead.moved)) * 100).toFixed(0)}%)`, + ); + } + if (controlRead.componentStallMs > controlRead.longestStallMs) { + failures.push( + `longest stall ${controlRead.componentStallMs.toFixed(1)} ms, longer than the control's ${controlRead.longestStallMs.toFixed(1)} ms`, + ); + } + } else if (longestStallFrames > maxStallFrames) { failures.push( `longest stall ${longestStallMs.toFixed(1)} ms = ${longestStallFrames} intervals > ${maxStallFrames}`, ); } - if (expectedFrames < minJudgedFrames) { + if (controlRead == null && expectedFrames < minJudgedFrames) { failures.push( `${(judged.to - judged.from).toFixed(0)} ms judged = ${expectedFrames} frames < ${minJudgedFrames}: the animation under test was not moving during the block`, ); @@ -532,7 +819,10 @@ export function busyWindowProof( 1, Math.floor((1 - minMovedFraction) * judgedFrames + 1e-9), ); - if (judgedFrames - moved - recorderSkipped > allowedMisses) { + if ( + controlRead == null && + judgedFrames - moved - recorderSkipped > allowedMisses + ) { failures.push( `${moved} of ${judgedFrames} expected frames moved${recorderSkipped > 0 ? ` (and ${recorderSkipped} the screencast skipped)` : ''} (${(movedFraction * 100).toFixed(0)}% < ${(minMovedFraction * 100).toFixed(0)}%)`, ); @@ -552,6 +842,9 @@ export function busyWindowProof( (onScreenFromMs == null ? '' : `, on screen from +${onScreenFromMs.toFixed(0)} ms (nothing moved before the block)`) + + (controlRead == null + ? '' + : `; against the control: moved on ${controlRead.movedWith} of its ${controlRead.moved} frames${controlRead.belowStep > 0 ? ` (${controlRead.belowStep} under one cast step a frame)` : ''}, its longest stall ${controlRead.longestStallMs.toFixed(1)} ms`) + notes.map(note => `; note: ${note}`).join(''); return { ...stats, @@ -571,6 +864,109 @@ export function busyWindowProof( longestStallFrames, moved, movedFraction, + control: controlRead, + verdict: {pass: failures.length === 0, failures}, + summary, + }; +} + +/** One run of a pooled busy proof (see `pooledBusyProof`). */ +export interface BusyRun { + /** The component's frames, tracked from the run's screencast. */ + readonly tracked: readonly TrackedFrame[]; + /** The control's frames (`startBusyControl`), from the same screencast. */ + readonly control: readonly TrackedFrame[]; + /** The run's block (`blockMainThread`). */ + readonly window: BusyWindow; +} + +export interface PooledBusyProof { + /** + * Each run judged on its own against its control: read its numbers, not its + * verdict (one short run can hold too few frames to judge). + */ + readonly runs: readonly BusyProof[]; + /** + * The control's new positions over every run's judged part, and those the + * component moved on too. + */ + readonly control: {readonly moved: number; readonly movedWith: number}; + /** The judgment over the pool (see `pooledBusyProof`). */ + readonly verdict: BusyVerdict; + /** One line with the pooled numbers, then each run's summary. */ + readonly summary: string; +} + +/** + * A busy proof over several runs of the same short animation, against the + * control. An animation that lasts a fraction of a second leaves a block only + * a few frames to judge: in WebKit, or on a loaded machine, the control can + * present fewer than `minJudgedFrames` in one run, and one run cannot decide. + * Pooled, the runs are judged as one long window is: + * - the control moved on at least `minJudgedFrames` frames over all the runs; + * - the component moved on every one of those frames; + * - in each run, its longest stall is no longer than that run's control's: a + * stall belongs to its run and is never pooled. + * `maxMedianFrameMs`, when given, applies to each run. A main-thread + * animation still fails: in every run the control moves and it does not. + */ +export function pooledBusyProof( + runs: readonly BusyRun[], + options: Omit< + NonNullable[2]>, + 'control' | 'minMovedFraction' + > = {}, +): PooledBusyProof { + if (runs.length === 0) { + throw new Error('pooledBusyProof: no runs to judge'); + } + const {minJudgedFrames = 4, maxMedianFrameMs = Infinity} = options; + const proofs = runs.map(run => + busyWindowProof(run.tracked, run.window, { + ...options, + control: run.control, + }), + ); + const reads = proofs.map(proof => proof.control as BusyControlRead); + const moved = reads.reduce((sum, read) => sum + read.moved, 0); + const movedWith = reads.reduce((sum, read) => sum + read.movedWith, 0); + const failures: string[] = []; + if (moved < minJudgedFrames) { + failures.push( + `the control moved on ${moved} frames over ${runs.length} runs, under ${minJudgedFrames}: the compositor presented too little in these windows to judge`, + ); + } + const visible = + movedWith - reads.reduce((sum, read) => sum + read.belowStep, 0); + if (visible < minJudgedFrames) { + failures.push( + `the component showed a new position on ${visible} of the control's frames over ${runs.length} runs, under ${minJudgedFrames}: too little motion in these windows to judge`, + ); + } + if (movedWith < moved) { + failures.push( + `moved on ${movedWith} of the ${moved} frames the control moved over ${runs.length} runs (${((movedWith / Math.max(1, moved)) * 100).toFixed(0)}%)`, + ); + } + proofs.forEach((proof, i) => { + if (proof.frames > 0 && proof.medianFrameMs > maxMedianFrameMs) { + failures.push( + `run ${i + 1}: median frame interval ${proof.medianFrameMs.toFixed(1)} ms > ${maxMedianFrameMs} ms`, + ); + } + const read = reads[i]; + if (read.componentStallMs > read.longestStallMs) { + failures.push( + `run ${i + 1}: longest stall ${read.componentStallMs.toFixed(1)} ms, longer than the control's ${read.longestStallMs.toFixed(1)} ms`, + ); + } + }); + const summary = + `${runs.length} runs pooled: moved on ${movedWith} of the control's ${moved} frames` + + proofs.map((proof, i) => `; run ${i + 1}: ${proof.summary}`).join(''); + return { + runs: proofs, + control: {moved, movedWith}, verdict: {pass: failures.length === 0, failures}, summary, }; 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..8b96f18a1437c 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,24 +23,35 @@ * 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 type {CastFrame, TrackedFrame} from './frames'; import { animationStoryboard, + castStoryboard, blockMainThread, busyWindowProof, compareCurve, + countBetween, cubicBezier, + CONTROL_RGB, curveOf, drag, flick, @@ -43,30 +59,53 @@ import { freezeTimers, holdAnimations, installInputLog, + installLongTaskLog, installTimerGate, markElement, merge, parseTransform, PARTIAL_LIFT_SKIP, pinch, + pooledBusyProof, readInputLog, + readLongTasks, releaseAnimations, sampleCurve, + sampleRects, seekAnimations, springStep, + startCompositorTrace, startScreencast, + startBusyControl, Storyboard, tap, thawTimers, timeline, touchDriver, touchStoryboard, + traceRendering, trackMarker, waitForAnimation, waitForRest, watchAnimations, withFinger, } from './index'; +import {toTouchEvents} from './scripts'; +import { + SCRIPTED_CLICK_SKIP, + TOUCH_PACING_TOLERANCE_MS, + TouchPacingError, +} from './touch'; + +// 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 = `
`; +/** + * The red channel at a CSS-px point in each cast frame (a 16 px square's mean), + * as a tracked value on x: what a backdrop's dim shows there. Decoded in a + * scratch page, as `trackMarker` does. + */ +async function brightnessAt( + page: Page, + castFrames: readonly CastFrame[], + {x, y}: {x: number; y: number}, +): Promise { + const scratch = await page.context().newPage(); + try { + const values: number[] = []; + for (let i = 0; i < castFrames.length; i += 12) { + const batch = castFrames.slice(i, i + 12); + values.push( + ...(await scratch.evaluate( + async ({items, px, py}) => { + const out: number[] = []; + for (const item of items) { + const blob = await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const scale = bitmap.width / item.viewportWidth; + const {data} = context.getImageData( + Math.round(px * scale), + Math.round(py * scale), + 16, + 16, + ); + let sum = 0; + for (let o = 0; o < data.length; o += 4) { + sum += data[o]; + } + out.push(sum / (data.length / 4)); + } + return out; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + })), + px: x, + py: y, + }, + )), + ); + } + return castFrames.map((f, i) => ({ + t: f.t, + box: {x: values[i], y: 0, width: 1, height: 1}, + pixels: 1, + })); + } finally { + await scratch.close(); + } +} + async function busyFixture(page: Page) { await page.route('http://busy.test/', route => route.fulfill({contentType: 'text/html', body: BUSY_FIXTURE}), @@ -267,6 +373,81 @@ test.describe('touch input', () => { ), ).toBe(2); }); + + test('a scripted tap clicks where the driver says it does, and driver.tap clicks in both engines', async ({ + page, + }) => { + await page.route('http://tap.test/', route => + route.fulfill({ + contentType: 'text/html', + body: ``, + }), + ); + await page.goto('http://tap.test/'); + const order = () => + page.evaluate(() => + (window as unknown as {__order: string[]}).__order.slice(), + ); + const driver = await touchDriver(page); + + await driver.play(tap({x: 140, y: 80})); + await driver.tap({x: 140, y: 80}); + // driver.tap's click comes last, and a click of the scripted tap, if it + // gets one, before driver.tap's touchstart: no wait on the clock. + await expect + .poll(async () => { + const events = await order(); + return events.lastIndexOf('click') > events.lastIndexOf('touchstart'); + }) + .toBe(true); + const events = await order(); + const second = events.indexOf('touchstart', 1); + expect(events.slice(0, second)).toEqual( + driver.synthesizesClicks + ? ['touchstart', 'touchend', 'click'] + : ['touchstart', 'touchend'], + ); + expect(events.slice(second)).toEqual(['touchstart', 'touchend', 'click']); + expect(SCRIPTED_CLICK_SKIP).toContain('driver.tap'); + }); + + test('a gesture the page receives off its pacing throws TouchPacingError where the page times touches by their arrival', async ({ + page, + }) => { + await fixture(page); + const driver = await touchDriver(page); + const script = flick( + {x: 160, y: 200}, + {velocity: {x: 0, y: -1500}, durationMs: 96, hz: 120}, + ); + // The driver stalls 40 ms before the last move, as a dispatch does on a + // loaded runner: the interval a release velocity reads stretches. + const count = toTouchEvents(script).length; + const played = driver.play(script, { + checkPacing: true, + onEvent: async (_, index) => { + if (index === count - 3) { + await new Promise(r => setTimeout(r, 40)); + } + }, + }); + if (driver.scriptedTimestamps) { + // Chromium: the page sees the script's stamps whatever the dispatch. + expect((await played).length).toBeGreaterThan(4); + return; + } + const error = await played.then( + () => null, + (thrown: unknown) => thrown, + ); + expect(error).toBeInstanceOf(TouchPacingError); + const {scriptedGaps, receivedGaps} = error as TouchPacingError; + expect(receivedGaps).toHaveLength(scriptedGaps.length); + // The stall stretches the interval ending at the last move. + expect(receivedGaps[count - 3] - scriptedGaps[count - 3]).toBeGreaterThan( + TOUCH_PACING_TOLERANCE_MS, + ); + }); }); test.describe('frame evidence', () => { @@ -276,8 +457,15 @@ test.describe('frame evidence', () => { await busyFixture(page); const box = page.locator('#box'); await markElement(box, {mode: 'outline'}); + // A loaded runner's compositor skips frames the display-rate bound + // counts: the component is judged against what the compositor presented + // in the same window, and the bound's own verdict is recorded beside it. + const control = await startBusyControl(page); + // The README's order: the cast, then the animation, then the block once + // it runs (on the compositor, for the compositor's run). const run = async (driver: 'compositor' | 'raf', everyNthFrame = 1) => { + const cast = await startScreencast(page, {everyNthFrame}); await box.evaluate((el, how) => { el.getAnimations().forEach(a => a.cancel()); el.style.transform = 'none'; @@ -298,48 +486,143 @@ test.describe('frame evidence', () => { requestAnimationFrame(step); } }, driver); - const cast = await startScreencast(page, {everyNthFrame}); - await page.waitForTimeout(200); - const window = await blockMainThread(page, 700); + const window = await blockMainThread( + page, + 700, + driver === 'compositor' ? {during: box} : {}, + ); await page.waitForTimeout(200); - const tracked = await trackMarker(page, await cast.stop()); - return busyWindowProof(tracked, window, {axis: 'y'}); + const frames = await cast.stop(); + const tracked = await trackMarker(page, frames); + const controlFrames = await trackMarker(page, frames, {rgb: CONTROL_RGB}); + const alone = busyWindowProof(tracked, window, {axis: 'y'}); + return { + proof: busyWindowProof(tracked, window, { + axis: 'y', + control: controlFrames, + }), + alone, + }; }; - const compositor = await run('compositor'); + const {proof: compositor, alone: compositorAlone} = await run('compositor'); testInfo.annotations.push({ type: 'busy-main-thread compositor', - description: compositor.summary, + description: `${compositor.summary} | without the control: ${compositorAlone.verdict.failures.join('; ') || 'pass'}`, }); expect(compositor.verdict.failures, compositor.summary).toEqual([]); expect(compositor.monotonic).toBe(true); // Resolvable on the cast's grid: two position steps or more per frame. - expect(compositor.notes, compositor.summary).toEqual([]); expect(compositor.pxPerFrame).toBeGreaterThanOrEqual(2 * compositor.stepPx); - const raf = await run('raf'); + const {proof: raf, alone: rafAlone} = await run('raf'); testInfo.annotations.push({ type: 'busy-main-thread rAF control', description: raf.summary, }); - // Frozen for the whole block: no presented frame moved (the compositor - // sends none at all when nothing on screen changes), and the verdict that - // passes the compositor animation fails it on every criterion. - expect(raf.changed).toBe(0); + // Frozen for the block: the compositor sends no frame while nothing on + // screen changes, so at most the frame presented as the block starts + // moved, and the bound's verdict fails it as the control's does. + expect(raf.changed).toBeLessThanOrEqual(1); expect(raf.longestStallMs).toBeGreaterThan(600); - expect(raf.verdict.pass).toBe(false); - expect(raf.verdict.failures.join('\n')).toMatch(/longest stall/); - expect(raf.verdict.failures.join('\n')).toMatch(/expected frames moved/); + expect(raf.verdict.failures.join('\n')).toMatch( + /moved on [01] of the \d+ frames the control moved/, + ); + expect(raf.verdict.failures.join('\n')).toMatch( + /longest stall .* longer than the control's/, + ); + expect(rafAlone.verdict.pass).toBe(false); + expect(rafAlone.verdict.failures.join('\n')).toMatch(/longest stall/); // The verdict judges the frames the screencast delivered, not its rate: // at one frame in six (a loaded machine's ~100 ms), the compositor // animation still passes and the rAF one still fails. - const slow = await run('compositor', 6); + const {proof: slow} = await run('compositor', 6); expect(slow.cadenceMs, slow.summary).toBeGreaterThan(60); expect(slow.verdict.failures, slow.summary).toEqual([]); - const slowRaf = await run('raf', 6); + const {proof: slowRaf} = await run('raf', 6); expect(slowRaf.verdict.pass, slowRaf.summary).toBe(false); expect(slowRaf.verdict.failures.join('\n')).toMatch(/longest stall/); + await control.remove(); + }); + + test('frames the screencast skips while the compositor presents them are not a stall; a frozen animation still fails with the compositor record', async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'compositor traces are Chromium-only (README: engine limits)', + ); + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const run = async (driver: 'compositor' | 'raf') => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page); + const compositor = await startCompositorTrace(page); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const presented = await compositor.stop(); + const tracked = await trackMarker(page, await cast.stop()); + return {window, tracked, presented}; + }; + + const moving = await run('compositor'); + expect(moving.presented.length).toBeGreaterThan(0); + // The screencast as a loaded machine delivers it: four frames in a row + // skipped mid-block, which the compositor still presented. + const mid = moving.tracked.findIndex(f => f.t > moving.window.from + 350); + const skipped = moving.tracked.filter((_, i) => i < mid || i >= mid + 4); + const alone = busyWindowProof(skipped, moving.window, {axis: 'y'}); + expect(alone.verdict.failures.join('\n'), alone.summary).toMatch( + /longest stall/, + ); + const proof = busyWindowProof(skipped, moving.window, { + axis: 'y', + presented: moving.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread compositor record', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.recorderSkipped).toBeGreaterThanOrEqual(4); + + // A rAF animation presents no change for the whole block: the record + // excuses nothing, whatever the compositor drew meanwhile. + const frozen = await run('raf'); + const still = busyWindowProof(frozen.tracked, frozen.window, { + axis: 'y', + presented: frozen.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control, compositor record', + description: `${still.summary}; compositor frames inside the block: ${frozen.presented.filter(t => t > frozen.window.from && t < frozen.window.to).length}`, + }); + expect(still.verdict.pass).toBe(false); + expect(still.recorderSkipped).toBe(0); + expect(still.verdict.failures.join('\n')).toMatch(/longest stall/); }); test('a block that starts during a just-started transition still lets the compositor advance it', async ({ @@ -377,6 +660,232 @@ test.describe('frame evidence', () => { expect(proof.monotonic).toBe(true); }); + test('against a compositor-only control in the same window, a compositor animation passes and a rAF one fails, whatever the screencast rate', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const control = await startBusyControl(page); + + const run = async (driver: 'compositor' | 'raf', everyNthFrame = 1) => { + // Endless, so the animation still runs when the block starts however + // long a loaded runner takes to start the cast, and wrapping from 560 px + // back to 0 rather than turning, so every frame changes: two frames + // straddling a turnaround evenly would read one position. + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear', iterations: Infinity}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = ((now - start) / 1600) % 1; + el.style.transform = `translateY(${560 * p}px)`; + requestAnimationFrame(step); + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(200); + // The compositor's run is blocked only once it is on the compositor. + const window = await blockMainThread( + page, + 700, + driver === 'compositor' ? {during: box} : {}, + ); + await page.waitForTimeout(200); + const frames = await cast.stop(); + const tracked = await trackMarker(page, frames); + const controlFrames = await trackMarker(page, frames, {rgb: CONTROL_RGB}); + const proof = busyWindowProof(tracked, window, { + axis: 'y', + control: controlFrames, + }); + // The judgment without the control, for the record only: on a + // two-core runner it can fail where the control shows the compositor + // itself presented no more. + const alone = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: `busy-main-thread ${driver} 1/${everyNthFrame} against the control`, + description: `${proof.summary} | without the control: ${alone.verdict.failures.join('; ') || 'pass'}`, + }); + return proof; + }; + + const compositor = await run('compositor'); + expect(compositor.verdict.failures, compositor.summary).toEqual([]); + expect(compositor.control?.moved).toBeGreaterThanOrEqual(4); + + // The mutant: a main-thread animation freezes while the control moves. A + // frame presented as the block starts can still show its last step, so + // it can read as moved on one of the control's frames; never on the rest. + const raf = await run('raf'); + expect(raf.verdict.pass, raf.summary).toBe(false); + const rafFailures = raf.verdict.failures.join('\n'); + expect(rafFailures).toMatch( + /moved on [01] of the \d+ frames the control moved/, + ); + expect(rafFailures).toMatch(/longest stall .* longer than the control's/); + + // A slower screencast samples the control and the component alike. + const slow = await run('compositor', 6); + expect(slow.verdict.failures, slow.summary).toEqual([]); + const slowRaf = await run('raf', 6); + expect(slowRaf.verdict.pass, slowRaf.summary).toBe(false); + await control.remove(); + }); + + test("mounted within an open modal dialog, the control leaves the dialog's backdrop animation on the compositor", async ({ + page, + }) => { + // A dimming backdrop over a black page: its brightness at a point is the + // value judged, read from the same frames as the control. + await page.route('http://backdrop.test/', route => + route.fulfill({ + contentType: 'text/html', + body: ``, + }), + ); + await page.goto('http://backdrop.test/'); + const dialog = page.locator('#sheet'); + await dialog.evaluate(el => (el as HTMLDialogElement).showModal()); + const control = await startBusyControl(page, {within: dialog}); + const cast = await startScreencast(page); + // The dim starts, as a dismissal's does, and the block holds the main + // thread once its transition runs. + await dialog.evaluate(el => { + el.animate([{opacity: 0.2}, {opacity: 1}], { + duration: 900, + easing: 'linear', + fill: 'forwards', + pseudoElement: '::backdrop', + }); + }); + const window = await blockMainThread(page, 600, {during: dialog}); + await page.waitForTimeout(200); + const castFrames = await cast.stop(); + await control.remove(); + const controlFrames = await trackMarker(page, castFrames, { + rgb: CONTROL_RGB, + }); + const dim = await brightnessAt(page, castFrames, {x: 40, y: 600}); + const proof = busyWindowProof(dim, window, { + axis: 'x', + control: controlFrames, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + }); + + test('a block too short to judge alone is judged over pooled runs: a compositor animation passes, and a rAF one fails, timed or frame-counted', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const control = await startBusyControl(page); + + // Six 70 ms blocks: each judged part (20 ms margins, 30 ms) holds at most + // three frames even at WebKit's 14 ms cast, under the four one window + // needs, as a short fade's does. + const runs = async ( + driver: 'compositor' | 'raf' | 'counted 1' | 'counted 2', + ) => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + // Endless, wrapping from 560 px back to 0 rather than turning, so + // every frame changes: two frames straddling a turnaround evenly + // would read one position. + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear', iterations: Infinity}, + ); + } else if (how === 'raf') { + const start = performance.now(); + // The same endless run on the main thread. + const step = (now: number) => { + const p = ((now - start) / 1600) % 1; + el.style.transform = `translateY(${560 * p}px)`; + requestAnimationFrame(step); + }; + requestAnimationFrame(step); + } else { + // A fixed step a frame, counted rather than timed: after a block it + // goes on from where it stood instead of catching up. + const px = how === 'counted 1' ? 1 : 2; + let y = 0; + let direction = 1; + const step = () => { + if (y + direction * px > 560 || y + direction * px < 0) { + direction = -direction; + } + y += direction * px; + el.style.transform = `translateY(${y}px)`; + requestAnimationFrame(step); + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page); + await page.waitForTimeout(200); + const windows = []; + for (let i = 0; i < 6; i += 1) { + windows.push(await blockMainThread(page, 70)); + await page.waitForTimeout(150); + } + const frames = await cast.stop(); + const tracked = await trackMarker(page, frames); + const controlFrames = await trackMarker(page, frames, {rgb: CONTROL_RGB}); + return windows.map(window => ({tracked, control: controlFrames, window})); + }; + + const compositor = await runs('compositor'); + const single = busyWindowProof( + compositor[0].tracked, + compositor[0].window, + {axis: 'y', control: compositor[0].control}, + ); + expect(single.verdict.failures.join('\n'), single.summary).toMatch( + /too little in this window to judge/, + ); + const pooled = pooledBusyProof(compositor, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-pooled compositor', + description: pooled.summary, + }); + expect(pooled.verdict.failures, pooled.summary).toEqual([]); + + // The mutant: a main-thread animation freezes in every block. + const raf = pooledBusyProof(await runs('raf'), {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-pooled raf', + description: raf.summary, + }); + expect(raf.verdict.pass, raf.summary).toBe(false); + expect(raf.verdict.failures.join('\n')).toMatch( + /moved on \d+ of the \d+ frames the control moved over 6 runs/, + ); + + // A frame-counted loop stalls in every block too and then steps on, one + // cast step a frame at 1 px: its stall is longer than its runs before it. + for (const counted of ['counted 1', 'counted 2'] as const) { + const proof = pooledBusyProof(await runs(counted), {axis: 'y'}); + testInfo.annotations.push({ + type: `busy-pooled ${counted}`, + description: proof.summary, + }); + expect(proof.verdict.pass, `${counted}: ${proof.summary}`).toBe(false); + } + await control.remove(); + }); + test('a block never starts on an animation that is not on the compositor yet', async ({ page, }, testInfo) => { @@ -422,6 +931,660 @@ test.describe('frame evidence', () => { expect(proof.verdict.failures, proof.summary).toEqual([]); expect(proof.monotonic).toBe(true); }); + + test('in the WebKit container, compiled shaders outlive the run', async ({ + browser, + }, testInfo) => { + // The official Playwright image keeps its browsers here. + test.skip( + testInfo.project.name !== 'webkit' || + process.env.PLAYWRIGHT_BROWSERS_PATH !== '/ms-playwright', + 'runs inside webkit-container.mjs', + ); + // A cache inside the container dies with it: every run then compiled each + // shader again, 450–700 ms in the first frame of a blurred layer. + const dir = process.env.MESA_SHADER_CACHE_DIR; + expect(dir, 'MESA_SHADER_CACHE_DIR names the host-kept cache').toBeTruthy(); + // Written on the host by the script before the container starts. + expect( + fs.readFileSync(path.join(dir ?? '', 'ASTRYX-SHADER-CACHE'), 'utf8'), + ).toContain('playwright'); + // Mesa writes what it compiles there once a page draws a blurred layer. + // Earlier runs have filled the cache already, so this run's browser gets + // an empty directory beside them, in the same host-kept mount, and the + // test watches its own writes arrive. + const fresh = fs.mkdtempSync(path.join(dir ?? '', 'self-test-')); + try { + const own = await browser.browserType().launch({ + env: {...process.env, MESA_SHADER_CACHE_DIR: fresh}, + }); + try { + const page = await own.newPage(); + await page.setContent( + '
', + ); + await page.evaluate( + () => + document + .getElementById('glass') + ?.animate([{scale: '1'}, {scale: '0.9'}], {duration: 200}) + .finished, + ); + } finally { + await own.close(); + } + expect(fs.readdirSync(fresh, {recursive: true})).not.toEqual([]); + } finally { + fs.rmSync(fresh, {recursive: true, force: true}); + } + }); + + test('rect sampling reads one rect per animation frame while the main thread is free', async ({ + page, + browserName, + }) => { + await fixture(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600, easing: 'linear'}, + ); + }); + const samples = await sampleRects(box, 400); + const xs = samples.map(s => s.box?.x ?? NaN); + const gaps = samples + .slice(1) + .map((s, i) => s.t - samples[i].t) + .sort((a, b) => a - b); + const cadence = gaps[Math.floor(gaps.length / 2)]; + expect(cadence).toBeLessThan(34); + expect(samples.length).toBeGreaterThanOrEqual(10); + if (browserName === 'chromium') { + // One sample per presented frame at 60 Hz. (Headless WebKit's animation + // frames are irregular, so only the cadence is checked there.) + expect(samples.length).toBeGreaterThanOrEqual(20); + } + expect(xs.every((x, i) => i === 0 || x >= xs[i - 1])).toBe(true); + expect(xs.at(-1) ?? 0).toBeGreaterThan(xs[0] + 80); + }); + + test('the trace counts no Layout for a transform animation and one per frame for `top`', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const box = page.locator('#box'); + // Count the animation's steady middle: starting and ending it may lay out once each. + const animate = async (property: 'transform' | 'top') => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await box.evaluate((el, prop) => { + const keyframes = + prop === 'transform' + ? [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(200px)'}, + ] + : [{top: '360px'}, {top: '560px'}]; + el.animate(keyframes, {duration: 600}); + return performance.timeOrigin + performance.now(); + }, property); + await page.waitForTimeout(700); + }); + return countBetween(trace, started + 100, started + 500); + }; + const transform = await animate('transform'); + const top = await animate('top'); + expect(transform.Layout ?? 0).toBe(0); + expect(transform.Paint ?? 0).toBe(0); + // ~24 frames in 400 ms: one layout per frame. + expect(top.Layout ?? 0).toBeGreaterThan(18); + }); + + test("the trace counts no Layout from the harness's own animation recorder, which stands down while it runs", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + // The recorder runs, as in any spec that waited for an animation first. + await waitForRest(pop); + const watch = await watchAnimations(pop); + const middle = async (askEveryFrame: boolean) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate((el, every) => { + const w = window as unknown as {stop?: boolean}; + w.stop = false; + if (every) { + const ask = () => { + if (!w.stop) { + requestAnimationFrame(ask); + } + }; + requestAnimationFrame(ask); + } + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }, askEveryFrame); + await page.waitForTimeout(700); + await page.evaluate(() => { + (window as unknown as {stop?: boolean}).stop = true; + }); + }); + // The steady middle: starting and ending it may lay out once each. + return countBetween(trace, started + 100, started + 500); + }; + expect((await middle(false)).Layout ?? 0).toBe(0); + // Control: a page that asks for a frame every frame lays it out in each. + expect((await middle(true)).Layout ?? 0).toBeGreaterThan(18); + // Nothing the traces ran went unrecorded. + const started = await watch.started(); + expect(started.map(a => a.durationMs)).toEqual([600, 600]); + }); + + test('a waitForRest awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // A per-frame wait here would make every frame a main-thread frame. + await waitForRest(pop); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // It still waited for the curve to end. + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForAnimation or watch.started() awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + let waited: {durationMs: number}[] = []; + let watched: {durationMs: number}[] = []; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // Both poll until 450 ms of the curve has played, across the steady + // middle; a read of the page's animations each time would lay it out. + waited = await waitForAnimation(pop, {minProgressMs: 450}); + watched = await watch.started({minProgressMs: 450}); + await page.waitForTimeout(200); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // Each still answered with the animation once 450 ms of it had played. + expect(waited.map(a => a.durationMs)).toEqual([600]); + expect(watched.map(a => a.durationMs)).toEqual([600]); + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for an animation the action starts two frames later', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const trace = await traceRendering(page, async () => { + // The action starts the animation two frames later, as a component's + // effect often does. + await pop.evaluate(el => { + requestAnimationFrame(() => + requestAnimationFrame(() => { + (window as unknown as {__started: number}).__started = + performance.timeOrigin + performance.now(); + el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600}, + ); + }), + ); + }); + await waitForRest(pop); + }); + const started = await page.evaluate( + () => (window as unknown as {__started?: number}).__started ?? null, + ); + // The traced window holds the whole animation. + expect(started).not.toBeNull(); + expect(trace.window.to - (started as number)).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for a run going backward to its end, and no longer', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + // How long a traced waitForRest holds after a 600 ms move starts. + const traced = async (reverse: boolean, endDelay = 0) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate( + (el, {reverse, endDelay}) => { + const run = el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600, endDelay}, + ); + if (reverse) { + // Its current time falls from its end time to 0. + run.reverse(); + } + return performance.timeOrigin + performance.now(); + }, + {reverse, endDelay}, + ); + await waitForRest(pop); + }); + await waitForRest(pop); + return trace.window.to - started; + }; + // Reversed with a 200 ms end delay: the move plays from 200 to 800 ms. + expect(await traced(true, 200)).toBeGreaterThanOrEqual(790); + // A reversed run is waited for as long as the same run forward, not + // twice as long. + const forward = await traced(false); + const backward = await traced(true); + expect(backward).toBeGreaterThanOrEqual(590); + expect(backward).toBeLessThan(forward * 1.5); + }); + + test('a waitForRest awaited inside a traced body runs on the real timers, so a frozen timer gate cannot hold it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + await installTimerGate(page); + await freezeTimers(page); + let outcome = ''; + await traceRendering(page, async () => { + await pop.evaluate(el => { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(100px)'}], + {duration: 300}, + ); + }); + // Held by the gate, the wait would neither resolve nor reach its + // deadline: the race reads that as "held". + outcome = await Promise.race([ + waitForRest(pop, {timeout: 2000}).then( + () => 'rested', + () => 'rejected', + ), + new Promise(resolve => setTimeout(() => resolve('held'), 4000)), + ]); + }); + await thawTimers(page); + expect(outcome).toBe('rested'); + }); + + test('inside a trace, waitForRest rests beside a scroll-timeline animation', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const scroller = page.locator('#scroller'); + await waitForRest(scroller); + let outcome = ''; + await traceRendering(page, async () => { + // A row driven by its scroller's timeline: it moves only on scroll. + await scroller.evaluate(el => { + const row = el.firstElementChild as HTMLElement; + row.animate([{opacity: 0.5}, {opacity: 1}], { + timeline: new ScrollTimeline({source: el}), + }); + }); + outcome = await waitForRest(scroller, {timeout: 2000}).then( + () => 'rested', + (error: Error) => error.message, + ); + }); + expect(outcome).toBe('rested'); + }); + + test('a CSS transition and animation that start and end inside a trace are reported, and lay out nothing in between', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + const pop = page.locator('#pop'); + // The recorder runs, and a watch is taken before the trace. + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + const pop = el as HTMLElement; + pop.style.transition = 'transform 400ms linear'; + void pop.offsetWidth; + pop.style.transform = 'translateY(100px)'; + pop.style.animation = 'harness-fade 400ms linear'; + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(600); + }); + // Its reads at the two starts fall before the steady middle. + expect(countBetween(trace, started + 100, started + 300).Layout ?? 0).toBe( + 0, + ); + const ended = await watch.started({timeout: 1_000}); + expect(ended.map(a => a.kind).sort()).toEqual([ + 'CSSAnimation', + 'CSSTransition', + ]); + expect(ended.every(a => a.finishedAt != null)).toBe(true); + }); + + test('the compositor record refuses another open page, whose frames it could not tell apart', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the compositor record is a Chromium trace (README: engine limits)', + ); + await fixture(page); + // Another page animating: its presented frames would be credited to + // this one, so the record refuses to start. + const other = await page.context().newPage(); + await other.setContent( + '
', + ); + 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 +2306,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 +2455,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 +2528,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 +2918,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 +2942,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 +3131,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/touch.pacing.test.ts b/internal/interaction-harness/src/touch.pacing.test.ts new file mode 100644 index 0000000000000..0f917922aad16 --- /dev/null +++ b/internal/interaction-harness/src/touch.pacing.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touch.pacing.test.ts + * @input Touch scripts from ./scripts and synthetic page timestamps (no + * browser) + * @output The driver's pacing: after a stalled dispatch the events behind it + * keep their scripted gaps rather than leaving together; a gesture the page + * received off its pacing where a release reads it (its last two move + * intervals, its lift) fails `checkTouchPacing` with its gaps named; and + * `retakeOnTouchPacing` re-takes only such a gesture, once, listing it. + * @position Unit test of ./touch's pacing; the browser proof of the same + * behavior through WebKit's real input is in + * harness.interaction.browser.spec.ts. + */ + +import {describe, expect, it} from 'vitest'; +import {drag} from './scripts'; +import { + checkTouchPacing, + playSteps, + retakeOnTouchPacing, + TOUCH_LIFT_STILLNESS_MS, + TOUCH_PACING_TOLERANCE_MS, + TouchPacingError, +} from './touch'; + +describe('playSteps', () => { + it('keeps the scripted gaps after a stalled dispatch instead of sending the events behind it together', async () => { + // A drag of six moves 12 ms apart; the third dispatch stalls 40 ms. + const script = drag( + {x: 0, y: 0}, + {x: 0, y: 60}, + {durationMs: 72, hz: 1000 / 12}, + ); + const sentAt: number[] = []; + let index = 0; + const played = await playSteps( + script, + {}, + async () => { + sentAt.push(performance.now()); + if (index === 2) { + await new Promise(resolve => setTimeout(resolve, 40)); + } + index += 1; + }, + t => t, + ); + const scriptedGaps = played.slice(1).map((p, i) => p.t - played[i].t); + const sentGaps = sentAt.slice(1).map((t, i) => t - sentAt[i]); + // The stalled event's own gap stretches; every later one is at least + // its scripted gap (less a millisecond of timer slack), never a burst. + for (let i = 3; i < sentGaps.length; i += 1) { + expect(sentGaps[i], `gap ${i + 1}`).toBeGreaterThanOrEqual( + scriptedGaps[i] - 1, + ); + } + expect(sentGaps[2]).toBeGreaterThanOrEqual(40); + }); +}); + +describe('checkTouchPacing', () => { + // A flick as the driver plays it: a touch-down, ten moves 8 ms apart, and + // the lift 2.1 ms after the last move. + const steps = [ + {t: 0, type: 'touchStart'}, + ...Array.from({length: 10}, (_, i) => ({ + t: 8 * (i + 1), + type: 'touchMove', + })), + {t: 82.1, type: 'touchEnd'}, + ]; + /** Page timestamps from the gaps between them. */ + const at = (gaps: readonly number[]) => + gaps.reduce( + (times, gap) => [...times, times[times.length - 1] + gap], + [1000], + ); + + it("passes the page's own handling in a gesture's first moves and its lift: a burst after the touch-down, a lift a frame late", () => { + // What WebKit delivered for a BottomSheet flick on an idle worker. + expect(() => + checkTouchPacing(steps, at([4, 11, 3, 8, 8, 8, 8, 8, 8, 8, 9])), + ).not.toThrow(); + }); + + it('fails a stall and the burst behind it in the move intervals a release velocity reads, naming both gap lists', () => { + const received = at([8, 8, 8, 8, 8, 8, 8, 29, 2, 1, 2]); + expect(() => checkTouchPacing(steps, received)).toThrow(TouchPacingError); + try { + checkTouchPacing(steps, received); + } catch (error) { + const pacing = error as TouchPacingError; + expect(pacing.message).toMatch( + /the move interval ending at event 10, which a release velocity reads, is 6\.0 ms off/, + ); + expect(pacing.receivedGaps).toEqual([8, 8, 8, 8, 8, 8, 8, 29, 2, 1, 2]); + expect(pacing.scriptedGaps.slice(0, 10)).toEqual(Array(10).fill(8)); + } + // The same stall earlier, with the last two intervals on pace: the + // release reads the script's velocity. + expect(() => + checkTouchPacing(steps, at([8, 8, 29, 2, 1, 2, 8, 8, 8, 8, 2])), + ).not.toThrow(); + }); + + it(`fails a lift that reaches the page ${TOUCH_LIFT_STILLNESS_MS} ms or more after the last move, where a release reads as still`, () => { + expect(() => + checkTouchPacing(steps, at([8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 45])), + ).toThrow(/the lift came 45\.0 ms after the last move/); + }); + + it('fails a gesture with an event missing, unless the touched element left the document', () => { + const received = at([8, 8, 8, 8]); + expect(() => checkTouchPacing(steps, received)).toThrow( + /5 touch events arrived for 12 dispatched/, + ); + expect(() => + checkTouchPacing(steps, received, {partial: true}), + ).not.toThrow(); + }); + + it('holds the tolerance it states', () => { + expect(TOUCH_PACING_TOLERANCE_MS).toBe(4); + }); +}); + +describe('retakeOnTouchPacing', () => { + const offPace = () => new TouchPacingError('gap 4 is 21.0 ms off', [8], [29]); + + it('re-takes a gesture received off pace once, and fails red when the second attempt is off pace too', async () => { + const info = {annotations: [] as {type: string; description?: string}[]}; + const attempts: number[] = []; + await expect( + retakeOnTouchPacing(info, async n => { + attempts.push(n); + throw offPace(); + }), + ).rejects.toThrow(TouchPacingError); + expect(attempts).toEqual([1, 2]); + expect(info.annotations.map(a => a.type)).toEqual(['touch-retake']); + }); + + it('never re-takes a product failure after input that arrived on pace', async () => { + const info = {annotations: [] as {type: string; description?: string}[]}; + const attempts: number[] = []; + await expect( + retakeOnTouchPacing(info, async n => { + attempts.push(n); + throw new Error('the sheet rested on full, expected half'); + }), + ).rejects.toThrow('the sheet rested on full, expected half'); + expect(attempts).toEqual([1]); + expect(info.annotations).toEqual([]); + }); + + it('passes on a good second attempt after a bad first one, and lists the re-take', async () => { + const info = {annotations: [] as {type: string; description?: string}[]}; + const value = await retakeOnTouchPacing(info, async n => { + if (n === 1) { + throw offPace(); + } + return 'rested on half'; + }); + expect(value).toBe('rested on half'); + expect(info.annotations).toHaveLength(1); + expect(info.annotations[0]).toMatchObject({ + type: 'touch-retake', + description: expect.stringMatching(/off its scripted pacing/), + }); + }); +}); diff --git a/internal/interaction-harness/src/touch.ts b/internal/interaction-harness/src/touch.ts index 1c16c4b1f82e0..b47c463f25772 100644 --- a/internal/interaction-harness/src/touch.ts +++ b/internal/interaction-harness/src/touch.ts @@ -23,7 +23,8 @@ * `Input.dispatchTouchEvent` on the page proxy. Touches and the pointer * events derived from them are trusted and cancelable, multi-touch works. * Limits, measured with WebKit 26.5: no timestamp parameter (the page sees - * the real dispatch time, so velocity comes from real-time pacing, ±1 ms); + * the real dispatch time, so velocity comes from real-time pacing, about + * ±1 ms on an idle worker; `checkPacing` reads what the page received); * integer coordinates only; no native scrolling, fling or pinch-zoom from * these touches; a `touchstart` lists only the fingers that landed in it * (pointer events are exact); no click synthesised from a raw touch sequence (use @@ -36,7 +37,7 @@ * harness self-test (../src/harness.interaction.browser.spec.ts). */ -import type {CDPSession, Page} from '@playwright/test'; +import type {CDPSession, Page, TestInfo} from '@playwright/test'; import { toTouchEvents, type Finger, @@ -55,6 +56,14 @@ export interface PlayOptions { * behaviors that run on the compositor clock (a fling) want real pacing. */ readonly realtime?: boolean; + /** + * Check, once the gesture is played in real time, that the page received it + * at the script's pacing, and throw `TouchPacingError` if not (default + * false). Only WebKit can fail it: its touches carry their real dispatch + * time. For a proof whose outcome follows a velocity, with + * `retakeOnTouchPacing` around it. + */ + readonly checkPacing?: boolean; /** Called after each event is dispatched, before the next one (storyboards). */ readonly onEvent?: ( step: TouchEventStep, @@ -81,6 +90,13 @@ export interface TouchDriver { * Skip such proofs with `PARTIAL_LIFT_SKIP` where it is false. */ readonly partialLifts: boolean; + /** + * Whether a scripted press and lift in place (`play(tap(at))`) gets the + * click a finger's tap gets. Playwright's WebKit synthesises that click only + * from `tap` (its tap gesture), never from a scripted touch sequence. Use + * `tap` for a tap that must click, or skip with `SCRIPTED_CLICK_SKIP`. + */ + readonly synthesizesClicks: boolean; /** Plays a script; resolves when its last event has been dispatched. */ play(script: TouchScript, options?: PlayOptions): Promise; /** A tap through the engine's tap path (WebKit synthesises click only from this). */ @@ -96,6 +112,146 @@ export interface TouchDriver { export const PARTIAL_LIFT_SKIP = 'injected WebKit touches report no remaining touches when one finger of several lifts (README: engine limits); capture it on the iOS simulator'; +/** + * The skip reason for a proof that needs the click a scripted tap gets, on a + * driver whose `synthesizesClicks` is false: Playwright's WebKit synthesises + * the click only from its tap gesture (`driver.tap`), never from touches it + * is sent one by one. + */ +export const SCRIPTED_CLICK_SKIP = + 'injected WebKit touches synthesise no click, only driver.tap does (README: engine limits); tap with driver.tap, or capture it on the iOS simulator'; + +/** + * The most a move interval a release velocity reads may move from the script + * by the time the page receives it, in ms. WebKit's touches carry their real + * dispatch time, in whole ms: on one worker, 440 gaps over 40 flicks moved at + * most 4 ms; a stalled dispatch moves one by 20 ms or more, and the events + * queued behind it land 1 to 2 ms apart. + */ +export const TOUCH_PACING_TOLERANCE_MS = 4; + +/** + * How long after its last move a lift may reach the page before the release + * reads as still, in ms: UIKit's measured stillness (`spec:AST-100` FR2). + */ +export const TOUCH_LIFT_STILLNESS_MS = 40; + +const formatGaps = (gaps: readonly number[]) => + gaps.map(gap => Number(gap.toFixed(1))).join(', '); + +/** + * A gesture the page received off its scripted pacing. WebKit's touch command + * takes no timestamp, so the page times each event by when it arrives: under + * load a dispatch can stall and the events behind it arrive at once, and the + * page measures another velocity than the script's. + */ +export class TouchPacingError extends Error { + readonly scriptedGaps: readonly number[]; + readonly receivedGaps: readonly number[]; + constructor( + reason: string, + scriptedGaps: readonly number[], + receivedGaps: readonly number[], + ) { + super( + `the page received this gesture off its scripted pacing: ${reason} (scripted gaps ${formatGaps(scriptedGaps)} ms; received ${formatGaps(receivedGaps)} ms)`, + ); + this.name = 'TouchPacingError'; + this.scriptedGaps = scriptedGaps; + this.receivedGaps = receivedGaps; + } +} + +/** + * Checks a gesture's pacing at the page where it decides a release: `steps` + * are its events in the script, with their times, `received` the + * `timeStamp`s the page saw, in order. One event per scripted one (fewer only + * when `partial`, the touched element having left the document, which takes + * its later events with it); the last two move intervals before the lift, + * which a release velocity reads, each within `TOUCH_PACING_TOLERANCE_MS` of + * the script's; and the lift within `TOUCH_LIFT_STILLNESS_MS` of the last + * move. The gesture's first moves and its lift arrive with the page's own + * handling time in WebKit (a burst after `touchstart`, a lift a frame late), + * and no release reads them. Throws `TouchPacingError` otherwise. + */ +export function checkTouchPacing( + steps: readonly {readonly t: number; readonly type: string}[], + received: readonly number[], + {partial = false}: {partial?: boolean} = {}, +): void { + const gapsOf = (times: readonly number[]) => + times.slice(1).map((t, i) => t - times[i]); + const scriptedGaps = gapsOf(steps.map(step => step.t)); + const receivedGaps = gapsOf(received); + const fail = (reason: string) => { + throw new TouchPacingError(reason, scriptedGaps, receivedGaps); + }; + if ( + received.length > steps.length || + (received.length < steps.length && !partial) + ) { + fail( + `${received.length} touch events arrived for ${steps.length} dispatched`, + ); + } + const lift = steps.length - 1; + if (received.length < steps.length || steps[lift]?.type !== 'touchEnd') { + return; + } + // The gap ending at event i runs from event i - 1 to event i. + const moves = steps.flatMap((step, i) => + step.type === 'touchMove' ? [i] : [], + ); + const lastMove = moves.at(-1); + if (lastMove == null) { + return; + } + for (const i of moves.slice(-2)) { + if (i === 0 || steps[i - 1].type !== 'touchMove') { + continue; + } + const off = Math.abs(receivedGaps[i - 1] - scriptedGaps[i - 1]); + if (off > TOUCH_PACING_TOLERANCE_MS) { + fail( + `the move interval ending at event ${i + 1}, which a release velocity reads, is ${off.toFixed(1)} ms off, over ${TOUCH_PACING_TOLERANCE_MS} ms`, + ); + } + } + const liftAfter = received[lift] - received[lastMove]; + if (liftAfter >= TOUCH_LIFT_STILLNESS_MS) { + fail( + `the lift came ${liftAfter.toFixed(1)} ms after the last move, where a release reads as still from ${TOUCH_LIFT_STILLNESS_MS} ms`, + ); + } +} + +/** + * Plays a gesture through `attempt`, and once more when the page received it + * off its scripted pacing: `attempt` builds the page's state, plays the + * gesture and makes the spec's assertions, so a re-take starts over. One + * re-take, two attempts in all, and only on a `TouchPacingError`: any other + * error, and a second `TouchPacingError`, fail the test with their own + * cause. Each re-take is listed in the report as a `touch-retake` + * annotation. + */ +export async function retakeOnTouchPacing( + testInfo: Pick, + attempt: (n: 1 | 2) => Promise, +): Promise { + try { + return await attempt(1); + } catch (error) { + if (!(error instanceof TouchPacingError)) { + throw error; + } + testInfo.annotations.push({ + type: 'touch-retake', + description: error.message, + }); + return attempt(2); + } +} + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** Waits until `performance.now()` reaches `deadline` with ~0.1 ms precision. */ @@ -109,7 +265,11 @@ async function until(deadline: number): Promise { } } -async function playSteps( +/** + * Dispatches a script's events through `dispatch`, paced in real time unless + * `realtime` is false. Exported for its unit test; specs use `touchDriver`. + */ +export async function playSteps( script: TouchScript, options: PlayOptions, dispatch: (step: TouchEventStep, timestamp: number) => Promise, @@ -119,12 +279,21 @@ async function playSteps( const steps = toTouchEvents(script); const played: PlayedEvent[] = []; const origin = performance.now(); + let previous: {t: number; sentAt: number} | null = null; for (const [index, step] of steps.entries()) { if (realtime) { - await until(origin + step.t); + // At its scripted time, and never sooner after the event before it + // than the script spaces them: after a stall the rest keep their gaps + // instead of leaving together. + await until( + previous == null + ? origin + step.t + : Math.max(origin + step.t, previous.sentAt + (step.t - previous.t)), + ); } const timestamp = stampFor(step.t); const dispatchedAt = Date.now(); + previous = {t: step.t, sentAt: performance.now()}; await dispatch(step, timestamp); played.push({...step, dispatchedAt, timestamp}); await onEvent?.(step, index); @@ -148,6 +317,7 @@ async function chromiumDriver(page: Page): Promise { scriptedTimestamps: true, drivesNativeGestures: true, partialLifts: true, + synthesizesClicks: true, async play(script, options = {}) { // Stamps follow the wall clock the page's own clock is on: with real-time // pacing an event's stamp and its dispatch agree to ~0.1 ms. @@ -213,13 +383,20 @@ async function webkitDriver(page: Page): Promise { // (an empty list releases nothing), a move every finger down. const points = (fingers: readonly Finger[]) => fingers.map(f => ({x: Math.round(f.x), y: Math.round(f.y), id: f.id})); + // The page's own record of each touch's `timeStamp`, to check the pacing. + await page.addInitScript(TOUCH_PACE_SCRIPT); + await page.evaluate(TOUCH_PACE_SCRIPT); return { engine: 'webkit', scriptedTimestamps: false, drivesNativeGestures: false, partialLifts: false, + synthesizesClicks: false, async play(script, options = {}) { - return playSteps( + const check = + (options.checkPacing ?? false) && (options.realtime ?? true); + const from = check ? await readTouchPace(page, 0) : null; + const played = await playSteps( script, options, async step => { @@ -231,7 +408,12 @@ async function webkitDriver(page: Page): Promise { }); }, () => Date.now(), - ).then(played => played.map(p => ({...p, timestamp: null}))); + ); + if (from != null) { + const pace = await readTouchPace(page, from.count); + checkTouchPacing(played, pace.times, {partial: !pace.connected}); + } + return played.map(p => ({...p, timestamp: null})); }, async tap(at) { await page.touchscreen.tap(Math.round(at.x), Math.round(at.y)); @@ -239,6 +421,42 @@ async function webkitDriver(page: Page): Promise { }; } +/** + * Records the `timeStamp` of every trusted touch event the page receives, and + * the element the last `touchstart` touched (WebKit's pacing check). + */ +const TOUCH_PACE_SCRIPT = `(() => { + if (window.__harnessTouchPace) return; + const pace = (window.__harnessTouchPace = {times: [], start: null}); + for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel']) { + window.addEventListener(type, event => { + if (!event.isTrusted) return; + pace.times.push(event.timeStamp); + if (type === 'touchstart') pace.start = event.target; + }, {capture: true, passive: true}); + } +})()`; + +/** The touch times recorded since `from`, and whether the touched element is still in the document. */ +async function readTouchPace( + page: Page, + from: number, +): Promise<{count: number; times: number[]; connected: boolean}> { + return page.evaluate(at => { + const pace = ( + window as unknown as { + __harnessTouchPace?: {times: number[]; start: Node | null}; + } + ).__harnessTouchPace; + const times = pace?.times ?? []; + return { + count: times.length, + times: times.slice(at), + connected: pace?.start?.isConnected ?? true, + }; + }, from); +} + /** A touch driver for the page's engine. Firefox is not supported (no touch input path). */ export async function touchDriver(page: Page): Promise { const name = page.context().browser()?.browserType().name(); 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/internal/test-utils/src/setup.ts b/internal/test-utils/src/setup.ts index 440b32ff22b3c..991bb056f0816 100644 --- a/internal/test-utils/src/setup.ts +++ b/internal/test-utils/src/setup.ts @@ -12,6 +12,8 @@ /// import '@testing-library/jest-dom/vitest'; import {configure} from '@testing-library/react'; +import {afterEach} from 'vitest'; +import {__resetClickGuardForTest} from '../../../packages/core/src/utils/clickGuard'; // Text queries (getByText/findByText/…) target VISIBLE text. Astryx's // `useAnnounce` renders a visually-hidden aria-live region @@ -25,6 +27,15 @@ import {configure} from '@testing-library/react'; // Keeps the jsdom defaults (script, style). configure({defaultIgnore: 'script, style, [data-astryx-live-region]'}); +// The click guard (packages/core/src/utils/clickGuard.ts) is armed per +// document by a gesture that already acted and disarms itself 400 ms later. +// A test that ends inside that window would leave the timer to fire after +// the environment tears its window down, during a later file. Disarm every +// guard as each test ends. +afterEach(() => { + __resetClickGuardForTest(); +}); + // Polyfill for matchMedia (not supported in jsdom). // // `(hover: hover)` reports TRUE: jsdom models a desktop browser driven by a 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/BottomSheet/BottomSheet.doc.mjs b/packages/core/src/BottomSheet/BottomSheet.doc.mjs index 1431c4f4ac5a9..3ae64cae561b4 100644 --- a/packages/core/src/BottomSheet/BottomSheet.doc.mjs +++ b/packages/core/src/BottomSheet/BottomSheet.doc.mjs @@ -159,7 +159,7 @@ export const docs = { name: 'snapPoints', type: 'ReadonlyArray', description: - "Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim.", + "Extra heights the sheet can rest at when dragged; its own height is always the tallest stop, and omitting this gives a sheet that only opens and closes. Each stop is the sheet's visible height: a number is a viewport fraction (0.5 is half the screen), '50%' the same in CSS, '320px' an absolute length. A stop of a quarter of the sheet or less is a peek: it slides away instead of reflowing, and thins the scrim. Under a finger, a released drag settles as an iOS sheet does: on the stop nearest where its speed carries it (a quick flick goes at least to the next stop), dismissal included, with iOS's spring; elsewhere it settles as before, on the theme's motion.", }, { name: 'hasScrim', diff --git a/packages/core/src/BottomSheet/BottomSheet.spec.md b/packages/core/src/BottomSheet/BottomSheet.spec.md index 0a52aa06a3423..6b2ae7edd34a2 100644 --- a/packages/core/src/BottomSheet/BottomSheet.spec.md +++ b/packages/core/src/BottomSheet/BottomSheet.spec.md @@ -230,6 +230,11 @@ remain documented in `BottomSheet.doc.mjs`. - in a BottomSheetSwitcher, each sheet's positioner holds its panel at the block end and hides a sheet that is not presented, so one dialog can switch between sheets. + - while a modal sheet enters under a finger, the layer's input hold adds a + shield inside the dialog: a transparent cover of the viewport that takes + a tap or a drag landing during the entrance (`spec:AST-094` FR35). It is + removed when the sheet lands. A non-modal sheet adds no shield: it holds + only input on itself, and the page beside it stays live. - The handle's pill is a pseudo-element and a standalone sheet's dialog holds the panel in place, so a standalone sheet's frame has no positioner or pill element of its own. diff --git a/packages/core/src/BottomSheet/BottomSheet.test.tsx b/packages/core/src/BottomSheet/BottomSheet.test.tsx index 2cc246afa3d13..e8247a511848f 100644 --- a/packages/core/src/BottomSheet/BottomSheet.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.test.tsx @@ -17,6 +17,7 @@ import {BottomSheetEscapeContext} from './bottomSheetEscape'; import {resetBottomSheetStackForTests} from './bottomSheetStack'; import {BottomSheetSwitcher} from './BottomSheetSwitcher'; import {__resetInteractionModalityForTest} from '../utils/interactionModality'; +import {isInHeldLayer, isInputShield} from '../Layer/inputHold'; // A sheet has no stops unless its host asks for them, so the tests that // exercise detents pass their own. These three — a 14% peek, a half-height @@ -73,9 +74,11 @@ beforeEach(() => { ); vi.stubGlobal('cancelAnimationFrame', vi.fn()); window.scrollTo = vi.fn(); + vi.useFakeTimers({toFake: ['performance', 'setTimeout', 'clearTimeout']}); }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -249,6 +252,51 @@ function drag( fireEvent.pointerUp(handle, {pointerId: 1, clientY: last.y, pointerType}); } +// The sheet's translation: its resting transform plus the travel channel +// useSheetGestures moves a dragged or settling sheet on (`translate`). +function translationOf(sheet: HTMLElement): number { + const resting = /translateY\((-?[\d.]+)px\)/.exec(sheet.style.transform); + const [, travel = '0px'] = sheet.style.translate.split(' '); + return ( + (resting == null ? 0 : Number.parseFloat(resting[1])) + + Number.parseFloat(travel) + ); +} + +// Gives the open sheet the border box of a Tall sheet in an 800px window: +// 784px, 736px of it visible (the 48px reserve sits below the fold), so it is +// gone 736px down. +function measureTallSheet() { + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 16, bottom: 800}), + ); +} + +// What the panel's stylesheet gives the sheet's transform in a browser (jsdom +// applies no StyleX): under a mouse, a release plays this timing on the +// animator (releaseMotion.ts). +function giveThemeTransition(sheet: HTMLElement) { + sheet.style.transitionProperty = 'transform'; + sheet.style.transitionDuration = '410ms'; + sheet.style.transitionTimingFunction = 'cubic-bezier(0.24, 1, 0.4, 1)'; +} + +// A slow drag on the handle that rests before the lift: released at rest. +function dragSlowly(handle: HTMLElement, y: number) { + fireTimedPointer(handle, 'pointerdown', {time: 0, y: 0}); + fireTimedPointer(handle, 'pointermove', {time: 1000, y}); + fireTimedPointer(handle, 'pointerup', {time: 2000, y}); +} + +// A release springs on the motion foundation's animator: its clock is +// performance.now() and its curve ends on a timer, both faked for this file. +// This lets a spring land and the sheet reconcile its layout. +async function landSpring() { + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); +} + function fireTimedPointer( target: Element, type: 'pointerdown' | 'pointermove' | 'pointerup', @@ -260,6 +308,11 @@ function fireTimedPointer( clientY: {value: y}, isPrimary: {value: true}, pointerId: {value: 1}, + // A finger where a test stubs a coarse primary pointer, a mouse otherwise + // (useSheetGestures.ts: UIKit's presentation for a finger or a pen there). + pointerType: { + value: matchMedia('(pointer: coarse)').matches ? 'touch' : 'mouse', + }, timeStamp: {value: time}, }); return fireEvent(target, event); @@ -983,7 +1036,9 @@ describe('BottomSheet', () => { Content , ); - drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]); + measureTallSheet(); + // Released at rest more than halfway from open to gone (736px). + dragSlowly(getHandle(), 400); expect(onOpenChange).toHaveBeenCalledWith(false); }); @@ -1044,6 +1099,594 @@ describe('BottomSheet', () => { }); }); + describe('under a mouse', () => { + it('holds nothing when a mouse opens the sheet (FR35, DEC-16)', async () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + await act(async () => { + await Promise.resolve(); + }); + const dialog = screen.getByRole('dialog'); + expect(isInHeldLayer(dialog)).toBe(false); + expect([...dialog.children].some(isInputShield)).toBe(false); + }); + + it('opens and closes on the stylesheet transitions, not the sheet spring', async () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const played: Keyframe[][] = []; + // jsdom has no Web Animations: record what the animator would play. + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function (this: Element, keyframes: Keyframe[]) { + played.push(keyframes); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + const travels = () => + played.flat().filter(frame => frame.translate != null).length; + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + await act(async () => { + await Promise.resolve(); + }); + const dialog = screen.getByRole('dialog'); + // No spring travel and no scrim hand-off: the stylesheet's entrance. + expect(travels()).toBe(0); + expect(dialog.style.getPropertyValue('--_sheet-scrim-duration')).toBe(''); + rerender( + + Content + , + ); + // The exit is the stylesheet's transition: the dialog closes when it ends. + expect(travels()).toBe(0); + expect(dialog).toHaveAttribute('open'); + act(() => finishSheetExit()); + await act(async () => { + await Promise.resolve(); + }); + expect(dialog).not.toHaveAttribute('open'); + delete (Element.prototype as {animate?: unknown}).animate; + }); + }); + + describe('under a finger', () => { + // A finger, on every touch device (one touch model): the sheet opens and + // closes on UIKit's sheet spring, played on the travel channel + // (useSheetGestures present/dismiss). + function stubFinger({ios}: {ios: boolean}) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query.includes('pointer: coarse') && + !query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + supports: (condition: string) => + ios && condition.includes('-webkit-touch-callout'), + escape: (value: string) => value, + }); + } + beforeEach(() => { + stubFinger({ios: true}); + vi.stubGlobal('innerHeight', 800); + }); + + it('a sheet opened over another presents and leaves on the spring with no dim of its own: one scrim per stack', async () => { + // jsdom has no Web Animations: record which dialogs' ::backdrop play. + const backdrops: Element[] = []; + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function ( + this: Element, + _keyframes: Keyframe[], + options?: KeyframeAnimationOptions & {pseudoElement?: string}, + ) { + if (options?.pseudoElement === '::backdrop') { + backdrops.push(this); + } + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + function Two({ + isOrdersOpen, + isOrderOpen, + }: { + isOrdersOpen: boolean; + isOrderOpen: boolean; + }) { + return ( + <> + + List + + + Detail + + + ); + } + const dialogOf = (label: string) => + screen.getByRole('dialog', {name: label, hidden: true}); + const measure = (label: string) => + vi + .spyOn( + dialogOf(label).querySelector( + '.astryx-bottom-sheet', + ) as HTMLElement, + 'getBoundingClientRect', + ) + .mockReturnValue(rect({top: 300, bottom: 848})); + const settle = async () => { + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + }; + const {rerender} = render( + , + ); + // On its own a sheet's spring brings the dim in on its backdrop. + rerender(); + measure('Orders'); + await settle(); + expect(backdrops).toContain(dialogOf('Orders')); + backdrops.length = 0; + // Pushed over it, a sheet keeps the transparent backdrop of a stack's + // upper sheet: its spring moves only the sheet, in and out. + rerender(); + measure('Order'); + await settle(); + expect(backdrops).toEqual([]); + rerender(); + await settle(); + expect(backdrops).toEqual([]); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('opens on the spring on Android too, holding input (one touch model)', async () => { + stubFinger({ios: false}); + const played: Keyframe[][] = []; + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function (this: Element, keyframes: Keyframe[]) { + played.push(keyframes); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + // The spring on the travel channel, and the input hold over the page, + // as on iOS. + expect(played.flat().some(frame => frame.translate != null)).toBe(true); + expect(isInHeldLayer(getSheet())).toBe(true); + }); + + function recordAnimate() { + const played: {keyframes: Keyframe[]; target: Element}[] = []; + // jsdom has no Web Animations: record what the animator plays. + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function (this: Element, keyframes: Keyframe[]) { + played.push({keyframes, target: this}); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + return played; + } + const travelOf = ( + played: {keyframes: Keyframe[]; target: Element}[], + target: Element, + ) => + played + .filter(p => p.target === target) + .flatMap(p => p.keyframes) + .filter(frame => frame.translate != null) + .map(frame => Number.parseFloat(String(frame.translate).split(' ')[1])); + + it("holds pointer input while the sheet enters: the layer's input hold covers the sheet and the scrim until it lands (FR35; F4 b59)", async () => { + recordAnimate(); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + const dialog = screen.getByRole('dialog'); + const shield = () => [...dialog.children].find(isInputShield) ?? null; + // At 60 and 150 ms the dialog is a held layer: a touch on the sheet or + // the scrim reaches no gesture, and the shield inside the dialog takes + // the hits. + for (const ms of [60, 90]) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + expect(isInHeldLayer(dialog)).toBe(true); + expect(isInHeldLayer(getSheet())).toBe(true); + expect(shield()).not.toBeNull(); + } + // A tap on it is not a tap on the scrim: the sheet stays open. + fireEvent.click(shield() as Element); + expect(dialog).toHaveAttribute('open'); + // Landed: the hold is released, so input works from the completion on. + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(isInHeldLayer(getSheet())).toBe(false); + expect(shield()).toBeNull(); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('a non-modal sheet holds only itself while it enters: the page beside it stays live, and a tap on the sheet does nothing until it lands (FR35)', async () => { + recordAnimate(); + const onPage = vi.fn(); + const onInside = vi.fn(); + function Page({isOpen}: {isOpen: boolean}) { + return ( + <> + + + + + + ); + } + const {rerender} = render(); + rerender(); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + const dialog = getSheet().closest('dialog') as HTMLElement; + // The page beside it is live: no shield covers it, and its button + // takes the tap. + expect([...dialog.children].some(isInputShield)).toBe(false); + expect(isInHeldLayer(dialog)).toBe(false); + fireEvent.click(screen.getByRole('button', {name: 'Page'})); + expect(onPage).toHaveBeenCalledTimes(1); + // A tap on the entering sheet activates nothing. + const inside = screen.getByRole('button', {name: 'Inside', hidden: true}); + fireEvent.pointerDown(inside, {pointerType: 'touch'}); + fireEvent.pointerUp(inside, {pointerType: 'touch'}); + fireEvent.click(inside); + expect(onInside).not.toHaveBeenCalled(); + // Landed: the sheet takes taps again. + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + fireEvent.pointerDown(inside, {pointerType: 'touch'}); + fireEvent.pointerUp(inside, {pointerType: 'touch'}); + fireEvent.click(inside); + expect(onInside).toHaveBeenCalledTimes(1); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('keeps keys and assistive technology live while the sheet enters (FR35)', async () => { + recordAnimate(); + const onOpenChange = vi.fn(); + const {rerender} = render( + + + , + ); + rerender( + + + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + const dialog = screen.getByRole('dialog'); + expect(isInHeldLayer(dialog)).toBe(true); + // Held for pointers only: the content stays in the accessibility tree + // and keyboard focus can reach it. + expect(dialog).not.toHaveAttribute('inert'); + expect(dialog).not.toHaveAttribute('aria-hidden'); + const inside = screen.getByRole('button', {name: 'Inside'}); + inside.focus(); + expect(document.activeElement).toBe(inside); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('a close at 100 ms waits for the sheet to land, then dismisses from the detent (F4 b59)', async () => { + const played = recordAnimate(); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + const before = travelOf(played, getSheet()).length; + const dialog = screen.getByRole('dialog'); + const sheet = getSheet(); + rerender( + + Content + , + ); + // No reversal: nothing new plays until the entrance lands. + expect(travelOf(played, sheet)).toHaveLength(before); + expect(dialog).toHaveAttribute('open'); + await act(async () => { + await vi.advanceTimersByTimeAsync(800); + }); + // Then a full dismissal from the detent. + const leaving = travelOf(played, sheet).slice(before); + expect(leaving[0]).toBeCloseTo(0, 0); + expect(leaving.at(-1)).toBeCloseTo(500, 0); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(dialog).not.toHaveAttribute('open'); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('a close queued during the entrance that a reopen supersedes plays no dismissal: the sheet lands and stays (architecture:interaction-modality INV8)', async () => { + const played = recordAnimate(); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + const before = travelOf(played, getSheet()).length; + const dialog = screen.getByRole('dialog'); + const sheet = getSheet(); + // Escape at 100 ms: the close waits for the landing (F4 b59)... + rerender( + + Content + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + // ...and the sheet is opened again before it lands: the newer intent + // owns the outcome. + rerender( + + Content + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + // Nothing carried it off screen: the 500 px dismissal never played. + const after = travelOf(played, sheet).slice(before); + expect(after.filter(y => y > 1)).toEqual([]); + expect(dialog).toHaveAttribute('open'); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('a sheet reopened during its exit springs back from where it is (spec:AST-094 FR6)', async () => { + const played = recordAnimate(); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + const sheet = getSheet(); + const dialog = screen.getByRole('dialog'); + // Closed: the dismissal spring runs toward 500 px. + rerender( + + Content + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + const before = travelOf(played, sheet).length; + // Reopened 120 ms in, partway down. + rerender( + + Content + , + ); + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + const back = travelOf(played, sheet).slice(before); + // A spring from where the sheet was, back to the detent: no jump. + expect(back.length).toBeGreaterThan(2); + expect(back[0]).toBeGreaterThan(20); + expect(back.at(-1)).toBeCloseTo(0, 0); + expect(dialog).toHaveAttribute('open'); + delete (Element.prototype as {animate?: unknown}).animate; + }); + + it('opens and closes on the sheet spring, closing the dialog when it lands', async () => { + const played: {keyframes: Keyframe[]; target: Element}[] = []; + // jsdom has no Web Animations: record what the animator plays. + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function (this: Element, keyframes: Keyframe[]) { + played.push({keyframes, target: this}); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + const {rerender} = render( + + Content + , + ); + rerender( + + Content + , + ); + vi.spyOn(getSheet(), 'getBoundingClientRect').mockReturnValue( + rect({top: 300, bottom: 848}), + ); + await act(async () => { + await Promise.resolve(); + }); + const travel = (target: Element) => + played + .filter(p => p.target === target) + .flatMap(p => p.keyframes) + .filter(frame => frame.translate != null) + .map(frame => + Number.parseFloat(String(frame.translate).split(' ')[1]), + ); + // In from below the screen: 500 px of travel to its rest. + const opening = travel(getSheet()); + expect(opening[0]).toBeCloseTo(500, 0); + expect(opening.at(-1)).toBeCloseTo(0, 0); + const dialog = screen.getByRole('dialog'); + expect(dialog.style.getPropertyValue('--_sheet-scrim-duration')).toBe( + '0s', + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(dialog.style.getPropertyValue('--_sheet-scrim-duration')).toBe(''); + + played.length = 0; + rerender( + + Content + , + ); + // Out below the screen on the dismiss spring, not the stylesheet exit. + const closing = travel(getSheet()); + expect(closing[0]).toBeCloseTo(0, 0); + expect(closing.at(-1)).toBeCloseTo(500, 0); + expect(dialog).toHaveAttribute('open'); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(dialog).not.toHaveAttribute('open'); + delete (Element.prototype as {animate?: unknown}).animate; + }); + }); + describe('swipe to dismiss', () => { it('requests close when dragged past the dismiss threshold', () => { const onOpenChange = vi.fn(); @@ -1052,12 +1695,142 @@ describe('BottomSheet', () => { Content , ); - // No measured height in jsdom (0) -> any downward drag dismisses via - // the distance branch (offset > 0.25) once released downward. - drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]); + measureTallSheet(); + // Released at rest more than halfway from open to gone (736px): the + // nearest position is gone. + dragSlowly(getHandle(), 400); expect(onOpenChange).toHaveBeenCalledWith(false); }); + it('pins the scrim easing while a drag moves the dim, and hands it back when the sheet lands', async () => { + // A finger, on any touch device (useSheetGestures.ts). + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query.includes('pointer: coarse') && + !query.includes('prefers-reduced-motion'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + escape: (value: string) => value, + }); + render( + + Content + , + ); + measureTallSheet(); + const dialog = screen.getByRole('dialog'); + const handle = getHandle(); + fireTimedPointer(handle, 'pointerdown', {time: 0, y: 0}); + // The dim follows the finger with no easing of its own. + expect(dialog.style.getPropertyValue('--_sheet-scrim-duration')).toBe( + '0s', + ); + fireTimedPointer(handle, 'pointermove', {time: 1000, y: 200}); + // Gone is 736px below open (the sheet's visible height): the dim is + // linear in position to there. + expect( + Number(dialog.style.getPropertyValue('--_sheet-scrim-opacity')), + ).toBeCloseTo((736 - 200) / 736, 2); + fireTimedPointer(handle, 'pointerup', {time: 2000, y: 200}); + await landSpring(); + // Back at rest the host's own easing is restored, for its open and + // close fades, at the dim the sheet came to rest with. + expect(dialog.style.getPropertyValue('--_sheet-scrim-duration')).toBe(''); + expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'}); + }); + + it('under a finger, a release on a sheet stacked above another plays no dim on its transparent backdrop (one scrim per stack)', async () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + escape: (value: string) => value, + }); + // jsdom has no Web Animations: record what plays on each ::backdrop. + const backdrops: Element[] = []; + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function ( + this: Element, + _keyframes: Keyframe[], + options?: KeyframeAnimationOptions & {pseudoElement?: string}, + ) { + if (options?.pseudoElement === '::backdrop') { + backdrops.push(this); + } + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + function Two({isOrderOpen}: {isOrderOpen: boolean}) { + return ( + <> + + List + + + Detail + + + ); + } + const {rerender} = render(); + const panelOf = (label: string) => + screen + .getByRole('dialog', {name: label, hidden: true}) + .querySelector('.astryx-bottom-sheet') as HTMLElement; + const handleOf = (label: string) => + panelOf(label).querySelector( + '[aria-hidden="true"]', + ) as HTMLElement; + const releaseOn = async (label: string) => { + vi.spyOn(panelOf(label), 'getBoundingClientRect').mockReturnValue( + rect({top: 16, bottom: 800}), + ); + fireTimedPointer(handleOf(label), 'pointerdown', {time: 0, y: 0}); + fireTimedPointer(handleOf(label), 'pointermove', {time: 50, y: 120}); + fireTimedPointer(handleOf(label), 'pointerup', {time: 66, y: 140}); + await landSpring(); + }; + // On its own the sheet's backdrop is the scrim: the release plays the + // dim there. + await releaseOn('Orders'); + const orders = screen.getByRole('dialog', {name: 'Orders', hidden: true}); + expect(backdrops).toContain(orders); + backdrops.length = 0; + // A sheet opened over it stacks on top with a transparent backdrop: its + // release moves only the sheet, and the page keeps the one dim. + rerender(); + await landSpring(); + backdrops.length = 0; + await releaseOn('Order'); + expect(backdrops).toEqual([]); + delete (Element.prototype as {animate?: unknown}).animate; + }); + it('restores the sheet when a context menu interrupts an active drag', () => { const onOpenChange = vi.fn(); render( @@ -1075,10 +1848,12 @@ describe('BottomSheet', () => { isPrimary: true, }); fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300}); - expect(getSheet().style.transform).toBe('translateY(300px)'); + expect(translationOf(getSheet())).toBe(300); expect(fireEvent.contextMenu(handle)).toBe(false); + // Back to the detent on the detent spring (jsdom: its end at once). + expect(translationOf(getSheet())).toBe(0); expect(getSheet().style.transform).toBe(''); expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'}); expect(onOpenChange).not.toHaveBeenCalled(); @@ -1118,7 +1893,7 @@ describe('BottomSheet', () => { } }); - it('keeps the full layout height at the peek detent', () => { + it('keeps the full layout height at the peek detent', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1147,7 +1922,7 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 600}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 600}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); // A glance state does not reflow the content into a sliver: the sheet // keeps its full layout height and slides below the viewport instead. @@ -1159,13 +1934,13 @@ describe('BottomSheet', () => { // reason — there is no shortened layout to restore first. fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 600}); fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 400}); - expect(sheet.style.transform).toBe('translateY(424px)'); + expect(translationOf(sheet)).toBe(424); expect(sheet.style.height).toBe('784px'); expect(getBody().style.paddingBlockEnd).toBe(''); // Releasing at the taller p50 detent resizes the scrolling area. fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 400}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); expect(sheet.style.height).toBe('448px'); expect(sheet.style.transform).toBe(''); }); @@ -1199,7 +1974,7 @@ describe('BottomSheet', () => { // so the release has no travel left to animate and reconciles at once. fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336}); - expect(sheet.style.transform).toBe('translateY(336px)'); + expect(translationOf(sheet)).toBe(336); expect(sheet.style.height).toBe('784px'); // Hold the reconciliation frame so the intermediate render is visible @@ -1217,6 +1992,7 @@ describe('BottomSheet', () => { // would lurch away from the detent before coming back to it. expect(sheet.style.height).toBe('448px'); expect(sheet.style.transform).toBe(''); + expect(translationOf(sheet)).toBe(0); expect(sheet.style.transition).toBe('none'); // Transitions come back for the next gesture. @@ -1224,7 +2000,7 @@ describe('BottomSheet', () => { expect(sheet.style.transition).toBe(''); }); - it('uses transforms while dragging and resizes to the visible snapped height', () => { + it('uses transforms while dragging and resizes to the visible snapped height', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1251,17 +2027,20 @@ describe('BottomSheet', () => { ); }); + giveThemeTransition(sheet); fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240}); - expect(sheet.style.transform).toBe('translateY(240px)'); + expect(translationOf(sheet)).toBe(240); expect(sheet.style.height).toBe('784px'); expect(sheet.style.transition).toBe('none'); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240}); - // Release remains transform-only until the snap finishes. - // p50 is a visible 400px sheet: 784 - 48 - 400 = 336px offset. - expect(sheet.style.transform).toBe('translateY(336px)'); + // Release remains transform-only until the snap finishes: the settle + // runs on the travel channel (jsdom has no Web Animations, so its end + // value is what shows). p50 is a visible 400px sheet: 784 - 48 - 400 = + // 336px offset. + expect(translationOf(sheet)).toBe(336); expect(sheet.style.height).toBe('784px'); expect(sheet.style.transition).toBe(''); const reconciliationFrames: FrameRequestCallback[] = []; @@ -1269,8 +2048,9 @@ describe('BottomSheet', () => { reconciliationFrames.push(callback); return reconciliationFrames.length; }); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); expect(sheet.style.transform).toBe(''); + expect(translationOf(sheet)).toBe(0); expect(sheet.style.height).toBe('448px'); // The transform reset is transition-free so it cannot produce a second // fly-in animation after the snap has already reached its destination. @@ -1289,7 +2069,7 @@ describe('BottomSheet', () => { }); fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240}); fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 140}); - expect(sheet.style.transform).toBe('translateY(236px)'); + expect(translationOf(sheet)).toBe(236); expect(sheet.style.height).toBe('784px'); expect(getBody().style.paddingBlockEnd).toBe('336px'); @@ -1297,12 +2077,12 @@ describe('BottomSheet', () => { // translates only the distance traveled from that detent. The temporary // scroll-preservation inset leaves with the temporary expanded layout. fireTimedPointer(getHandle(), 'pointermove', {time: 5000, y: 340}); - expect(sheet.style.transform).toBe('translateY(100px)'); + expect(translationOf(sheet)).toBe(100); expect(sheet.style.height).toBe('448px'); expect(getBody().style.paddingBlockEnd).toBe(''); }); - it('reconciles the snapped height immediately when transitions are disabled', () => { + it('reconciles the snapped height when the spring lands, with transitions disabled', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1331,12 +2111,15 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240}); + // The settle is a spring, not a CSS transition: it does not wait for a + // transitionend that never comes. + await landSpring(); expect(sheet.style.transform).toBe(''); expect(sheet.style.height).toBe('448px'); }); - it('restores the maximum height before an upward drag and reconciles at snap', () => { + it('restores the maximum height before an upward drag and reconciles at snap', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1362,30 +2145,31 @@ describe('BottomSheet', () => { }); // Start at the middle 400px detent. + giveThemeTransition(sheet); fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); expect(sheet.style.height).toBe('448px'); // The upward gesture renders the full 784px surface below the viewport // and translates it to preserve the visible top edge. fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240}); fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 0}); - expect(sheet.style.transform).toBe('translateY(96px)'); + expect(translationOf(sheet)).toBe(96); expect(sheet.style.height).toBe('784px'); expect(getBody().style.paddingBlockEnd).toBe('336px'); // Release first animates only the transform, keeping the source layout // and scroll range fixed for the entire snap. fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 0}); - expect(sheet.style.transform).toBe(''); + expect(translationOf(sheet)).toBe(0); expect(sheet.style.height).toBe('784px'); expect(getBody().style.paddingBlockEnd).toBe('336px'); - // At transition end, height and preservation spacing reconcile in one - // render with the same visible geometry. - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + // When the spring lands, height and preservation spacing reconcile in + // one render with the same visible geometry. + await landSpring(); expect(sheet.style.transform).toBe(''); expect(sheet.style.height).toBe(''); expect(getBody().style.paddingBlockEnd).toBe('336px'); @@ -1402,7 +2186,7 @@ describe('BottomSheet', () => { expect(body.style.paddingBlockEnd).toBe(''); }); - it('keeps the settled height stable while dismissing', () => { + it('keeps the settled height stable while dismissing', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1422,14 +2206,14 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); expect(sheet.style.height).toBe('448px'); // A later dismiss stays transform-only and preserves that settled // scroll-area height throughout the exit. fireTimedPointer(getHandle(), 'pointerdown', {time: 3000, y: 240}); fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 1000}); - expect(sheet.style.transform).toBe('translateY(760px)'); + expect(translationOf(sheet)).toBe(760); expect(sheet.style.height).toBe('448px'); fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 1000}); expect(sheet.style.height).toBe('448px'); @@ -1443,7 +2227,7 @@ describe('BottomSheet', () => { expect(sheet.style.height).toBe('448px'); }); - it('re-resolves the settled detent when the window resizes', () => { + it('re-resolves the settled detent when the window resizes', async () => { const observers = mockResizeObserverInstances(); mockVisualViewport(800); mockWindowHeight(800); @@ -1477,7 +2261,7 @@ describe('BottomSheet', () => { reconciliationFrames.push(callback); return reconciliationFrames.length; }); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); act(() => reconciliationFrames.splice(0).forEach(frame => frame(0))); // 448px of layout height shows 400px of sheet: half the 800px window. expect(sheet.style.height).toBe('448px'); @@ -1508,7 +2292,7 @@ describe('BottomSheet', () => { expect(sheet.style.transition).toBe(''); }); - it('keeps its detents when the keyboard shrinks the visual viewport', () => { + it('keeps its detents when the keyboard shrinks the visual viewport', async () => { const observers = mockResizeObserverInstances(); const viewport = mockVisualViewport(800); mockWindowHeight(800); @@ -1536,7 +2320,7 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 336}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 336}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); expect(sheet.style.height).toBe('448px'); // The keyboard opens: the visual viewport shrinks, the layout viewport @@ -1553,7 +2337,7 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointermove', {time: 4000, y: 288}); fireTimedPointer(getHandle(), 'pointerup', {time: 5000, y: 288}); // The peek of an 800px window: 736 - 112 = 624px of travel. - expect(sheet.style.transform).toBe('translateY(624px)'); + expect(translationOf(sheet)).toBe(624); }); }); @@ -1590,56 +2374,56 @@ describe('BottomSheet', () => { return {sheet, view}; } - function dragHandleTo(y: number) { + async function dragHandleTo(y: number) { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y}); - fireEvent.transitionEnd(getSheet(), {propertyName: 'transform'}); + await landSpring(); } - it('has no stops of its own, so a released drag springs back', () => { + it('has no stops of its own, so a released drag springs back', async () => { const onOpenChange = vi.fn(); const {sheet} = renderTallSheet(undefined, onOpenChange); // 200px down is well short of the dismiss threshold, and there is no // stop to catch it, so the sheet returns to fully open. - dragHandleTo(200); + await dragHandleTo(200); expect(sheet.style.transform).toBe(''); expect(sheet.style.height).toBe(''); expect(onOpenChange).not.toHaveBeenCalled(); }); - it('rests at a stop given as a fraction of the viewport', () => { + it('rests at a stop given as a fraction of the viewport', async () => { const {sheet} = renderTallSheet([0.5]); // Half of the 800px window is 400px of visible sheet: 736 - 400 = 336px // of travel, taken as layout height once the snap lands. - dragHandleTo(336); + await dragHandleTo(336); expect(sheet.style.height).toBe('448px'); expect(sheet.style.transform).toBe(''); }); - it('reads a percentage as the same stop as the fraction', () => { + it('reads a percentage as the same stop as the fraction', async () => { const {sheet} = renderTallSheet(['50%']); - dragHandleTo(336); + await dragHandleTo(336); expect(sheet.style.height).toBe('448px'); }); - it('rests at a stop given as an absolute px length', () => { + it('rests at a stop given as an absolute px length', async () => { const {sheet} = renderTallSheet(['320px']); // A 320px stop sits 736 - 320 = 416px down, whatever the window does. - dragHandleTo(416); + await dragHandleTo(416); expect(sheet.style.height).toBe('368px'); expect(sheet.style.transform).toBe(''); }); - it('re-anchors to the same stop when the points change under a resting sheet', () => { + it('re-anchors to the same stop when the points change under a resting sheet', async () => { const {sheet, view} = renderTallSheet([0.5]); - dragHandleTo(336); + await dragHandleTo(336); expect(sheet.style.height).toBe('448px'); // Without an inline height the sheet renders its natural 92dvh budget. @@ -1671,7 +2455,7 @@ describe('BottomSheet', () => { expect(sheet.style.transform).toBe(''); }); - it('ignores a stop it cannot resolve, and warns which one', () => { + it('ignores a stop it cannot resolve, and warns which one', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); // 200 is the px mistake: a bare number is a fraction, never a length. const {sheet} = renderTallSheet([0.5, 200]); @@ -1681,7 +2465,7 @@ describe('BottomSheet', () => { ).toBe(true); // The stop it could read still works. - dragHandleTo(336); + await dragHandleTo(336); expect(sheet.style.height).toBe('448px'); warn.mockRestore(); }); @@ -1931,7 +2715,7 @@ describe('BottomSheet', () => { expect(body.scrollTop).toBe(0); }); - it('does not accommodate the keyboard at a shorter Tall detent', () => { + it('does not accommodate the keyboard at a shorter Tall detent', async () => { mockIOSWebKit(); const observers = mockResizeObserverInstances(); const viewport = mockVisualViewport(800); @@ -1963,7 +2747,7 @@ describe('BottomSheet', () => { fireTimedPointer(getHandle(), 'pointerdown', {time: 0, y: 0}); fireTimedPointer(getHandle(), 'pointermove', {time: 1000, y: 240}); fireTimedPointer(getHandle(), 'pointerup', {time: 2000, y: 240}); - fireEvent.transitionEnd(sheet, {propertyName: 'transform'}); + await landSpring(); // Settled at the p50 detent: 400px of visible sheet plus the 48px // border-box reserve held below the viewport. expect(sheet.style.transform).toBe(''); diff --git a/packages/core/src/BottomSheet/BottomSheet.tsx b/packages/core/src/BottomSheet/BottomSheet.tsx index bffdec265767f..65f25906eb9a1 100644 --- a/packages/core/src/BottomSheet/BottomSheet.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.tsx @@ -4,7 +4,7 @@ /** * @file BottomSheet.tsx - * @input Uses React, StyleX, core hooks, shared layer dismissal, the modal outlet registry, the shared interaction modality, named BottomSheetPanel, BottomSheetSwitcherContext, BottomSheetHostContext, the private bottom-sheet stack registry + * @input Uses React, StyleX, core hooks, shared layer dismissal, the layer input hold, the sheet self hold, the modal outlet registry, the shared interaction modality, named BottomSheetPanel, BottomSheetSwitcherContext, BottomSheetHostContext, the private bottom-sheet stack registry * @output Exports BottomSheet component and BottomSheetProps * @position Public BottomSheet router plus private standalone/switcher hosts * @@ -97,6 +97,7 @@ import { easeVars, spacingVars, } from '../theme/tokens.stylex'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {useDevWarning} from '../hooks/useDevWarning'; import {useScrollLock} from '../hooks/useScrollLock'; import {mergeProps} from '../utils'; @@ -104,6 +105,8 @@ import {LayerDepthProvider} from '../Layer/LayerDepthContext'; import {ModalAnnounceProvider} from '../hooks/ModalAnnounceProvider'; import {useModalOutlet} from '../Layer/modalOutlet'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; +import {useInputHold} from '../Layer/inputHold'; +import {useSheetSelfHold} from './sheetSelfHold'; import {focusForLastInput} from '../utils/focusReturn'; import {useInteractionModalityTracking} from '../utils/interactionModality'; import { @@ -207,11 +210,23 @@ const styles = stylex.create({ // The scrim changes hands between stacked sheets with no fade at all: // the host pins this to 0s inline for the frame the change lands in // (see `writeScrimOpacity`). Reduced motion keeps the pin too. + // Under reduced motion (design:reduced-motion) the scrim fades in + // 200 ms on a touch screen (DR2) and changes at once with a fine + // pointer (DR3), the pin still overriding either. transitionDuration: `var(--_sheet-scrim-duration, ${durationVars['--duration-medium']})`, transitionTimingFunction: easeVars['--ease-standard'], transitionBehavior: 'allow-discrete', - '@media (prefers-reduced-motion: reduce)': { - transitionDuration: 'var(--_sheet-scrim-duration, 0.01s)', + // The backdrop keeps its own compositing layer while the sheet is open. + // A release plays the dim on the compositor; a backdrop given a layer + // only for that animation is re-rastered as it gets one and again as it + // loses it, which costs a frame on a software GPU. + willChange: 'opacity', + '@media (prefers-reduced-motion: reduce) and (pointer: coarse)': { + transitionDuration: `var(--_sheet-scrim-duration, ${reducedMotionFadeConsts.duration})`, + transitionTimingFunction: reducedMotionFadeConsts.easing, + }, + '@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))': { + transitionDuration: 'var(--_sheet-scrim-duration, 0s)', }, }, }, @@ -500,6 +515,12 @@ function StandaloneBottomSheet({ writeScrimOpacity(dialogRef.current, opacity, scrimWriteRef.current), [], ); + // The same rule for the dim a release plays on the backdrop itself: only + // the stack's visible backdrop carries it. + const isScrimShown = useCallback(() => { + const view = stackViewRef.current; + return !(view.isStacked && !view.ownsScrim) && !view.isScrimTakenOver; + }, []); const handleScrimOpacity = useCallback((opacity: number) => { // Only the sheet whose backdrop is the visible scrim thins it; a sheet // stacked above it has a transparent backdrop that a drag must not reveal. @@ -512,6 +533,36 @@ function StandaloneBottomSheet({ String(opacity), ); }, []); + // While the sheet's gesture or its release moves the dim, the backdrop + // follows it with no easing of its own (the release plays it in lockstep + // with the sheet). The pin is lifted only after a style flush has applied + // the last value, so that value does not ease in again. + // Under a finger every touch presentation holds pointer input while it + // enters (`spec:AST-094` FR35, DEC-16), as UIKit drops such a touch (F4 + // b59). A modal sheet's hold is the layer's, over the sheet and the scrim + // until the sheet lands; a non-modal sheet holds only a tap or a drag on + // itself, and the page beside it stays live. Keys and assistive technology + // stay live. + const [isEntranceHeld, setIsEntranceHeld] = useState(false); + useInputHold(dialogRef, { + isHeld: isEntranceHeld && hasScrim, + scope: 'viewport', + }); + useSheetSelfHold(dialogRef, isEntranceHeld && !hasScrim); + const handleScrimTracking = useCallback((tracking: boolean) => { + const dialog = dialogRef.current; + if (dialog == null) { + return; + } + if (tracking) { + dialog.style.setProperty('--_sheet-scrim-duration', '0s'); + return; + } + if (typeof getComputedStyle === 'function') { + void getComputedStyle(dialog, '::backdrop').opacity; + } + dialog.style.removeProperty('--_sheet-scrim-duration'); + }, []); // A sheet joins the registry for as long as it is presented. For a modal // sheet the registry shows the dialog (in open order, repairing same-commit @@ -830,6 +881,10 @@ function StandaloneBottomSheet({ xstyle={xstyle} onDismiss={dismissOnLightInteraction} onScrimOpacity={handleScrimOpacity} + onScrimTracking={hasScrim ? handleScrimTracking : undefined} + isScrimShown={hasScrim ? isScrimShown : undefined} + hasSpringPresentation + onPresentingChange={setIsEntranceHeld} onElementChange={handlePanelElementChange} onMotionStart={handleMotionStart} onMotionComplete={handleMotionComplete}> @@ -917,6 +972,15 @@ function SwitcherBottomSheetItem({ const isBlocked = isInactive || (hasScrim && stackView.isBlocked); const isHostSheet = hasValidSheetId && hostSheet === sheetId; const dialogRef = useRef(null); + // A step's sheet enters on the stylesheet; under a finger it holds pointer + // input until that entrance ends, as a lone sheet does, a non-modal flow + // only on itself (`spec:AST-094` FR35, DEC-16). + const [isEntranceHeld, setIsEntranceHeld] = useState(false); + useInputHold(dialogRef, { + isHeld: isEntranceHeld && hasScrim, + scope: 'viewport', + }); + useSheetSelfHold(dialogRef, isEntranceHeld && !hasScrim); const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null); const panelRef = useRef(null); const previousPhaseRef = useRef(phase); @@ -1167,6 +1231,7 @@ function SwitcherBottomSheetItem({ xstyle={xstyle} onDismiss={dismissOnSwipe} onScrimOpacity={handleScrimOpacity} + onPresentingChange={setIsEntranceHeld} onElementChange={handlePanelElementChange} onMotionStart={handleMotionStart} onMotionComplete={handleMotionComplete}> diff --git a/packages/core/src/BottomSheet/BottomSheetPanel.tsx b/packages/core/src/BottomSheet/BottomSheetPanel.tsx index 502210f2d1917..f1b00bccd0795 100644 --- a/packages/core/src/BottomSheet/BottomSheetPanel.tsx +++ b/packages/core/src/BottomSheet/BottomSheetPanel.tsx @@ -49,6 +49,7 @@ import { spacingVars, } from '../theme/tokens.stylex'; import {mergeProps, themeProps} from '../utils'; +import {useTouchPresentation} from '../utils/touchPresentation'; import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import {layerTextReset} from '../Layer/layerTextReset.stylex'; import {layerStructureReset} from '../Layer/layerStructureReset.stylex'; @@ -81,7 +82,9 @@ const HEIGHT_BUDGETS = { export type BottomSheetHeight = keyof typeof HEIGHT_BUDGETS; export type {BottomSheetSnapPoint}; -// SYNC: must match OVERSCROLL_MAX in useSheetGestures.ts. +// How far the surface extends below the viewport, so a release that carries +// the sheet past fully open (the 0.8-damped spring overshoots) never shows a +// gap under it. const OVERSCROLL_PADDING = 48; const MOBILE_KEYBOARD_BOTTOM_CLEARANCE = 48; const TRANSITION_BACKSTOP_BUFFER_MS = 50; @@ -240,6 +243,16 @@ const styles = stylex.create({ sheetFading: { opacity: 0, }, + // Under a finger the standalone sheet opens and closes on UIKit's spring + // (useSheetGestures `present` / `dismiss`), on the compositor: the + // stylesheet's entrance and exit transforms must not also run. Nor its + // reduced-motion cross-fade: the animator plays that one (DR2), and reads + // the sheet's opacity to fade it in, which a stylesheet fade from 0 would + // still be holding at 0. + sheetSprung: { + transitionProperty: 'opacity', + opacity: null, + }, sheetInactive: { pointerEvents: 'none', }, @@ -369,6 +382,23 @@ interface BottomSheetPanelProps extends BaseProps { isPageScrollLocked?: boolean; onDismiss: () => void; onScrimOpacity: (opacity: number) => void; + /** Given by a host whose scrim is the dialog's ::backdrop; see useSheetGestures. */ + onScrimTracking?: (tracking: boolean) => void; + /** Whether that ::backdrop is the stack's visible scrim; see useSheetGestures. */ + isScrimShown?: () => boolean; + /** + * Under a finger (any touch device), the sheet opens and closes + * on UIKit's sheet spring (useSheetGestures `present` / `dismiss`) instead + * of the stylesheet's entrance and exit. Set by the standalone sheet. + */ + hasSpringPresentation?: boolean; + /** + * True while the sheet enters under a finger: on UIKit's present spring + * (useSheetGestures `present`), or, where the sheet does not spring (a + * switcher's step), on the stylesheet's entrance until its transition ends. + * The host holds pointer input meanwhile (`spec:AST-094` FR35). + */ + onPresentingChange?: (presenting: boolean) => void; onElementChange?: (element: HTMLDivElement | null) => void; onMotionStart?: (motion: BottomSheetPanelMotion) => void; onMotionComplete?: (motion: BottomSheetPanelMotion) => void; @@ -514,6 +544,10 @@ export function BottomSheetPanel({ isPageScrollLocked = false, onDismiss, onScrimOpacity, + onScrimTracking, + isScrimShown, + hasSpringPresentation = false, + onPresentingChange, onElementChange, onMotionStart, onMotionComplete, @@ -554,6 +588,11 @@ export function BottomSheetPanel({ const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); const alignmentOffset = isRetained && !isReducedMotion ? state.alignmentOffset : 0; + // Under a finger, on every touch device (one touch model), the standalone + // sheet opens and closes on UIKit's spring; under a mouse on today's + // transitions. + const {isTouch} = useTouchPresentation(); + const springsPresentation = hasSpringPresentation && isTouch; // Everything derived from the snap points keys off their identity, not the // array's: the resolver's identity is the hook's signal that the stops @@ -600,7 +639,11 @@ export function BottomSheetPanel({ scrollPreservationInset, settlingLayoutOffset, settledLayoutOffset, - completeScrollAreaSettle, + gestureExitRef, + resetTravel, + present, + afterEntrance, + dismiss, } = useSheetGestures({ isOpen: isInteractive, isOpenRequested: isOpenRequested ?? isInteractive, @@ -609,6 +652,9 @@ export function BottomSheetPanel({ onDismiss, snapHeights, onScrimOpacity, + onScrimTracking, + isScrimShown, + onPresentingChange, }); const {getViewportProps, getContentProps} = useScrollableArea({ @@ -675,6 +721,41 @@ export function BottomSheetPanel({ pendingMotionCompleteRef.current = motion; return; } + const complete = () => { + if (startedMotionRef.current === motion) { + onMotionCompleteRef.current?.(motion); + } else { + pendingMotionCompleteRef.current = motion; + } + }; + // Under a finger a sheet that replaces another arrives on UIKit's present + // spring, as any sheet it opens does: its entrance is over when the spring + // lands, and only then does the replaced sheet fade. It is entering from + // its first commit, so the spring starts here rather than in the effect + // that presents a lone sheet. + if (motion === 'entering' && springsPresentation) { + present(); + return afterEntrance(complete); + } + // A sheet dismissed by a drag leaves on its release spring (the gesture + // hook carries it off screen), not on the exit transition: its exit is + // over when that spring lands. Under a finger any other close leaves on + // UIKit's dismiss spring the same way. + const gestureExit = + motion === 'exiting' + ? (gestureExitRef.current ?? (springsPresentation ? dismiss() : null)) + : null; + if (gestureExit != null) { + let isCurrent = true; + void gestureExit.finished.then(() => { + if (isCurrent) { + complete(); + } + }); + return () => { + isCurrent = false; + }; + } // What ends a motion: the fade is opacity; the entrance, alignment and // exit are transforms — except that under the reduced-motion cross-fade // (DR2) an exit fades out where it is and its dialog may close only once @@ -685,29 +766,74 @@ export function BottomSheetPanel({ return waitForTransition( elementRef.current, motion === 'fading' || crossFadeMotion ? 'opacity' : 'transform', - () => { - if (startedMotionRef.current === motion) { - onMotionCompleteRef.current?.(motion); - } else { - pendingMotionCompleteRef.current = motion; - } - }, + complete, ); - }, [motion, isCrossFade]); - // The snap is transform-only; the layout height reconciles when it lands. - // waitForTransition — the same helper the motion states use — resolves that - // even when no `transitionend` is coming: inline or computed - // `transition: none`, a zero duration, and a timer backstop otherwise. + }, [ + afterEntrance, + dismiss, + gestureExitRef, + isCrossFade, + motion, + present, + springsPresentation, + ]); + // Under a finger the sheet arrives on UIKit's present spring. From an + // effect, so the dim is taken over before the host shows the dialog (a + // child's effects run before its host's). + const wasPresentedRef = useRef(false); + useEffect(() => { + const opens = + state.kind === 'open' && !state.entering && !wasPresentedRef.current; + wasPresentedRef.current = state.kind !== 'hidden'; + if (opens && springsPresentation) { + present(); + } + }, [present, springsPresentation, state]); + // Under a finger a stylesheet entrance (a switcher's step) holds pointer + // input as the spring entrance does (`spec:AST-094` FR35): from the frame + // the sheet opens, or its swap starts, until its transition ends. Keyed on + // the state's kind, not its identity: a host renders a new state object on + // every render. + const onPresentingChangeRef = useRef(onPresentingChange); + useLayoutEffect(() => { + onPresentingChangeRef.current = onPresentingChange; + }, [onPresentingChange]); + const entranceHeldRef = useRef(false); + const holdEntrance = useCallback((isHeld: boolean) => { + if (entranceHeldRef.current !== isHeld) { + entranceHeldRef.current = isHeld; + onPresentingChangeRef.current?.(isHeld); + } + }, []); + const stateKind = state.kind; + const wasOpenRef = useRef(false); useLayoutEffect(() => { - if (settlingLayoutOffset == null) { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = stateKind === 'open'; + if (!isTouch || springsPresentation) { + holdEntrance(false); return; } + if (stateKind !== 'open' || (!isEntering && wasOpen)) { + holdEntrance(false); + return; + } + holdEntrance(true); + // Under reduced motion on a coarse pointer the entrance is the opacity + // cross-fade (design:reduced-motion DR2) and its transform is instant. return waitForTransition( elementRef.current, - 'transform', - completeScrollAreaSettle, + isCoarseCrossFade ? 'opacity' : 'transform', + () => holdEntrance(false), ); - }, [completeScrollAreaSettle, settlingLayoutOffset]); + }, [ + holdEntrance, + isCoarseCrossFade, + isEntering, + isTouch, + springsPresentation, + stateKind, + ]); useEffect(() => { if (motion == null) { return; @@ -784,22 +910,30 @@ export function BottomSheetPanel({ ? retainedTransform : undefined; const consumerTransform = style?.transform; - // The transform is written to the element here, not rendered through the - // style prop. A drag writes it straight to the element once per input - // sample (useSheetGestures) and renders nothing in between, so React never - // sees those writes; rendered through the prop, the resting value would be - // skipped as "unchanged" when the drag ends and the sheet would stay where - // the finger left it. Written after every commit instead, outside a drag, - // the resting value lands in the same style update as the restored - // transition, so a settle or a dismissal animates from the live position. + // The resting transform is written to the element here, after every + // commit, not rendered through the style prop: the gesture hook moves the + // sheet on the separate `translate` channel (a drag once per input sample, + // a release on its spring), and the new resting value is written once that + // travel is over, in the same commit as the layout reconcile, with the + // travel folded into it (resetTravel). While a release spring runs, the + // previous resting transform stays: the travel is measured from it. A + // drag-dismissed sheet keeps it through its exit too, which also holds off + // the closing style's transform, since the release spring carries it out. useLayoutEffect(() => { if (isDragging || consumerTransform != null) { return; } const element = elementRef.current; - if (element != null) { - element.style.transform = sheetTransform ?? ''; + if (element == null || settlingLayoutOffset != null) { + return; + } + const gestureExit = gestureExitRef.current; + if (gestureExit != null) { + element.style.transform = gestureExit.transform; + return; } + element.style.transform = sheetTransform ?? ''; + resetTravel(); }); const gestureStyle = { transition: contentProps.style.transition, @@ -823,6 +957,8 @@ export function BottomSheetPanel({ isClosing && styles.sheetClosing, isClosing && isCrossFade && styles.sheetClosingCrossFade, isFading && styles.sheetFading, + // A replaced sheet aligns and fades on its stylesheet. + springsPresentation && !isRetained && styles.sheetSprung, isInactive && styles.sheetInactive, xstyle, ), diff --git a/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx b/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx index 28967e0403eb9..af4fe3d6b94a9 100644 --- a/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetReplace.test.tsx @@ -28,6 +28,8 @@ import { resetBottomSheetStackForTests, } from './bottomSheetStack'; import {__resetInteractionModalityForTest} from '../utils/interactionModality'; +import {isInHeldLayer} from '../Layer/inputHold'; +import {TOUCH_QUERY} from '../utils/touchQueries'; let nativeLog: { call: 'showModal' | 'show' | 'close' | 'showPopover' | 'hidePopover'; @@ -929,6 +931,127 @@ function stubMedia(matching: ReadonlyArray): void { ); } +describe('BottomSheet replace: under a finger (the touch layer)', () => { + // A finger: a coarse primary pointer, no Reduce Motion. Every standalone + // sheet arrives on UIKit's present spring, the replacing one too; the + // replaced sheet aligns and fades on its stylesheet once that spring lands. + beforeEach(() => { + stubMedia([TOUCH_QUERY]); + vi.useFakeTimers({toFake: ['performance', 'setTimeout', 'clearTimeout']}); + // Frames on the faked clock; jsdom has no Web Animations, so the + // animator's curves end on its timers. + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => callback(performance.now()), 16), + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id)); + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: () => ({cancel: vi.fn(), startTime: 0}) as unknown as Animation, + }); + }); + afterEach(() => { + delete (Element.prototype as {animate?: unknown}).animate; + vi.useRealTimers(); + }); + async function land() { + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + } + + it('the replacing sheet arrives on the present spring, and the replaced sheet fades only once it lands', async () => { + render(); + openFlow(); + await land(); + // The box the browser would give step two: the spring travels its height. + layout(getPanel(getDialog('Two')), 444, 400); + fireEvent.click(screen.getByRole('button', {name: 'Continue to two'})); + await act(async () => { + await Promise.resolve(); + }); + const one = getDialog('One'); + const two = getDialog('Two'); + expect(summary()).toEqual(['One:replaced-covered:0', 'Two:replacing:0']); + // A transform end is not the spring's landing. + endMotion(two, 'transform'); + expect(summary()).toEqual(['One:replaced-covered:0', 'Two:replacing:0']); + await land(); + expect(summary()).toEqual(['One:replaced-fading:0', 'Two:replacing:0']); + endMotion(one, 'opacity'); + expect(summary()).toEqual(['Two:top:0']); + }); + + it('a taller replaced sheet aligns down on its stylesheet transform', async () => { + render(); + openFlow(); + await land(); + const one = getDialog('One'); + const two = getDialog('Two'); + layout(getPanel(one), 444, 400); + layout(getPanel(two).parentElement as HTMLElement, 604, 240); + fireEvent.click(screen.getByRole('button', {name: 'Continue to two'})); + await act(async () => { + await Promise.resolve(); + }); + expect(summary()).toEqual(['One:replaced-aligning:0', 'Two:replacing:0']); + expect(getPanel(one).style.transform).toBe('translateY(160px)'); + expect(getComputedStyle(getPanel(one)).transitionProperty).toContain( + 'transform', + ); + await land(); + expect(summary()).toEqual(['One:replaced-aligning:0', 'Two:replacing:0']); + endMotion(one, 'transform'); + expect(summary()).toEqual(['One:replaced-fading:0', 'Two:replacing:0']); + }); + + it('the replacing sheet takes the dim over at full strength: its spring plays no dim, where a lone sheet’s does', async () => { + const backdrops: Element[] = []; + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value: function ( + this: Element, + _keyframes: Keyframe[], + options?: KeyframeAnimationOptions & {pseudoElement?: string}, + ) { + if (options?.pseudoElement === '::backdrop') { + backdrops.push(this); + } + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }, + }); + render(); + // The first sheet's box: its present spring brings the dim in with it. + layout(getPanel(getDialog('One')), 444, 400); + openFlow(); + await land(); + expect(backdrops).toContain(getDialog('One')); + backdrops.length = 0; + layout(getPanel(getDialog('Two')), 444, 400); + fireEvent.click(screen.getByRole('button', {name: 'Continue to two'})); + await land(); + // The dim stays where it was through the handoff: nothing dips it. + expect(backdrops).toEqual([]); + }); + + it('holds pointer input while the replacing sheet enters, and releases it as it lands (spec:AST-094 FR35)', async () => { + render(); + openFlow(); + await land(); + // The box the browser would give step two: the spring travels its height. + layout(getPanel(getDialog('Two')), 444, 400); + fireEvent.click(screen.getByRole('button', {name: 'Continue to two'})); + await act(async () => { + await Promise.resolve(); + }); + const two = getDialog('Two'); + expect(isInHeldLayer(getPanel(two))).toBe(true); + await land(); + expect(isInHeldLayer(getPanel(two))).toBe(false); + }); +}); + describe('BottomSheet replace: reduced motion (design:reduced-motion)', () => { // The CSS itself (no transform travel, an opacity-only cross-fade of at // most 200 ms on a coarse pointer, an immediate change on a fine one) is diff --git a/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx b/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx index 4e17547d600eb..79af6b0cfdb02 100644 --- a/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx +++ b/packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx @@ -10,6 +10,8 @@ * update these tests to match the public behavior. */ +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; import {act, fireEvent, render, screen} from '@testing-library/react'; import { StrictMode, @@ -22,6 +24,7 @@ import { import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {hasActiveFocusTrapEscape, useFocusTrap} from '../hooks'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; +import {isInHeldLayer} from '../Layer/inputHold'; import {Dialog} from '../Dialog'; import {hydrateRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; @@ -1076,10 +1079,15 @@ describe('BottomSheetSwitcher', () => { isPrimary: true, }); fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300}); - expect(panel.style.transform).toBe('translateY(300px)'); + // The drag moves the sheet on its travel channel (`translate`). + expect(panel.style.translate).toBe('0px 300px'); expect(fireEvent.contextMenu(handle)).toBe(false); + // Back to the detent on the detent spring (jsdom: its end at once). At + // rest an identity leaves the element (the animator writes no inline + // `translate: 0px 0px`, which would make the panel a containing block). + expect(panel.style.translate).toBe(''); expect(panel.style.transform).toBe(''); expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'}); expect(onActiveSheetChange).not.toHaveBeenCalled(); @@ -1309,6 +1317,44 @@ describe('BottomSheetSwitcher', () => { ); }); + it('under a finger, holds pointer input while each sheet enters the flow, and releases it as the entrance ends (FR35)', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + // A finger without Reduce Motion. + matches: + query.includes('pointer: coarse') && + !query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + render(); + fireEvent.click(screen.getByRole('button', {name: 'Start flow'})); + const details = getSheetLayer('details-sheet'); + // Entering: its dialog is a held layer. + expect(isInHeldLayer(details)).toBe(true); + finishSheetTransition(details, 'transform'); + expect(isInHeldLayer(details)).toBe(false); + + fireEvent.click(screen.getByRole('button', {name: 'Continue'})); + const confirm = getSheetLayer('confirm-sheet'); + expect(isInHeldLayer(confirm)).toBe(true); + finishSheetTransition(confirm, 'transform'); + expect(isInHeldLayer(confirm)).toBe(false); + }); + + it('with a mouse, holds nothing while a sheet enters the flow (FR35, DEC-16)', () => { + render(); + fireEvent.click(screen.getByRole('button', {name: 'Start flow'})); + expect(isInHeldLayer(getSheetLayer('details-sheet'))).toBe(false); + }); + it('presents the entering dialog once and closes the retained dialog only after it fades', () => { const log = recordDialogCalls(); render(); @@ -1736,31 +1782,27 @@ describe('BottomSheetSwitcher', () => { expect(getSheetDialog('c')).toHaveAttribute('role', 'alertdialog'); }); - it('keeps the scrim takeover pin under prefers-reduced-motion: the reduced duration reads the pinned variable', () => { - render(); - // jsdom evaluates neither @media nor ::backdrop; read the compiled - // rules the dialog's scrim class produced. The reduced-motion rule for - // the backdrop's transition-duration must defer to the takeover pin - // (`--_sheet-scrim-duration: 0s` for one style flush); a literal 0.01s - // there would win the cascade and paint a frame with no scrim. - const rules: string[] = []; - for (const sheet of Array.from(document.styleSheets)) { - for (const rule of Array.from(sheet.cssRules)) { - rules.push(rule.cssText); - } - } - const reducedBackdropDurations = rules.filter( - text => - text.includes('prefers-reduced-motion: reduce') && - text.includes('::backdrop') && - text.includes('transition-duration'), - ); - expect(reducedBackdropDurations.length).toBeGreaterThan(0); - for (const text of reducedBackdropDurations) { - expect(text).toMatch( - /transition-duration:\s*var\(--_sheet-scrim-duration,\s*0?\.01s\)/, + it('keeps the scrim takeover pin under reduced motion: each reduced duration reads the pinned variable', () => { + // The backdrop's reduced-motion durations must defer to the takeover pin + // (`--_sheet-scrim-duration: 0s` for one style flush); a literal there + // would win the cascade and paint a frame with no scrim. Read from the + // source: StyleX's runtime injection dedupes rules by a key that, for a + // media query with `and`, is the query alone, so only the first rule per + // query (the panel's) reaches jsdom. + const source = readFileSync(join(__dirname, 'BottomSheet.tsx'), 'utf8'); + const start = source.indexOf("'::backdrop': {"); + const backdrop = source.slice(start, source.indexOf('\n },\n', start)); + const reduced = [ + ...backdrop.matchAll( + /'@media \(prefers-reduced-motion: reduce\)[^']*': \{([^}]*)\}/g, + ), + ].map(match => match[1]); + // design:reduced-motion: a fade on a touch screen, at once otherwise. + expect(reduced).toHaveLength(2); + for (const block of reduced) { + expect(block).toMatch( + /transitionDuration:\s*[`']var\(--_sheet-scrim-duration,/, ); - expect(text).not.toMatch(/transition-duration:\s*0?\.01s/); } }); diff --git a/packages/core/src/BottomSheet/__fixtures__/release-storyboards.json b/packages/core/src/BottomSheet/__fixtures__/release-storyboards.json new file mode 100644 index 0000000000000..0f4772d360d6c --- /dev/null +++ b/packages/core/src/BottomSheet/__fixtures__/release-storyboards.json @@ -0,0 +1,125 @@ +{ + "slow settle onto the detent": { + "sheet": [ + [ + 140, 143.156, 149.996, 157.953, 165.721, 172.673, 178.571, 183.436, + 187.309, 190.381, 192.746, 194.572, 195.957, 197.003, 197.789, 198.372, + 198.808, 199.127, 199.364, 199.537, 199.664, 199.756, 199.824, 199.873, + 199.908, 199.934, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, + 200, 200, 200, 200 + ] + ], + "backdrop": [ + [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ] + ] + }, + "flick onto the next detent": { + "sheet": [ + [ + 80, 106.321, 130.345, 150.693, 166.965, 179.339, 188.303, 194.472, + 198.427, 200.824, 202.066, 202.555, 202.575, 202.325, 201.956, 201.554, + 201.174, 200.846, 200.579, 200.371, 200.219, 200.111, 200.041, 199.997, + 199.973, 199.963, 199.96, 199.963, 200, 200, 200, 200, 200, 200, 200, + 200, 200, 200, 200, 200, 200 + ] + ], + "backdrop": [ + [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.996, 0.99, 0.987, 0.987, 0.988, 0.99, + 0.992, 0.994, 0.996, 0.997, 0.998, 0.999, 0.999, 0.999, 0.999, 0.999, + 0.999, 0.999, 0.999, 0.999, 0.999, 0.999, 0.999, 0.999, 0.999, 0.999, + 0.999, 0.999, 0.999, 0.999, 0.999, 0.999 + ] + ] + }, + "spring back from below the detent": { + "sheet": [ + [ + 290, 285.26, 274.999, 263.07, 251.425, 240.971, 232.145, 224.859, + 219.029, 214.445, 210.866, 208.147, 206.063, 204.494, 203.319, 202.438, + 201.791, 201.308, 200.954, 200.695, 200.504, 200.366, 200.264, 200.191, + 200.138, 200.099, 200.072, 200.009, 200, 200, 200, 200, 200, 200, 200, + 200, 200, 200, 200, 200, 200 + ] + ], + "backdrop": [ + [ + 0.55, 0.574, 0.625, 0.685, 0.743, 0.795, 0.839, 0.876, 0.905, 0.928, + 0.946, 0.959, 0.97, 0.978, 0.983, 0.988, 0.991, 0.993, 0.995, 0.997, + 0.997, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, + 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, + 0.998 + ] + ] + }, + "flick dismissal": { + "sheet": [ + [ + 100, 130.148, 153.934, 171.801, 184.584, 193.257, 198.713, 201.932, + 203.519, 204.053, 203.954, 203.495, 202.894, 202.27, 201.695, 201.204, + 200.812, 200.511, 200.293, 200.142, 200.043, 199.984, 199.953, 199.94, + 199.939, 199.944, 199.953, 200, 200, 200, 200, 200, 200, 200, 200, 200, + 200, 200, 200, 200, 200 + ], + [ + 100, 117.87, 115.465, 103.492, 88.052, 72.388, 58.085, 45.711, 35.53, + 27.26, 20.749, 15.661, 11.741, 8.763, 6.494, 4.807, 3.536, 2.597, 1.9, + 1.387, 1.01, 0.733, 0.532, 0.385, 0.278, 0.2, 0.144, 0.104, 0.074, + 0.028, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ], + "backdrop": [ + [ + 0.5, 0.35, 0.231, 0.141, 0.077, 0.034, 0.006, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0 + ], + [ + 0.5, 0.411, 0.423, 0.483, 0.56, 0.638, 0.71, 0.771, 0.822, 0.864, 0.896, + 0.922, 0.941, 0.956, 0.967, 0.976, 0.982, 0.987, 0.99, 0.993, 0.995, + 0.996, 0.997, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, + 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998, 0.998 + ] + ] + }, + "throw up onto the tallest detent": { + "sheet": [ + [ + 120, 99.85, 80.785, 64.034, 49.978, 38.543, 29.438, 22.255, 16.756, + 12.511, 9.309, 6.891, 5.08, 3.736, 2.732, 2, 1.456, 1.059, 0.769, 0.556, + 0.403, 0.29, 0.209, 0.151, 0.108, 0.077, 0.045, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0 + ] + ], + "backdrop": [ + [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ] + ] + }, + "mouse release on the theme timing": { + "sheet": [ + [ + 140, 141.628, 148.855, 160.233, 169.243, 175.728, 180.629, 184.491, + 187.578, 190.118, 192.2, 193.931, 195.362, 196.541, 197.506, 198.279, + 198.89, 199.348, 199.679, 199.886, 199.987, 200, 200, 200, 200, 200, + 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, 200, + 200 + ] + ], + "backdrop": [] + }, + "reduced-motion release": { + "sheet": [ + [ + 1, 0.918, 0.668, 0.332, 0.082, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + ], + "backdrop": [] + } +} diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetDetent.interaction.browser.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetDetent.interaction.browser.spec.ts new file mode 100644 index 0000000000000..65b28def8b6f1 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetDetent.interaction.browser.spec.ts @@ -0,0 +1,1450 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file BottomSheetDetent.interaction.browser.spec.ts + * @input The built "Snap points" BottomSheet story, real touch input + * (Chromium CDP / WebKit's input command), the interaction harness and the + * UIKit constants table (the parameters the oracle springs use) + * @output Interaction and animation proofs for releasing the sheet: the drag + * follows the finger and a slow release settles on the nearest detent; a + * flick dismisses; the settle is UIKit's release spring, sampled frame by + * frame against an independent spring oracle, starting at the finger's + * velocity (critically damped below `sheet.release.fastSpeed`, 80 % damped + * above); a finger catches the settling sheet where it is; the settle keeps + * advancing every frame through a blocked main thread; it does no Layout or + * Paint per frame; a finger's drag on Android re-rasters none of the sheet + * (pending a fix at its origin); storyboards of the drag, the settle and a flick. A finger + * on Android gets the same (one touch model, AST-094); a mouse keeps today's + * rule and the theme's timing (unit tests). Under reduced motion a touch release cross-fades in place + * in 200 ms or less (design:reduced-motion DR2). + * @position The exemplar for `*.interaction.browser.spec.ts`: component lanes + * copy this shape. Each test names the instrument it uses from + * @astryxdesign/interaction-harness, so the evidence layer is explicit. + * + * The sheet moves on the `translate` property while a gesture or its release + * runs (the motion foundation's animator; its resting position is the + * element's `transform`), so curves are sampled from both (`sampleTravel`). + * + * UIKit's release belongs to every finger (one touch model), so most cases + * run under `emulateIosWebKit` and the Android cases without it, to show the + * same behavior without WebKit on iOS. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + busyWindowProof, + castStoryboard, + compareCurve, + countBetween, + drag, + flick, + frames, + freezeTimers, + holdAnimations, + installInputLog, + installLongTaskLog, + installTimerGate, + markElement, + openStory, + readInputLog, + readLongTasks, + releaseAnimations, + releaseVelocity, + retakeOnTouchPacing, + seekAnimations, + springStep, + startCompositorTrace, + startScreencast, + thawTimers, + timeline, + touchDriver, + toTouchEvents, + touchStoryboard, + traceRendering, + trackMarker, + withStorybook, + waitForAnimation, + waitForRest, + writeEvidence, + type CastFrame, + type InputRecord, + type PlayedEvent, + type TimedValue, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {END_HOLD_MS} from './specConstants'; +import {PROMOTION_LINGER_MS} from '../../motion/animator'; +import {sheet as sheetTable} from '../../motion/uikit/sheet'; +import {springs} from '../../motion/uikit/springs'; +import {emulateIosWebKit} from './emulateIosWebKit'; + +const storybook = withStorybook(test); + +interface OpenSheet { + driver: TouchDriver; + sheet: Locator; + /** Top of the sheet at its tall detent and at the half-viewport detent, CSS px. */ + tallTop: number; + halfTop: number; + handle: {x: number; y: number}; +} + +async function openSheet( + page: Page, + {idiom = 'ios'}: {idiom?: 'ios' | 'android'} = {}, +): Promise { + if (idiom === 'ios') { + await emulateIosWebKit(page); + } + await installLongTaskLog(page); + await installInputLog(page); + await openStory(page, storybook(), 'core-bottomsheet--snap-points'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open nearby places'}); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + const sheet = page + .getByRole('dialog', {name: 'Nearby places'}) + .locator('.astryx-bottom-sheet') + .first(); + await expect(sheet).toBeVisible(); + await waitForRest(sheet); + const rect = await sheet.boundingBox(); + if (rect == null) { + throw new Error('no sheet'); + } + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + return { + driver, + sheet, + tallTop: rect.y, + // snapPoints={[0.5]}: the extra stop shows half the viewport. + halfTop: viewport.height * 0.5, + handle: {x: rect.x + rect.width / 2, y: rect.y + 12}, + }; +} + +/** + * The page a velocity proof's attempt runs on. A re-take (retakeOnTouchPacing, + * once, only when WebKit received the gesture off its scripted pacing) starts + * over on a fresh page, since the first gesture already moved the sheet. + */ +async function pageForAttempt(page: Page, attempt: 1 | 2): Promise { + if (attempt === 1) { + return page; + } + const fresh = await page.context().newPage(); + await page.close(); + return fresh; +} + +// A plain evaluate, not boundingBox(): it must also work while the page's timers are frozen. +const sheetTop = async (sheet: Locator) => + sheet.evaluate(el => el.getBoundingClientRect().y); + +const SLOW_DRAG_MS = 400; + +/** + * A slow drag that rests one frame before the lift. A still finger sends no + * events, and a lift within the release filter's stillness interval (40 ms) + * keeps the last moving speed, so the release carries `dy / 0.4 s` + * (`slowDragSpeed`); a longer rest would read 0 (spec:AST-100 FR2). + */ +function slowDragTo(from: {x: number; y: number}, dy: number) { + return drag( + from, + {x: from.x, y: from.y + dy}, + {durationMs: SLOW_DRAG_MS, hz: 60, restMs: 16}, + ); +} +const slowDragSpeed = (dy: number) => (dy * 1000) / SLOW_DRAG_MS; + +/** + * A finger that holds 100 ms, drags 80 px down and most of the way back, and + * lifts still, 16 px below where it started: the sheet settles back on the + * tallest detent and keeps its size, so nothing about its layout changes. + */ +function outAndBack(from: {x: number; y: number}) { + return drag( + from, + {x: from.x, y: from.y + 80}, + { + durationMs: SLOW_DRAG_MS, + hz: 60, + holdMs: 100, + restMs: 60, + easing: p => (p <= 0.5 ? 2 * p : 1 - 1.6 * (p - 0.5)), + }, + ); +} + +/** + * The sheet's vertical translation at each time (ms from the held + * animations' start): its `translate` (the release spring) plus its + * `transform` (the resting detent). Hold the animations first. + */ +async function sampleTravel( + sheet: Locator, + times: ReadonlyArray, +): Promise { + return sheet.evaluate((element, ts) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? []; + if (held.length === 0) { + throw new Error('sampleTravel: no held animations'); + } + return ts.map(t => { + for (const animation of held) { + const end = Number( + animation.effect?.getComputedTiming().endTime ?? Infinity, + ); + animation.currentTime = Math.min(t, end - 0.5); + } + const style = getComputedStyle(element); + const translate = + style.translate === 'none' + ? 0 + : Number.parseFloat(style.translate.split(' ')[1] ?? '0'); + const transform = + style.transform === 'none' ? 0 : new DOMMatrix(style.transform).m42; + return {t, value: translate + transform}; + }); + }, times); +} + +/** + * The velocity UIKit reports at the lift (px/s) for the touch the page + * received last: 0.2 × the newest event's instantaneous speed + 0.8 × the + * one before, never decaying through a rest (UIPanGestureRecognizer, measured + * on the iOS 26.2 simulator). From the events' own timestamps and positions, + * so it holds in WebKit too, whose input carries real dispatch times and + * whole-pixel positions rather than the script's. + */ +function uikitReleaseVelocity(records: ReadonlyArray): number { + const touch = records.filter( + r => + r.pointerType === 'touch' && + (r.type === 'pointerdown' || r.type === 'pointermove') && + r.y != null, + ); + const samples = touch.slice( + touch.map(r => r.type).lastIndexOf('pointerdown'), + ); + const speed = (a: InputRecord, b: InputRecord) => + b.timeStamp > a.timeStamp + ? (((b.y as number) - (a.y as number)) / (b.timeStamp - a.timeStamp)) * + 1000 + : 0; + const n = samples.length; + if (n < 2) { + return 0; + } + if (n === 2) { + return speed(samples[0], samples[1]); + } + return ( + 0.2 * speed(samples[n - 2], samples[n - 1]) + + 0.8 * speed(samples[n - 3], samples[n - 2]) + ); +} + +/** Chromium's clamp on an event's time as a page reads it, ms. */ +const PAGE_CLOCK_CLAMP_MS = 0.1; + +/** + * Checks that the samples UIKit's filter reads (the last three touch samples) + * are the script's: each at a scripted move's time and position. Chromium's + * input carries the script's timestamps and sub-pixel positions, but a page + * reads each event's time clamped to 0.1 ms with a jittered threshold + * (Chromium's time clamping without cross-origin isolation), so a sample sits + * up to two clamps off the script's time relative to the touch-down: a + * scripted 8.33 ms interval reads 8.2 to 8.5 ms, which alone moves the + * two-sample filter up to 2 % off a steady speed. What the filter reads is + * what the curve below is checked against, so the speed itself is not + * compared. + */ +function expectSamplesOnScript( + records: ReadonlyArray, + played: ReadonlyArray, +): void { + const touch = records.filter( + r => + r.pointerType === 'touch' && + (r.type === 'pointerdown' || r.type === 'pointermove') && + r.y != null, + ); + const down = touch.map(r => r.type).lastIndexOf('pointerdown'); + const start = played.find(e => e.type === 'touchStart'); + expect(down).toBeGreaterThanOrEqual(0); + expect(start).toBeDefined(); + const offset = touch[down].timeStamp - (start as PlayedEvent).t; + const moves = played.filter(e => e.type === 'touchMove'); + for (const sample of touch.slice(down + 1).slice(-3)) { + const t = sample.timeStamp - offset; + const step = moves.find( + e => Math.abs(e.t - t) <= 2 * PAGE_CLOCK_CLAMP_MS + 1e-6, + ); + expect(step, `a scripted move at ${t.toFixed(2)} ms`).toBeDefined(); + expect(sample.y as number).toBeCloseTo( + (step as PlayedEvent).fingers[0].y, + 2, + ); + } +} + +/** + * UIKit's release spring for a settle from `from` to `to` (px) that starts at + * `velocity` px/s, from the harness's own spring math (independent of the + * code under test) and the constants table's parameters. + */ +function uikitRelease(from: number, to: number, velocity: number) { + const spring = + Math.abs(velocity) >= sheetTable.release.fastSpeed + ? springs.sheetReleaseFast + : springs.sheetDetent; + const initialVelocity = to === from ? 0 : velocity / (to - from); + return (t: number) => + from + + (to - from) * + springStep( + { + response: spring.response, + dampingFraction: spring.dampingRatio, + initialVelocity, + }, + t, + ); +} + +/** Holds the release's animation and compares it with UIKit's spring. */ +async function compareRelease( + page: Page, + sheet: Locator, + velocity: number, +): Promise<{ + result: ReturnType; + samples: TimedValue[]; + from: number; + to: number; +}> { + const [settle] = await waitForAnimation(sheet); + await holdAnimations(page, sheet); + const samples = await sampleTravel(sheet, timeline(settle.durationMs)); + const from = samples[0].value; + const to = samples[samples.length - 1].value; + const result = compareCurve(samples, uikitRelease(from, to, velocity), 0.5); + await releaseAnimations(page); + return {result, samples, from, to}; +} + +test('a slow drag follows the finger and settles on the nearest detent', async ({ + page, + browserName, +}) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + // Two thirds of the way to the half detent: nearer to it than to tall. + const dy = Math.round((halfTop - tallTop) * 0.66); + const script = slowDragTo(handle, dy); + const lastMove = + toTouchEvents(script).filter(e => e.type === 'touchMove').length - 1; + const tracked: {top: number; finger: number}[] = []; + let moves = 0; + let restingTop = NaN; + const started = Date.now(); + await driver.play(script, { + onEvent: async step => { + if (step.type !== 'touchMove') { + return; + } + // A frame after each move, so the page has handled it (Chromium hands + // touch moves over on the next animation frame), and once more after + // the last move: the finger rests 150 ms before lifting. + await frames(page, 1); + tracked.push({ + top: await sheetTop(sheet), + finger: step.changed[0].y - handle.y, + }); + if (moves++ === lastMove) { + await frames(page, 1); + restingTop = await sheetTop(sheet); + } + }, + }); + // 1:1 with the finger at every move, no pull toward a detent (within the + // pixel WebKit rounds touch positions to). + for (const {top, finger} of tracked) { + expect(Math.abs(top - tallTop - finger)).toBeLessThanOrEqual(1); + } + expect(restingTop).toBeCloseTo(tallTop + dy, 0); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); + // No long task from the gesture or the settle (the story's own load is + // excluded). Only Chromium has the Long Tasks API, so only Chromium can + // check this; the drag and the settle above hold in both engines. + if (browserName === 'chromium') { + expect(await readLongTasks(page, {from: started, to: Date.now()})).toEqual( + [], + ); + } +}); + +test('pulled up past the tallest detent, the sheet stretches by the rubber band and settles back (spec:AST-094 FR3)', async ({ + page, +}) => { + const {driver, sheet, tallTop, handle} = await openSheet(page); + // UIKit's rubber band over the sheet's height (coefficient 0.55), no + // farther than the 48 px the sheet reserves below the screen. + const height = await sheet.evaluate(el => el.getBoundingClientRect().height); + const band = (past: number) => + Math.min(48, (1 - 1 / ((past * 0.55) / height + 1)) * height); + const pulled: {top: number; past: number}[] = []; + await driver.play( + drag(handle, {x: handle.x, y: handle.y - 120}, {durationMs: 400, hz: 60}), + { + onEvent: async step => { + if (step.type === 'touchMove') { + // A frame after each move, so the page has handled it. + await frames(page, 1); + pulled.push({ + top: await sheetTop(sheet), + past: handle.y - step.changed[0].y, + }); + } + }, + }, + ); + expect(pulled.length).toBeGreaterThan(0); + for (const {top, past} of pulled) { + expect(Math.abs(top - (tallTop - band(past)))).toBeLessThanOrEqual(1); + } + // Let go, it settles back onto the tallest detent. + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(tallTop, 0); +}); + +test('on Android too, the drag tracks the finger 1:1 and stretches past the tallest detent', async ({ + page, +}) => { + // Tracking is universal (F4's Android audit): no shim, Chromium's own idiom. + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page, { + idiom: 'android', + }); + const tracked: {top: number; finger: number}[] = []; + await driver.play( + slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66)), + { + onEvent: async step => { + if (step.type === 'touchMove') { + await frames(page, 1); + tracked.push({ + top: await sheetTop(sheet), + finger: step.changed[0].y - handle.y, + }); + } + }, + }, + ); + expect(tracked.length).toBeGreaterThan(0); + for (const {top, finger} of tracked) { + expect(Math.abs(top - tallTop - finger)).toBeLessThanOrEqual(1); + } + await waitForRest(sheet); + const restTop = await sheetTop(sheet); + const upHandle = {x: handle.x, y: restTop + 12}; + const tops: number[] = []; + // From half, up past the tallest detent: it follows to tall, then + // stretches by the rubber band, within the 48 px reserve (spec:AST-094 FR3). + await driver.play( + drag( + upHandle, + {x: upHandle.x, y: tallTop - 108}, + {durationMs: 500, hz: 60}, + ), + { + onEvent: async step => { + if (step.type === 'touchMove') { + tops.push(await sheetTop(sheet)); + } + }, + }, + ); + expect(Math.min(...tops)).toBeGreaterThanOrEqual(tallTop - 48.5); + expect(Math.min(...tops)).toBeLessThan(tallTop - 10); +}); + +test('the dim follows the sheet: at once under the finger, in lockstep with the release', async ({ + page, +}) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + const dialog = page.getByRole('dialog', {name: 'Nearby places'}); + const viewportHeight = page.viewportSize()?.height ?? 0; + // UIKit's dim: full at the shortest detent and above, linear in the sheet's + // top below it, clear when the top reaches the bottom of the screen. + const dimAt = (top: number) => + Math.min( + 1, + Math.max(0, (viewportHeight - top) / (viewportHeight - halfTop)), + ); + const backdropOpacity = async () => + dialog.evaluate(el => Number(getComputedStyle(el, '::backdrop').opacity)); + // Down to the half detent first. + await driver.play(slowDragTo(handle, Math.round((halfTop - tallTop) * 0.8))); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); + expect(await backdropOpacity()).toBeCloseTo(1, 2); + // 100 px below it, slowly: the dim follows every move at once. + const fromHalf = {x: handle.x, y: halfTop + 12}; + const tracked: {top: number; dim: number}[] = []; + await driver.play( + drag( + fromHalf, + {x: fromHalf.x, y: fromHalf.y + 100}, + {durationMs: 400, hz: 60, restMs: 150}, + ), + { + onEvent: async step => { + if (step.type === 'touchMove') { + await frames(page, 1); + tracked.push({ + top: await sheetTop(sheet), + dim: await backdropOpacity(), + }); + } + }, + }, + ); + expect(tracked.at(-1)?.top).toBeGreaterThan(halfTop + 90); + for (const {top, dim} of tracked) { + expect(dim).toBeCloseTo(dimAt(top), 2); + } + // Released at 250 px/s, short of halfway to gone: back to the half detent, + // the dim riding the same spring, each moment's dim the one for where the + // sheet is. + await holdAnimations(page, dialog); + for (const t of [0, 33, 83, 150, 250, 400]) { + await seekAnimations(page, t); + const top = await sheetTop(sheet); + expect(await backdropOpacity(), `t=${t} top=${top}`).toBeCloseTo( + dimAt(top), + 2, + ); + } +}); + +/** + * The luminance (0-255) around a viewport point in each screencast frame, + * decoded in a scratch page of the same browser (as trackMarker does), so the + * dim can be read while the page's main thread is blocked. + */ +async function luminanceAt( + page: Page, + frames: ReadonlyArray, + point: {x: number; y: number}, +): Promise<{t: number; lum: number}[]> { + const scratch = await page.context().newPage(); + try { + const out: {t: number; lum: number}[] = []; + for (let i = 0; i < frames.length; i += 12) { + const batch = frames.slice(i, i + 12); + const values = await scratch.evaluate( + async ({items, p}) => { + const results: number[] = []; + for (const item of items) { + const blob = await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const x = Math.round((p.x * bitmap.width) / item.viewportWidth); + const y = Math.round((p.y * bitmap.height) / item.viewportHeight); + const {data} = context.getImageData(x - 2, y - 2, 5, 5); + let sum = 0; + for (let k = 0; k < data.length; k += 4) { + sum += + 0.2126 * data[k] + 0.7152 * data[k + 1] + 0.0722 * data[k + 2]; + } + results.push(sum / (data.length / 4)); + } + return results; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + viewportHeight: f.viewportHeight, + })), + p: point, + }, + ); + values.forEach((lum, k) => out.push({t: batch[k].t, lum})); + } + return out; + } finally { + await scratch.close(); + } +} + +test('the dim keeps its lockstep with the release while the main thread is blocked', async ({ + page, +}, testInfo) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + // Below the half detent the dim depends on where the sheet is: let go + // there slowly and the sheet settles back to half, the dim deepening with + // it. The drag is slow enough (~340 px/s at the lift, a frame after its + // last move) that the release projects back to half rather than to + // dismissal. The block starts once the release (sheet and dim) is running. + const dy = Math.round(halfTop - tallTop + (viewport.height - halfTop) * 0.3); + await markElement(sheet); + const cast = await startScreencast(page); + // What the compositor presented: frames the screencast skips under load + // while the compositor draws them are not stalls (WebKit: none, so the + // screencast alone is judged). + const compositor = await startCompositorTrace(page); + await driver.play( + drag( + handle, + {x: handle.x, y: handle.y + dy}, + {durationMs: 1200, hz: 60, restMs: 16}, + ), + ); + const [settle] = await waitForAnimation(sheet, {minProgressMs: 16}); + const block = await blockMainThread(page, 700); + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const presented = await compositor.stop(); + // A point of the backdrop above the sheet at every stop. + const lum = await luminanceAt(page, castFrames, { + x: viewport.width - 24, + y: Math.max(8, tallTop / 2), + }); + const tracked = await trackMarker(page, castFrames); + const resting = ( + lum.filter(s => s.t >= block.to).at(-1) ?? lum[lum.length - 1] + ).lum; + const blocked = lum.filter( + s => s.t >= block.from + 20 && s.t <= Math.min(block.to, settle.to), + ); + // The backdrop's luminance is whole steps, and near the end of the spring + // the dim moves about one step a frame: two frames the screencast delivers + // close together read the same step though the dim never stopped. So it is + // judged by time, as the harness judges a moving element: no stall longer + // than three screencast intervals while it is more than 3 steps from rest + // (a main-thread dim would hold still for the whole block), over at least + // the harness's fewest judged frames. + const proof = busyWindowProof( + lum.map(s => ({ + t: s.t, + box: {x: 0, y: s.lum, width: 0, height: 0}, + pixels: 1, + })), + {from: block.from, to: Math.min(block.to, settle.to)}, + {settledWithin: 3, settledAt: resting, minMovedFraction: 0, presented}, + ); + await writeEvidence( + 'busy-main-thread/bottomsheet-dim', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + settle, + resting, + proof, + frames: castFrames.map(f => ({ + t: +(f.t - block.from).toFixed(1), + lum: +(lum.find(s => s.t === f.t)?.lum ?? -1).toFixed(1), + y: tracked.find(s => s.t === f.t)?.box?.y ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + // Through the block the backdrop keeps darkening (the dim keeps rising + // with the sheet) and never lightens. + testInfo.annotations.push({ + type: 'busy-main-thread', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + for (let i = 1; i < blocked.length; i++) { + expect( + blocked[i].lum, + `t=${blocked[i].t - block.from}`, + ).toBeLessThanOrEqual(blocked[i - 1].lum + 1); + } +}); + +test('on Android the dim follows the sheet too, pinned (one touch model)', async ({ + page, +}) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page, { + idiom: 'android', + }); + const dialog = page.getByRole('dialog', {name: 'Nearby places'}); + const viewportHeight = page.viewportSize()?.height ?? 0; + // UIKit's dim: full at the shortest detent, clear at the bottom of the + // screen, linear between. + const uikitDimAt = (top: number) => + Math.min(1, Math.max(0, 1 - (top - halfTop) / (viewportHeight - halfTop))); + const scrimVars = async () => + dialog.evaluate(el => ({ + opacity: Number(el.style.getPropertyValue('--_sheet-scrim-opacity')), + duration: el.style.getPropertyValue('--_sheet-scrim-duration'), + })); + await driver.play(slowDragTo(handle, Math.round((halfTop - tallTop) * 0.8))); + await waitForRest(sheet); + const fromHalf = {x: handle.x, y: halfTop + 12}; + const tracked: {top: number; opacity: number; duration: string}[] = []; + await driver.play( + drag( + fromHalf, + {x: fromHalf.x, y: fromHalf.y + 100}, + {durationMs: 400, hz: 60, restMs: 150}, + ), + { + onEvent: async step => { + if (step.type === 'touchMove') { + await frames(page, 1); + tracked.push({top: await sheetTop(sheet), ...(await scrimVars())}); + } + }, + }, + ); + expect(tracked.at(-1)?.top).toBeGreaterThan(halfTop + 90); + for (const {top, opacity, duration} of tracked) { + expect(opacity).toBeCloseTo(uikitDimAt(top), 1); + // Pinned to 0 s: the dim moves with the finger, not behind it. + expect(duration).toBe('0s'); + } +}); + +test('a downward flick lands on the next detent, and from there dismisses', async ({ + page: firstPage, +}, testInfo) => { + // Its outcome follows the flick's speed, so WebKit's pacing is checked. + await retakeOnTouchPacing(testInfo, async attempt => { + const page = await pageForAttempt(firstPage, attempt); + const {driver, sheet, halfTop, handle} = await openSheet(page); + // 2000 px/s for 80 ms from the tall detent: a flick, so at least to the + // next detent down, the half one, which its projection (160 + 2000 × 0.1 + // = 360 px) is nearest too (UIKit sends a medium/large sheet flicked this + // way from large to medium). + const fromTall = flick(handle, { + velocity: {x: 0, y: 2000}, + durationMs: 80, + hz: 120, + }); + expect(releaseVelocity(fromTall).y).toBeCloseTo(2000, -1); + await driver.play(fromTall, {checkPacing: true}); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); + // The same flick from the half detent goes on to the next position: gone. + await driver.play( + flick( + {x: handle.x, y: halfTop + 12}, + {velocity: {x: 0, y: 2000}, durationMs: 80, hz: 120}, + ), + {checkPacing: true}, + ); + await expect( + page.getByRole('dialog', {name: 'Nearby places'}), + ).toBeHidden(); + }); +}); + +test('the settle is the UIKit release spring, sampled frame by frame', async ({ + page, +}, testInfo) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + const dy = Math.round((halfTop - tallTop) * 0.66); + const script = slowDragTo(handle, dy); + expect(releaseVelocity(script).y).toBeCloseTo(slowDragSpeed(dy), -1); + await readInputLog(page, {clear: true}); + const played = await driver.play(script); + // The critically damped detent spring from where the finger left the + // sheet, handed the drag's speed, which survives the frame's rest before + // the lift. + // In Chromium the page measures the script (expectSamplesOnScript). + // WebKit's input carries real dispatch times in whole milliseconds, so what + // it measures varies with the box; the curve below is checked against that + // measurement either way. + const records = await readInputLog(page); + const velocity = uikitReleaseVelocity(records); + if (driver.scriptedTimestamps) { + expectSamplesOnScript(records, played); + } + const {result, samples, to} = await compareRelease(page, sheet, velocity); + await writeEvidence( + 'curves/bottomsheet-settle', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {spring: springs.sheetDetent, velocity, samples, comparison: result}, + null, + 2, + ), + }, + testInfo, + ); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.5, + ); + expect(to).toBeCloseTo(halfTop - tallTop, 0); +}); + +for (const {name, speed, travel} of [ + // Released after a slow pull, below the speed that switches springs: the + // critically damped spring, handed the finger's velocity. + {name: 'a moderate throw', speed: 600, travel: 0.4}, + // Above sheet.release.fastSpeed: the 80 % damped spring. + {name: 'a fast throw', speed: 1500, travel: 0.25}, +]) { + test(`${name} hands its velocity to the release spring`, async ({ + page: firstPage, + }, testInfo) => { + // Its outcome follows the throw's speed, so WebKit's pacing is checked. + await retakeOnTouchPacing(testInfo, async attempt => { + const page = await pageForAttempt(firstPage, attempt); + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + // A pull toward the half detent, then a constant-speed throw at `speed` + // for the last 100 ms: it projects past halfway, so it lands there. + const pull = Math.round((halfTop - tallTop) * travel); + const throwDistance = (speed * 100) / 1000; + const start = {x: handle.x, y: handle.y}; + const script = drag( + start, + {x: start.x, y: start.y + pull + throwDistance}, + { + durationMs: 100 + (pull / throwDistance) * 100, + hz: 120, + }, + ); + expect(releaseVelocity(script).y).toBeCloseTo(speed, -1); + await readInputLog(page, {clear: true}); + const played = await driver.play(script, {checkPacing: true}); + // What the page measured: the script in Chromium, to within its clock's + // clamp (expectSamplesOnScript). WebKit's input carries real dispatch + // times in whole milliseconds and whole pixels: at 120 Hz one + // millisecond is 12 % of an interval, and UIKit's two-sample filter + // reads 1333–1625 px/s of a steady 1500 from that rounding alone, more + // when a dispatch runs late. The curve and the hand-off below are + // checked against what the page measured, in both engines. + const records = await readInputLog(page); + const velocity = uikitReleaseVelocity(records); + if (driver.scriptedTimestamps) { + expectSamplesOnScript(records, played); + } + const {result, samples, from, to} = await compareRelease( + page, + sheet, + velocity, + ); + await writeEvidence( + `curves/bottomsheet-release-${speed}`, + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {speed, velocity, from, to, samples, comparison: result}, + null, + 2, + ), + }, + testInfo, + ); + expect(to).toBeCloseTo(halfTop - tallTop, 0); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.5, + ); + // The hand-off is what the curve shows: a spring started at rest would + // trail it by several px within the first 16 ms. + const atRest = uikitRelease(from, to, 0); + const at16 = samples.find(s => s.t >= 16) ?? samples[1]; + expect(Math.abs(at16.value - atRest(at16.t))).toBeGreaterThan(3); + }); + }); +} + +test('a finger catches the settling sheet where it is', async ({page}) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + const target = halfTop - tallTop; + // A moderate throw toward the half detent; catch it ~100 ms into the settle. + const pull = Math.round(target * 0.4); + await driver.play( + drag(handle, {x: handle.x, y: handle.y + pull + 60}, {durationMs: 200}), + ); + await waitForAnimation(sheet, {minProgressMs: 90}); + // How far the settle moves in a frame right now, then where it is. + const before = await sheetTop(sheet); + await frames(page, 1); + const caughtAt = await sheetTop(sheet); + const perFrame = Math.abs(caughtAt - before); + expect(caughtAt).toBeGreaterThan(tallTop + pull); + expect(caughtAt).toBeLessThan(halfTop - 1); + // Press where the handle is now, hold still for 150 ms, then pull up 40 px. + const handleNow = {x: handle.x, y: caughtAt + 12}; + let pressed = NaN; + let held = NaN; + const tops: number[] = []; + await driver.play( + drag( + handleNow, + {x: handleNow.x, y: handleNow.y - 40}, + {durationMs: 200, holdMs: 150, hz: 60}, + ), + { + onEvent: async (step, index) => { + if (index === 0) { + await frames(page, 1); + pressed = await sheetTop(sheet); + await page.waitForTimeout(80); + held = await sheetTop(sheet); + } else if (step.type === 'touchMove') { + await frames(page, 1); + tops.push(await sheetTop(sheet)); + } + }, + }, + ); + // Caught where it was: no jump at the press beyond the frame or two the + // settle moved between the read and the press ... + expect(Math.abs(pressed - caughtAt)).toBeLessThanOrEqual(2 * perFrame + 1); + // ... and it stays there while the finger holds still, + expect(Math.abs(held - pressed)).toBeLessThan(0.5); + // then follows the pull. + expect(tops[tops.length - 1]).toBeCloseTo(pressed - 40, -1); +}); + +test('a finger on the drawn sheet catches it even when the hit test lands on the scrim, as WebKit does', async ({ + page, +}) => { + // iOS Safari 26.2 hit-tests an element a compositor animation is moving at + // its un-animated position: a touch on the moving sheet arrives on the + // scrim (or content the sheet has left). Mimicked here by dispatching the + // touch to the dialog itself, at a point on the drawn sheet. + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + const canTouch = await page.evaluate(() => { + try { + void new Touch({identifier: 1, target: document.body}); + return true; + } catch { + return false; + } + }); + test.skip(!canTouch, 'this engine cannot construct touch events'); + const fire = async (type: string, y: number) => + sheet.evaluate( + (element, {kind, clientY}) => { + const dialog = element.closest('dialog') as HTMLDialogElement; + const touch = new Touch({ + identifier: 77, + target: dialog, + clientX: 200, + clientY, + }); + dialog.dispatchEvent( + new TouchEvent(kind, { + bubbles: true, + cancelable: true, + touches: kind === 'touchend' ? [] : [touch], + changedTouches: [touch], + }), + ); + if (kind === 'touchend') { + dialog.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true}), + ); + } + }, + {kind: type, clientY: y}, + ); + await driver.play(slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66))); + await waitForAnimation(sheet, {minProgressMs: 40}); + // How far the settle moves in a frame right now, then where it is. + const before = await sheetTop(sheet); + await frames(page, 1); + const drawn = await sheetTop(sheet); + const perFrame = Math.abs(drawn - before); + await fire('touchstart', drawn + 20); + await frames(page, 1); + const pressed = await sheetTop(sheet); + await page.waitForTimeout(80); + const held = await sheetTop(sheet); + // Caught where it was drawn (within the frame or two the settle moved + // between the read and the press), and it stays there while held. + expect(Math.abs(pressed - drawn)).toBeLessThanOrEqual(2 * perFrame + 1); + expect(Math.abs(held - pressed)).toBeLessThan(0.5); + // The lift settles it from there, and the tap's click on the dialog is not + // a scrim tap: the sheet stays open. + await fire('touchend', pressed + 20); + await waitForRest(sheet); + await expect(sheet).toBeVisible(); + expect(await sheet.evaluate(el => el.closest('dialog')?.open)).toBe(true); +}); + +test('the settle keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, +}, testInfo) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + await markElement(sheet); + const cast = await startScreencast(page); + // The settle must be running (and composited) before the block: an + // animation cannot start while the main thread is held. Armed before the + // release, the page waits for the settle itself (the sheet runs no other + // animation during the drag) and blocks once it has run 16 ms, however busy + // the machine; armed after, the round trip would leave little of the settle + // to judge. + const blocking = blockMainThread(page, 700, { + during: sheet, + minProgressMs: 16, + }); + await driver.play(slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66))); + const block = await blocking; + // Where the settle ends, from the page: a frame after the block can still + // show a stale position (see `settledAt`). The frames track the marker + // chip, which sits inset in the sheet, so it is the chip's rest: the sheet's + // own top would end the judged window about 9 px before the settle does. + await waitForRest(sheet); + const settledAt = await sheet.evaluate(el => { + const chip = el.querySelector('[data-harness-marker-chip]'); + if (chip == null) { + throw new Error('the sheet lost its marker chip'); + } + return chip.getBoundingClientRect().y; + }); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + // Judge the part of the block during which the settle was still moving: + // until it is within two of the screencast's position steps of rest + // (Chromium's are 1 px, WebKit's 2 px). Closer, the spring's tail moves + // less than a step a frame and shows plateaus: on WebKit, two frames at one + // 2 px step read as not moved though the sheet never stopped. + const settledWithin = browserName === 'webkit' ? 4 : 2; + const proof = busyWindowProof(tracked, block, {settledWithin, settledAt}); + await writeEvidence( + 'busy-main-thread/bottomsheet-settle', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + settledAt, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + y: f.box?.y ?? null, + h: f.box?.height ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'bottomsheet-settle-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: proof.judged.to + 40, + columns: 8, + cellScale: 0.35, + data: t => ({y: tracked.find(f => f.t === t)?.box?.y ?? -1}), + }); + await film.write(testInfo, {block, judged: proof.judged}); + testInfo.annotations.push({ + type: 'busy-main-thread', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + // The judged window runs until the marker is within two steps of where it + // rests. + const lastJudged = tracked + .filter(f => f.box != null && f.t <= proof.judged.to) + .at(-1); + expect( + Math.abs((lastJudged?.box?.y ?? Number.NaN) - settledAt), + ).toBeLessThanOrEqual(settledWithin); +}); + +test('the settle does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + let settle = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + await driver.play( + slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66)), + ); + [settle] = await waitForAnimation(sheet); + await waitForRest(sheet); + }); + // The steady part of the settle: its first and last frames may commit. + const during = countBetween(trace, settle.from + 50, settle.to - 50); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(trace.longTasks).toEqual([]); +}); + +/** + * Tiles Chromium's compositor schedules for raster in the page's renderer + * (cc's `TileManager::ScheduleTasks`), per window between the marks `body` + * places: `{[mark]: tiles until the next mark}`. A transform or an opacity on + * a composited layer rasters nothing; a style change that repaints content, + * or gives an element a layer or takes it away, re-rasters its tiles. + */ +async function traceRasterTiles( + page: Page, + body: (mark: (name: string) => Promise) => Promise, +): Promise> { + const cdp = await page.context().newCDPSession(page); + const events: { + name: string; + ts: number; + pid: number; + args?: {count?: 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: ['cc', 'blink.user_timing'], + excludedCategories: ['*'], + }, + }); + const tag = `raster-${Math.random().toString(36).slice(2, 8)}`; + const names: string[] = []; + const mark = async (name: string) => { + names.push(name); + await page.evaluate(n => void performance.mark(n), `${tag}:${name}`); + }; + try { + await body(mark); + await mark('end'); + } finally { + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + } + const marks = names.map(name => { + const event = events.find(e => e.name === `${tag}:${name}`); + if (event == null) { + throw new Error(`traceRasterTiles: the mark "${name}" was not traced`); + } + return {name, ts: event.ts, pid: event.pid}; + }); + const tiles: Record = {}; + for (let i = 0; i < marks.length - 1; i++) { + const {name, ts, pid} = marks[i]; + tiles[name] = events + .filter( + e => + e.name === 'TileManager::ScheduleTasks' && + e.pid === pid && + e.ts >= ts && + e.ts < marks[i + 1].ts, + ) + .reduce((sum, e) => sum + (e.args?.count ?? 0), 0); + } + return tiles; +} + +test('a finger dragging the open sheet re-rasters none of it', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'raster traces are Chromium-only'); + // A finger on Android: its selection lock changes no style. On iOS WebKit + // the lock still turns user-select off on the sheet (the only refusal iOS + // honors, sheetSelectionLock.ts), which repaints it at the catch and the + // lift. + const {driver, sheet, tallTop, handle} = await openSheet(page, { + idiom: 'android', + }); + // The presentation's own layer goes this long after it settles. + await page.waitForTimeout(PROMOTION_LINGER_MS + 100); + let moving = false; + const tiles = await traceRasterTiles(page, async mark => { + await mark('touch-down'); + await driver.play(outAndBack(handle), { + onEvent: async step => { + if (step.type === 'touchMove' && !moving) { + moving = true; + await mark('moving'); + } + }, + }); + await waitForRest(sheet); + // The animator lets the sheet's layer go this long after the settle. + await page.waitForTimeout(PROMOTION_LINGER_MS + 200); + }); + expect(await sheetTop(sheet)).toBeCloseTo(tallTop, 0); + // The touch-down's own frame is excluded: it pins the sheet's height for + // the drag, as it did before the release rule. From the first move through + // the settle and its layer's release, nothing may re-raster. + expect(tiles.moving, JSON.stringify(tiles)).toBe(0); +}); + +test("the dim a release plays re-rasters nothing: the open sheet's backdrop keeps its layer", async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'raster traces are Chromium-only'); + const {driver, sheet, tallTop, handle} = await openSheet(page); + // The presentation's own layer goes this long after it settles. + await page.waitForTimeout(PROMOTION_LINGER_MS + 100); + // Measure the dim alone. The two pending causes above are taken out of the + // window: the animator's promotion adds to an inline will-change and keeps + // it, and the selection lock puts an inline user-select back as it found it. + await sheet.evaluate(el => { + el.style.setProperty('will-change', 'transform, opacity'); + el.style.setProperty('user-select', 'none'); + el.style.setProperty('-webkit-user-select', 'none'); + }); + await frames(page, 2); + let lifted = false; + const tiles = await traceRasterTiles(page, async mark => { + await mark('drag'); + await driver.play(outAndBack(handle), { + onEvent: async step => { + if (step.type === 'touchEnd' && !lifted) { + lifted = true; + await mark('release'); + } + }, + }); + await waitForRest(sheet); + await page.waitForTimeout(PROMOTION_LINGER_MS + 200); + }); + expect(await sheetTop(sheet)).toBeCloseTo(tallTop, 0); + // The release plays the dim back to full on the backdrop's compositor + // layer. A backdrop given a layer only for that animation is re-rastered + // when it gets one and again when it loses it. + expect(tiles.release, JSON.stringify(tiles)).toBe(0); +}); + +test( + 'storyboard: drag to the half detent and settle', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + // Gate the page's timers from here on, so the settle's completion timer can + // be held with its animation while the filmstrip is photographed. + await installTimerGate(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const clip = {x: 0, y: 0, width: viewport.width, height: viewport.height}; + const dy = Math.round((halfTop - tallTop) * 0.66); + const measure = async () => ({top: await sheetTop(sheet)}); + const dragBoard = await touchStoryboard( + page, + driver, + drag( + handle, + {x: handle.x, y: handle.y + dy}, + {durationMs: 200, hz: 30, restMs: 150}, + ), + { + name: 'bottomsheet-drag-to-detent', + clip, + measure, + columns: 5, + cellScale: 0.4, + }, + ); + await waitForAnimation(sheet); + await freezeTimers(page); + const {storyboard: settleBoard} = await animationStoryboard(page, { + name: 'bottomsheet-settle', + clip, + scope: sheet, + measure, + columns: 7, + cellScale: 0.4, + }); + 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 dragTops = dragBoard.frames.map(f => f.data.top as number); + expect( + dragTops.every((top, i) => i === 0 || top >= dragTops[i - 1] - 0.5), + ).toBe(true); + const settleTops = settleBoard.frames.map(f => f.data.top as number); + expect( + settleTops.every((top, i) => i === 0 || top >= settleTops[i - 1] - 0.5), + ).toBe(true); + expect(settleTops.at(-1)).toBeCloseTo(halfTop, 0); + }, +); + +test( + 'storyboard: a fast throw settles on the 80 % damped spring', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page); + await installTimerGate(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const clip = {x: 0, y: 0, width: viewport.width, height: viewport.height}; + const pull = Math.round((halfTop - tallTop) * 0.25); + await driver.play( + drag( + handle, + {x: handle.x, y: handle.y + pull + 150}, + { + durationMs: 100 + (pull / 150) * 100, + hz: 120, + }, + ), + ); + await waitForAnimation(sheet); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'bottomsheet-fast-throw-settle', + clip, + scope: sheet, + measure: async () => ({top: await sheetTop(sheet)}), + columns: 8, + cellScale: 0.35, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + const tops = storyboard.frames.map(f => f.data.top as number); + // An 80 % damped spring overshoots the detent (by ~1.5 % of the travel + // for a release from rest; more with the throw's velocity), then settles. + expect(Math.max(...tops)).toBeGreaterThan(halfTop + 0.5); + expect(tops.at(-1)).toBeCloseTo(halfTop, 0); + }, +); + +/** The duration the sheet's stylesheet gives its `transform` transition, ms. */ +async function themeDurationMs(sheet: Locator): Promise { + return sheet.evaluate(el => { + const style = getComputedStyle(el); + const properties = style.transitionProperty.split(',').map(p => p.trim()); + const index = Math.max(0, properties.indexOf('transform')); + const items = style.transitionDuration.split(','); + const value = items[index % items.length].trim(); + return value.endsWith('ms') + ? Number.parseFloat(value) + : Number.parseFloat(value) * 1000; + }); +} + +/** The duration of each animation running on the sheet, ms. */ +async function runningDurations(sheet: Locator): Promise { + return sheet.evaluate(el => + el + .getAnimations() + .map(a => Number(a.effect?.getComputedTiming().duration ?? 0)), + ); +} + +test("a finger on Android takes UIKit's release too, not the theme's timing (one touch model)", async ({ + page, +}) => { + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page, { + idiom: 'android', + }); + const theme = await themeDurationMs(sheet); + // Two thirds of the way to half, resting before the lift: UIKit's rule + // lands on half, on the critically damped spring, not the theme's curve. + await driver.play(slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66))); + const settle = await runningDurations(sheet); + expect(settle).toHaveLength(1); + expect(Math.abs(settle[0] - END_HOLD_MS - theme)).toBeGreaterThan(5); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); +}); + +test('a flick from tall lands on half on Android too (one touch model)', async ({ + page: firstPage, +}, testInfo) => { + // Its outcome follows the flick's speed, so WebKit's pacing is checked. + await retakeOnTouchPacing(testInfo, async attempt => { + const page = await pageForAttempt(firstPage, attempt); + const {driver, sheet, halfTop, handle} = await openSheet(page, { + idiom: 'android', + }); + // 2000 px/s for 80 ms from tall: a flick, at least to the next detent + // down, which its projection (160 + 200 px) is nearest too. + await driver.play( + flick(handle, {velocity: {x: 0, y: 2000}, durationMs: 80, hz: 120}), + {checkPacing: true}, + ); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); + }); +}); + +/** Each animation running on the sheet: the properties it changes and its duration (ms). */ +async function runningMotion( + sheet: Locator, +): Promise<{properties: string[]; ms: number}[]> { + return sheet.evaluate(el => + el.getAnimations().map(animation => { + const frames = + (animation.effect as KeyframeEffect | null)?.getKeyframes() ?? []; + const first = (frames[0] ?? {}) as Record; + const properties = new Set(); + for (const frame of frames) { + for (const [key, value] of Object.entries(frame)) { + const timing = ['offset', 'computedOffset', 'easing', 'composite']; + if (!timing.includes(key) && String(value) !== String(first[key])) { + properties.add(key); + } + } + } + return { + properties: [...properties], + ms: Number(animation.effect?.getComputedTiming().duration ?? 0), + }; + }), + ); +} + +for (const idiom of ['ios', 'android'] as const) { + test(`under reduced motion a touch release cross-fades in place, 200 ms or less (${idiom}; design:reduced-motion DR2)`, async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, sheet, tallTop, halfTop, handle} = await openSheet(page, { + idiom, + }); + await driver.play( + slowDragTo(handle, Math.round((halfTop - tallTop) * 0.66)), + ); + // A fade, and nothing that outlasts the record's 200 ms: no spring. + const motion = await runningMotion(sheet); + expect(motion.some(m => m.properties.includes('opacity'))).toBe(true); + expect(motion.every(m => m.ms - END_HOLD_MS <= 200.5)).toBe(true); + await waitForRest(sheet); + expect(await sheetTop(sheet)).toBeCloseTo(halfTop, 0); + }); +} diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetDrag.a11y.chromium.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetDrag.a11y.chromium.spec.ts index 01dd2de446d63..8a76934f3b370 100644 --- a/packages/core/src/BottomSheet/__tests__/BottomSheetDrag.a11y.chromium.spec.ts +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetDrag.a11y.chromium.spec.ts @@ -9,7 +9,7 @@ * @position Chromium proof for useSheetGestures' live transform path */ -import {expect, test, type Page} from '@playwright/test'; +import {expect, test, type Locator, type Page} from '@playwright/test'; import { DEFAULT_STORYBOOK_DIR, serveStorybook, @@ -49,16 +49,43 @@ async function openSheet(page: Page) { // The sheet panel carries the component class and the transform. const sheet = dialog.locator('.astryx-bottom-sheet').first(); await expect(sheet).toBeVisible(); - await page.waitForTimeout(600); // the entrance + // The entrance: the sheet takes no input until it has landed. + await expect + .poll(async () => sheet.evaluate(el => el.getAnimations().length)) + .toBe(0); return {dialog, sheet}; } -function tyOf(transform: string): number { - const match = /translateY\((-?[\d.]+)px\)/.exec(transform); - return match ? Number(match[1]) : 0; +// The sheet's travel, inline: its resting transform plus the travel channel +// a drag moves it on (`translate`), written per move. +async function inlineTravel(sheet: Locator): Promise { + return sheet.evaluate(el => { + const {style} = el as HTMLElement; + const resting = /translateY\((-?[\d.]+)px\)/.exec(style.transform); + const [, travel = '0'] = style.translate.split(' '); + return ( + (resting == null ? 0 : Number(resting[1])) + + (Number.parseFloat(travel) || 0) + ); + }); } -test('a drag writes the transform per move and renders nothing between', async ({ +// The same as drawn, from the computed style: a release animates the travel +// channel on the compositor. +async function drawnTravel(sheet: Locator): Promise { + return sheet.evaluate(el => { + const computed = getComputedStyle(el); + const resting = + computed.transform === 'none' ? 0 : new DOMMatrix(computed.transform).m42; + const travel = + computed.translate === 'none' + ? 0 + : Number.parseFloat(computed.translate.split(' ')[1] ?? '0') || 0; + return resting + travel; + }); +} + +test('a drag writes its travel per move and renders nothing between', async ({ page, }) => { await page.addInitScript(COMMIT_COUNTER); @@ -86,9 +113,7 @@ test('a drag writes the transform per move and renders nothing between', async ( type: 'touchMove', touchPoints: [{x, y: handleY + i * 5}], }); - trail.push( - tyOf(await sheet.evaluate(el => (el as HTMLElement).style.transform)), - ); + trail.push(await inlineTravel(sheet)); await page.waitForTimeout(16); } const commitsDuring = await page.evaluate( @@ -99,7 +124,7 @@ test('a drag writes the transform per move and renders nothing between', async ( } ).__REACT_DEVTOOLS_GLOBAL_HOOK__.commits, ); - // The transform followed the finger (inline, written per move; the first + // The travel followed the finger (inline, written per move; the first // samples ease out of the magnet around the resting detent) ... expect(trail[trail.length - 1]).toBeGreaterThanOrEqual(100); expect(trail.filter((ty, i) => i === 0 || ty >= trail[i - 1]).length).toBe( @@ -112,18 +137,12 @@ test('a drag writes the transform per move and renders nothing between', async ( type: 'touchEnd', touchPoints: [], }); - // The release animates back from where the finger left the sheet: the - // computed transform is still off rest a frame later, and at rest after the - // transition. + // The release animates back from where the finger left the sheet: still + // off rest as drawn a frame later, and at rest once it lands. await page.waitForTimeout(40); - const midway = await sheet.evaluate(el => getComputedStyle(el).transform); - const midwayTy = /matrix\(1, 0, 0, 1, 0, (-?[\d.]+)\)/.exec(midway); - expect(midwayTy, midway).not.toBeNull(); - expect(Number(midwayTy?.[1])).toBeGreaterThan(5); + expect(await drawnTravel(sheet)).toBeGreaterThan(5); await expect - .poll(async () => sheet.evaluate(el => getComputedStyle(el).transform), { - timeout: 2_000, - }) - .toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/); + .poll(async () => Math.abs(await drawnTravel(sheet)), {timeout: 2_000}) + .toBeLessThan(0.5); await expect(page.getByRole('dialog', {name: 'Nearby places'})).toBeVisible(); }); diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetPresent.interaction.browser.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetPresent.interaction.browser.spec.ts new file mode 100644 index 0000000000000..89cb04ce98b5a --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetPresent.interaction.browser.spec.ts @@ -0,0 +1,577 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file BottomSheetPresent.interaction.browser.spec.ts + * @input The built "Snap points" BottomSheet story, real touch input + * (Chromium CDP / WebKit's input command), the interaction harness and the + * UIKit constants table (the parameters the oracle spring uses) + * @output Interaction and animation proofs that, under a finger, the sheet + * opens and closes on UIKit's sheet spring: the entrance and the exit + * sampled frame by frame against an independent spring oracle (from the + * sheet's resting top to the bottom of the screen and back), the dim on the + * same spring's progress, the entrance advancing every frame through a + * blocked main thread, a fade in place of 200 ms or less under reduced + * motion (design:reduced-motion DR2), + * an exit whose clock starts when the browser commits it, and a storyboard + * of the entrance. A finger on Android gets the same (one touch model, + * AST-094). + * @position Interaction proof for BottomSheet's presentation, in the shape of + * BottomSheetDetent.interaction.browser.spec.ts + * + * The sheet travels on the `translate` property (the motion foundation's + * animator) from its resting `transform`; the dim is the dialog's + * `::backdrop` opacity, animated by the same animator. + * + * UIKit's presentation belongs to every finger (one touch model), so most + * cases run under `emulateIosWebKit` and the Android cases without it, to + * show the same behavior without WebKit on iOS. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + busyWindowProof, + castStoryboard, + compareCurve, + drag, + freezeTimers, + holdAnimations, + installTimerGate, + markElement, + openStory, + releaseAnimations, + springStep, + startCompositorTrace, + startScreencast, + thawTimers, + timeline, + touchDriver, + trackMarker, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, + type TimedValue, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {END_HOLD_MS} from './specConstants'; +import {springs} from '../../motion/uikit/springs'; +import {emulateIosWebKit} from './emulateIosWebKit'; + +const storybook = withStorybook(test); + +/** + * The oracle's parameters for a table spring. A Core Animation spring + * (mass, stiffness, damping) renders critically damped when overdamped, as + * UIKit attaches it (allowsOverdamping NO). + */ +function oracleParameters( + spring: + | {readonly response: number; readonly dampingRatio: number} + | { + readonly mass: number; + readonly stiffness: number; + readonly damping: number; + }, +): {response: number; dampingFraction: number} { + if ('response' in spring) { + return {response: spring.response, dampingFraction: spring.dampingRatio}; + } + const omega = Math.sqrt(spring.stiffness / spring.mass); + return { + response: (2 * Math.PI) / omega, + dampingFraction: Math.min( + 1, + spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass)), + ), + }; +} + +/** The sheet spring from `from` to `to` px, from rest. */ +function sheetSpring( + spring: Parameters[0], + from: number, + to: number, +) { + const parameters = oracleParameters(spring); + return (t: number) => from + (to - from) * springStep(parameters, t); +} + +interface Story { + driver: TouchDriver; + dialog: Locator; + sheet: Locator; + tapTrigger: () => Promise; +} + +async function story( + page: Page, + {idiom = 'ios'}: {idiom?: 'ios' | 'android'} = {}, +): Promise { + if (idiom === 'ios') { + await emulateIosWebKit(page); + } + await openStory(page, storybook(), 'core-bottomsheet--snap-points'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Open nearby places'}); + // CSS, not the role: a closing dialog is aria-hidden and inert, and must + // still be found while its exit runs. + const dialog = page.locator('dialog[aria-label="Nearby places"]'); + return { + driver, + dialog, + sheet: dialog.locator('.astryx-bottom-sheet').first(), + tapTrigger: async () => { + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }, + }; +} + +/** + * The sheet's travel and the dim at each time (ms from the held animations' + * start): its `translate` plus its `transform`, and the `::backdrop` + * opacity. Hold the animations first. + */ +async function sampleSheet( + dialog: Locator, + sheet: Locator, + times: ReadonlyArray, +): Promise<{t: number; travel: number; dim: number}[]> { + const element = await sheet.elementHandle(); + return dialog.evaluate( + (dialogElement, {ts, sheetElement}) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? + []; + return ts.map(t => { + for (const animation of held) { + const end = Number( + animation.effect?.getComputedTiming().endTime ?? Infinity, + ); + animation.currentTime = Math.min(t, end - 0.5); + } + const style = getComputedStyle(sheetElement as Element); + const translate = + style.translate === 'none' + ? 0 + : Number.parseFloat(style.translate.split(' ')[1] ?? '0'); + const transform = + style.transform === 'none' ? 0 : new DOMMatrix(style.transform).m42; + return { + t, + travel: translate + transform, + dim: Number(getComputedStyle(dialogElement, '::backdrop').opacity), + }; + }); + }, + {ts: [...times], sheetElement: element}, + ); +} + +const sheetTop = async (sheet: Locator) => + sheet.evaluate(el => el.getBoundingClientRect().y); + +test('opens on the sheet spring from below the screen, the dim on the same curve', async ({ + page, +}) => { + const {dialog, sheet, tapTrigger} = await story(page); + await tapTrigger(); + const [entrance] = await waitForAnimation(sheet); + await holdAnimations(page, dialog); + const samples = await sampleSheet( + dialog, + sheet, + timeline(entrance.durationMs), + ); + await releaseAnimations(page); + await waitForRest(sheet); + const restTop = await sheetTop(sheet); + const viewportHeight = page.viewportSize()?.height ?? 0; + // From the bottom of the screen to the sheet's rest. + const from = samples[0].travel; + expect(from).toBeCloseTo(viewportHeight - restTop, 0); + const travel: TimedValue[] = samples.map(s => ({t: s.t, value: s.travel})); + const result = compareCurve( + travel, + sheetSpring(springs.sheetPresent, from, 0), + 0.5, + ); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.5, + ); + // The dim rises with the spring's progress: clear below the screen. + for (const s of samples) { + expect(s.dim, `t=${s.t}`).toBeCloseTo(1 - s.travel / from, 2); + } +}); + +test('closes on the sheet spring, back below the screen', async ({page}) => { + const {driver, dialog, sheet, tapTrigger} = await story(page); + await tapTrigger(); + await expect(sheet).toBeVisible(); + await waitForRest(sheet); + const restTop = await sheetTop(sheet); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const viewportHeight = viewport.height; + // A tap on the dimmed page above the sheet closes it (purpose "info"). + await driver.tap({x: viewport.width / 2, y: restTop / 2}); + const [exit] = await waitForAnimation(sheet); + await holdAnimations(page, dialog); + const samples = await sampleSheet(dialog, sheet, timeline(exit.durationMs)); + await releaseAnimations(page); + const distance = viewportHeight - restTop; + const travel: TimedValue[] = samples.map(s => ({t: s.t, value: s.travel})); + const result = compareCurve( + travel, + sheetSpring(springs.sheetDismiss, 0, distance), + 0.5, + ); + expect(result.maxError, JSON.stringify(result.worst)).toBeLessThanOrEqual( + 0.5, + ); + for (const s of samples) { + expect(s.dim, `t=${s.t}`).toBeCloseTo(1 - s.travel / distance, 2); + } + await expect(dialog).not.toHaveAttribute('open'); +}); + +test('during the entrance a tap on the dim and a grab are ignored, and input returns as the sheet lands (UIKit, F4 b59)', async ({ + page, +}) => { + const {driver, dialog, sheet, tapTrigger} = await story(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const center = viewport.width / 2; + // When each contact ends, on the page's clock: under load the grab's end can + // land after the sheet does. + await page.evaluate(() => { + window.addEventListener( + 'pointerup', + () => { + (window as unknown as {__lastLift?: number}).__lastLift = + performance.now(); + }, + true, + ); + }); + await tapTrigger(); + // ~60 ms in: a tap on the dimmed page above the sheet. + await page.waitForTimeout(60); + await driver.tap({x: center, y: 30}); + // Right after: a finger lands on the rising sheet and drags 120 px down. + await driver.play( + drag({x: center, y: 420}, {x: center, y: 540}, {durationMs: 300, hz: 60}), + ); + await waitForRest(sheet); + // Neither reached it: the sheet presented to its detent and stayed. + await expect(dialog).toHaveAttribute('open'); + expect(await sheetTop(sheet)).toBeLessThan(viewport.height * 0.15); + // Landed: the hold is released and a tap on the dim closes the sheet. A tap + // within about 300 ms of a touch drag's end is the browser's (Chromium makes + // no click of it: it ends that gesture), so the tap waits that out from the + // grab's own end. + await page.waitForFunction(() => { + const lift = (window as unknown as {__lastLift?: number}).__lastLift; + return lift != null && performance.now() - lift >= 400; + }); + await driver.tap({x: center, y: 30}); + await expect(dialog).not.toHaveAttribute('open'); +}); + +test('a non-modal sheet holds only itself while it enters: a tap on the page beside it lands (spec:AST-094 FR35)', async ({ + page, +}) => { + await emulateIosWebKit(page); + await openStory(page, storybook(), 'core-bottomsheet--no-scrim'); + const driver = await touchDriver(page); + const tapOn = async (locator: Locator) => { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('nothing to tap'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + }; + const counter = page.getByRole('button', {name: /Background clicks/}); + await tapOn(page.getByRole('button', {name: 'Open sheet'})); + // ~60 ms into the entrance: the page's counter, above the rising sheet. + await page.waitForTimeout(60); + await tapOn(counter); + await expect(counter).toHaveAccessibleName('Background clicks: 1'); + // The sheet still finishes entering and stays open (a non-modal sheet is + // a manual popover). + await expect + .poll(async () => + page + .locator('dialog[aria-label="Nearby places"]') + .evaluate(dialog => dialog.matches(':popover-open')), + ) + .toBe(true); +}); + +test('during the entrance keys stay live: Escape closes the sheet once it lands (spec:AST-094 FR35)', async ({ + page, +}) => { + const {dialog, sheet, tapTrigger} = await story(page); + await tapTrigger(); + await page.waitForTimeout(60); + // Only pointers are held: Escape during the entrance asks to close, and + // the close waits for the sheet to land, then dismisses (F4 b59). + await page.keyboard.press('Escape'); + await waitForRest(sheet); + await expect(dialog).not.toHaveAttribute('open'); +}); + +test('the entrance keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName === 'webkit', + "Playwright WebKit's screencast sends no frames while a dialog's entrance runs; the iOS simulator recording is the WebKit proof", + ); + const {sheet, tapTrigger} = await story(page); + const cast = await startScreencast(page); + // What the compositor presented: frames the screencast skips under load + // while the compositor draws them are not stalls. + const compositor = await startCompositorTrace(page); + await tapTrigger(); + await markElement(sheet); + const [entrance] = await waitForAnimation(sheet, {minProgressMs: 16}); + const block = await blockMainThread(page, 700); + await page.waitForTimeout(150); + const castFrames = await cast.stop(); + const presented = await compositor.stop(); + const tracked = await trackMarker(page, castFrames); + // Judge while the sheet still moves more than a screencast frame resolves + // per frame. This spring (ω 18.3/s) moves about 0.3 × the remaining + // distance a 60 Hz frame, under Chromium's 1 px within ~4 px of the end; + // WebKit's frames resolve 2 px every ~11 ms, so 10 px there. + const proof = busyWindowProof( + tracked, + {from: block.from, to: Math.min(block.to, entrance.to)}, + {settledWithin: browserName === 'webkit' ? 10 : 4, presented}, + ); + await writeEvidence( + 'busy-main-thread/bottomsheet-present', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + entrance, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + y: f.box?.y ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'bottomsheet-present-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: Math.min(block.to, entrance.to) + 40, + columns: 8, + cellScale: 0.35, + data: t => ({y: tracked.find(f => f.t === t)?.box?.y ?? -1}), + }); + await film.write(testInfo, {block, entrance}); + testInfo.annotations.push({ + type: 'busy-main-thread', + description: proof.summary, + }); + // Judged per delivered frame, whatever the screencast's rate: frames + // presented, no long stall, nearly every frame moving, over at least the + // harness's fewest judged frames. + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); +}); + +for (const idiom of ['ios', 'android'] as const) { + test(`under reduced motion the sheet fades in place, 200 ms or less (${idiom}; design:reduced-motion DR2)`, async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + "Playwright WebKit commits no frame while a dialog's entrance runs, so an entrance that starts on its first committed frame never starts there; the iOS simulator's reduced-motion recording is the WebKit proof", + ); + await page.emulateMedia({reducedMotion: 'reduce'}); + const {dialog, sheet, tapTrigger} = await story(page, {idiom}); + await tapTrigger(); + await expect(sheet).toBeVisible(); + const [fade] = await waitForAnimation(sheet); + await holdAnimations(page, dialog); + const samples = await sheet.evaluate( + (element, ts) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? + []; + return ts.map(t => { + for (const animation of held) { + animation.currentTime = t; + } + const style = getComputedStyle(element); + const translate = + style.translate === 'none' + ? 0 + : Number.parseFloat(style.translate.split(' ')[1] ?? '0'); + return {t, opacity: Number(style.opacity), translate}; + }); + }, + [0, 25, 50, 75, 100, 125, 150, 175, 200, 250, 400], + ); + await releaseAnimations(page); + // The fade, without the animator's held end value. + const fadeMs = + fade.durationMs > END_HOLD_MS + ? fade.durationMs - END_HOLD_MS + : fade.durationMs; + expect(fadeMs).toBeLessThanOrEqual(200.5); + // In place from the first frame, rising from clear to full by 200 ms. + expect(samples[0].opacity).toBeCloseTo(0, 1); + for (let i = 0; i < samples.length; i++) { + expect(samples[i].translate, `t=${samples[i].t}`).toBe(0); + if (i > 0) { + expect(samples[i].opacity).toBeGreaterThanOrEqual( + samples[i - 1].opacity - 1e-6, + ); + } + } + expect(samples.find(s => s.t === 200)?.opacity).toBeCloseTo(1, 2); + }); +} + +test( + 'storyboard: the sheet opens on its spring', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {dialog, sheet, tapTrigger} = await story(page); + // Gate the page's timers, so the entrance's completion timer is held with + // its animation while the filmstrip is photographed. + await installTimerGate(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + await tapTrigger(); + await waitForAnimation(sheet); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'bottomsheet-present', + clip: {x: 0, y: 0, width: viewport.width, height: viewport.height}, + scope: dialog, + measure: async () => ({ + top: await sheetTop(sheet), + dim: await dialog.evaluate(el => + Number(getComputedStyle(el, '::backdrop').opacity), + ), + }), + columns: 7, + cellScale: 0.4, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + // The top only falls toward its rest and the dim only rises. + const frames = storyboard.frames.map(f => ({ + top: f.data.top as number, + dim: f.data.dim as number, + })); + for (let i = 1; i < frames.length; i++) { + expect(frames[i].top).toBeLessThanOrEqual(frames[i - 1].top + 0.5); + expect(frames[i].dim).toBeGreaterThanOrEqual(frames[i - 1].dim - 0.005); + } + expect(frames[0].dim).toBeLessThan(0.1); + expect(frames.at(-1)?.dim).toBeCloseTo(1, 1); + }, +); + +test('the exit starts when the browser commits it, not when it is called', async ({ + page, +}) => { + // Dated from the call, an exit a long task follows skips into its curve on + // iOS (its first frames never show); left without a start time, the + // browser starts it when it commits the frame (the animator, from rest). + const {dialog, sheet, tapTrigger} = await story(page); + await tapTrigger(); + await expect(sheet).toBeVisible(); + await waitForRest(sheet); + const startTimes = await dialog.evaluate(async el => { + // A tap on the dim: the dialog itself is the click's target. + el.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true}), + ); + // The close commits in a microtask. Read in the next animation frame, + // once an animator that creates calls from rest a frame late has + // created them, but before that frame commits them. + await Promise.resolve(); + await Promise.resolve(); + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + return document + .getAnimations() + .filter(a => { + const target = (a.effect as KeyframeEffect | null)?.target; + return target === el || (target != null && el.contains(target)); + }) + .map(a => a.startTime); + }); + // The sheet's travel and the dim, both waiting for the commit. + expect(startTimes.length).toBeGreaterThanOrEqual(2); + expect(startTimes).toEqual(startTimes.map(() => null)); + await expect(dialog).not.toHaveAttribute('open'); +}); + +test('on Android the sheet opens on the spring too, holding input (one touch model)', async ({ + page, +}) => { + const {dialog, sheet, tapTrigger} = await story(page, {idiom: 'android'}); + await tapTrigger(); + await expect(sheet).toBeVisible(); + // The animator's spring on the travel channel, and the input hold over the + // page while it presents, as on iOS. + const kinds = await sheet.evaluate(el => + el.getAnimations().map(a => a.constructor.name), + ); + expect(kinds).toContain('Animation'); + await waitForRest(sheet); + const restTop = await sheetTop(sheet); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + await page.mouse.click(viewport.width / 2, restTop / 2); + await expect(dialog).not.toHaveAttribute('open'); +}); + +test.describe('with a mouse', () => { + test.use({isMobile: false, hasTouch: false}); + + test('under reduced motion with a mouse the sheet opens and closes at once (design:reduced-motion DR3)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'core-bottomsheet--snap-points'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const dialog = page.locator('dialog[aria-label="Nearby places"]'); + const sheet = dialog.locator('.astryx-bottom-sheet').first(); + await page.getByRole('button', {name: 'Open nearby places'}).click(); + await expect(sheet).toBeVisible(); + // Nothing animates, the scrim included: the stylesheet's reduced-motion + // arm for a fine pointer turns every transition off. + const running = await dialog.evaluate(el => + el + .getAnimations({subtree: true}) + .map(a => Number(a.effect?.getComputedTiming().duration ?? 0)), + ); + expect(running.every(ms => ms === 0)).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).not.toHaveAttribute('open'); + }); +}); diff --git a/packages/core/src/BottomSheet/__tests__/BottomSheetTouchscreenLaptop.a11y.chromium.spec.ts b/packages/core/src/BottomSheet/__tests__/BottomSheetTouchscreenLaptop.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..5a1940f74a7c0 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/BottomSheetTouchscreenLaptop.a11y.chromium.spec.ts @@ -0,0 +1,111 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file BottomSheetTouchscreenLaptop.a11y.chromium.spec.ts + * @input Built BottomSheet stories, Chromium as a touchscreen laptop (a fine + * primary pointer and a screen that takes touches), and real touch input + * (CDP) + * @output Browser evidence that a finger pulls the sheet down from the top of + * its content where the primary pointer is fine, as it does on a phone + * @position Chromium proof for useSheetGestures' body pull under a finger + * on a touchscreen laptop + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +// A touchscreen laptop: the mouse is the primary pointer, and the screen +// sends touches. (Playwright's `hasTouch` would make the primary pointer +// coarse, a phone's.) +test.use({ + viewport: {width: 1280, height: 800}, + hasTouch: false, + launchOptions: { + args: [ + '--touch-events=enabled', + '--blink-settings=primaryPointerType=4,availablePointerTypes=6,primaryHoverType=2,availableHoverTypes=3', + ], + }, +}); + +async function openSheet(page: Page): Promise { + await page.goto( + `${storybook.origin}/iframe.html?id=core-bottomsheet--snap-points&viewMode=story`, + ); + await page.getByRole('button', {name: 'Open nearby places'}).click(); + const dialog = page.getByRole('dialog', {name: 'Nearby places'}); + await expect(dialog).toBeVisible(); + const sheet = dialog.locator('.astryx-bottom-sheet').first(); + await expect(sheet).toBeVisible(); + await page.waitForTimeout(600); // the entrance + return sheet; +} + +async function sheetTop(sheet: Locator): Promise { + return sheet.evaluate(el => el.getBoundingClientRect().y); +} + +test('the primary pointer is fine and the screen takes touches', async ({ + page, +}) => { + await openSheet(page); + expect( + await page.evaluate(() => [ + matchMedia('(pointer: coarse)').matches, + matchMedia('(any-pointer: coarse)').matches, + 'ontouchstart' in window, + ]), + ).toEqual([false, true, true]); +}); + +test('a finger pulls the sheet down from the top of its content', async ({ + page, +}) => { + const sheet = await openSheet(page); + const top = await sheetTop(sheet); + const box = await sheet.boundingBox(); + if (box == null) { + throw new Error('sheet not laid out'); + } + const cdp = await page.context().newCDPSession(page); + const x = box.x + box.width / 2; + const y = box.y + 200; + let time = Date.now() / 1000; + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x, y, id: 1}], + timestamp: time, + }); + // 120 px down, 4 px every 16 ms. + for (let i = 1; i <= 30; i += 1) { + time += 0.016; + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{x, y: y + i * 4, id: 1}], + timestamp: time, + }); + } + // The sheet follows the finger; the content does not scroll instead. + await expect + .poll(async () => (await sheetTop(sheet)) - top) + .toBeGreaterThan(80); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + timestamp: time + 0.4, + }); +}); diff --git a/packages/core/src/BottomSheet/__tests__/emulateIosWebKit.ts b/packages/core/src/BottomSheet/__tests__/emulateIosWebKit.ts new file mode 100644 index 0000000000000..bbeb186cc8d6e --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/emulateIosWebKit.ts @@ -0,0 +1,58 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file emulateIosWebKit.ts + * @input A Playwright page, before it loads the story + * @output Exports emulateIosWebKit: the page takes the iOS gate. From the + * first script on, `CSS.supports` answers yes for `-webkit-touch-callout` + * (only WebKit on iOS implements it), so useTouchIdiom() reads `ios` under + * the emulated finger; every `@supports (-webkit-touch-callout: none)` + * rule is rewritten to a condition the engine meets, keeping the media + * query outside it. Everything else is the engine's own. + * @position Test helper of the BottomSheet interaction specs (the same + * helper as the ContextMenu specs'). Real iOS Safari is the simulator + * recording; without this, the same specs see Chromium's and desktop + * WebKit's own answer (the Android fallback). + */ + +import type {Page} from '@playwright/test'; + +export async function emulateIosWebKit(page: Page): Promise { + await page.addInitScript(() => { + const original = CSS.supports.bind(CSS); + CSS.supports = (condition: string, value?: string) => + `${condition}${value ?? ''}`.includes('-webkit-touch-callout') || + (value === undefined ? original(condition) : original(condition, value)); + const visit = (owner: CSSStyleSheet | CSSGroupingRule) => { + const rules = owner.cssRules; + for (let i = 0; i < rules.length; i += 1) { + const rule = rules[i]; + if ( + rule instanceof CSSSupportsRule && + rule.conditionText.includes('-webkit-touch-callout') + ) { + const inner = Array.from(rule.cssRules, r => r.cssText).join('\n'); + owner.deleteRule(i); + owner.insertRule(`@supports (display: block) {${inner}}`, i); + } else if (rule instanceof CSSGroupingRule) { + visit(rule); + } + } + }; + const rewrite = () => { + for (const sheet of Array.from(document.styleSheets)) { + try { + visit(sheet); + } catch { + // A cross-origin sheet. + } + } + }; + // Sheets arrive as the story's modules load. + new MutationObserver(rewrite).observe(document, { + childList: true, + subtree: true, + }); + addEventListener('load', rewrite); + }); +} diff --git a/packages/core/src/BottomSheet/__tests__/specConstants.test.ts b/packages/core/src/BottomSheet/__tests__/specConstants.test.ts new file mode 100644 index 0000000000000..237acca4b62a9 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/specConstants.test.ts @@ -0,0 +1,18 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file specConstants.test.ts + * @input specConstants.ts and the modules its values come from + * @output Fails when a browser-spec constant drifts from the source's value + * @position Guard for BottomSheet's browser specs + */ + +import {describe, expect, it} from 'vitest'; +import {END_HOLD_MS} from '../../motion/animator'; +import * as spec from './specConstants'; + +describe("BottomSheet's browser-spec constants", () => { + it("match the animator's end-state hold", () => { + expect(spec.END_HOLD_MS).toBe(END_HOLD_MS); + }); +}); diff --git a/packages/core/src/BottomSheet/__tests__/specConstants.ts b/packages/core/src/BottomSheet/__tests__/specConstants.ts new file mode 100644 index 0000000000000..21556803dace3 --- /dev/null +++ b/packages/core/src/BottomSheet/__tests__/specConstants.ts @@ -0,0 +1,16 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file specConstants.ts + * @input Nothing: plain values + * @output END_HOLD_MS, the animator's end-state hold, for the browser specs + * @position Internal to BottomSheet's browser specs + * + * The browser specs load in Node, where the animator cannot be imported at + * runtime: it reads the touch presentation, whose compact definition comes + * with the theme's StyleX variables, which only compile in a bundle. + * specConstants.test.ts keeps these values equal to the source's. + */ + +/** The keyframes hold the end state this long after a curve ends (motion/animator). */ +export const END_HOLD_MS = 60_000; diff --git a/packages/core/src/BottomSheet/releaseMotion.test.ts b/packages/core/src/BottomSheet/releaseMotion.test.ts new file mode 100644 index 0000000000000..d95633d206b8a --- /dev/null +++ b/packages/core/src/BottomSheet/releaseMotion.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file releaseMotion.test.ts + * @input vitest, jsdom and readReleaseMotion + * @output Which timing a sheet's release runs on: UIKit's under a finger, + * the stylesheet's transform transition under a mouse or a finger on + * Android (lists, keywords, transitions turned off, unreadable values); + * the theme curve's values + * @position Testing; validates releaseMotion.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {readReleaseMotion} from './releaseMotion'; + +/** A finger or a mouse; with `ios`, in WebKit on iOS (which changes nothing: one touch model). */ +function pointer(coarse: boolean, {ios = false}: {ios?: boolean} = {}) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: coarse && query.includes('pointer: coarse'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + supports: (condition: string) => + ios && condition.includes('-webkit-touch-callout'), + }); +} + +const THEME = { + transitionProperty: 'transform', + transitionDuration: '410ms', + transitionTimingFunction: 'cubic-bezier(0.24, 1, 0.4, 1)', +}; + +function sheet(style: Partial): HTMLElement { + const element = document.createElement('div'); + Object.assign(element.style, style); + return element; +} + +beforeEach(() => pointer(false)); +afterEach(() => vi.unstubAllGlobals()); + +describe('readReleaseMotion', () => { + it('runs on UIKit springs under a finger', () => { + pointer(true, {ios: true}); + expect(readReleaseMotion(sheet(THEME), 'touch')).toEqual({kind: 'uikit'}); + }); + + it('runs on UIKit springs under a finger on Android too (one touch model)', () => { + pointer(true); + expect(readReleaseMotion(sheet(THEME), 'touch')).toEqual({kind: 'uikit'}); + }); + + it('runs on UIKit springs under a pen where the primary pointer is coarse', () => { + pointer(true, {ios: true}); + expect(readReleaseMotion(sheet(THEME), 'pen')).toEqual({kind: 'uikit'}); + }); + + it('keeps the theme timing under a mouse where the primary pointer is coarse (spec:AST-094 FR13)', () => { + pointer(true, {ios: true}); + expect(readReleaseMotion(sheet(THEME), 'mouse')).toMatchObject({ + kind: 'theme', + }); + }); + + it('keeps the theme timing under a finger where the primary pointer is fine (spec:AST-094 FR13)', () => { + pointer(false); + expect(readReleaseMotion(sheet(THEME), 'touch')).toMatchObject({ + kind: 'theme', + }); + }); + + it("keeps the theme timing under a mouse, in WebKit on iOS's engine too", () => { + pointer(false, {ios: true}); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + expect(readReleaseMotion(sheet(THEME), 'mouse')).toMatchObject({ + kind: 'theme', + }); + }); + + it('reads the transform transition under a mouse, from a list', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const motion = readReleaseMotion( + sheet({ + transitionProperty: 'opacity, transform', + transitionDuration: '0.2s, 0.41s', + transitionDelay: '0s, 0s', + transitionTimingFunction: 'linear, cubic-bezier(0.24, 1, 0.4, 1)', + }), + 'mouse', + ); + expect(motion.kind).toBe('theme'); + if (motion.kind === 'theme') { + expect(motion.durationMs).toBeCloseTo(410, 6); + expect(motion.easing(0.5)).toBeGreaterThan(0.9); // a fast-out curve + } + }); + + it('understands the CSS keywords', () => { + const motion = readReleaseMotion( + sheet({ + transitionProperty: 'all', + transitionDuration: '300ms', + transitionTimingFunction: 'ease-in', + }), + 'mouse', + ); + expect(motion.kind === 'theme' && motion.easing(0.5)).toBeCloseTo(0.315, 2); + }); + + it('does not animate when transitions are off', () => { + const motion = readReleaseMotion( + sheet({transitionProperty: 'none'}), + 'mouse', + ); + expect(motion).toMatchObject({kind: 'theme', durationMs: 0}); + const notTransform = readReleaseMotion( + sheet({transitionProperty: 'opacity', transitionDuration: '1s'}), + 'mouse', + ); + expect(notTransform).toMatchObject({kind: 'theme', durationMs: 0}); + }); + + it('plays an unreadable easing linearly, and settles at once without an element', () => { + const motion = readReleaseMotion( + sheet({ + transitionProperty: 'transform', + transitionDuration: '410ms', + transitionTimingFunction: 'steps(4, end)', + }), + 'mouse', + ); + expect(motion).toMatchObject({kind: 'theme', durationMs: 410}); + expect(motion.kind === 'theme' && motion.easing(0.3)).toBeCloseTo(0.3, 9); + expect(readReleaseMotion(null, 'mouse')).toMatchObject({ + kind: 'theme', + durationMs: 0, + }); + }); +}); diff --git a/packages/core/src/BottomSheet/releaseMotion.ts b/packages/core/src/BottomSheet/releaseMotion.ts new file mode 100644 index 0000000000000..45821f40352e2 --- /dev/null +++ b/packages/core/src/BottomSheet/releaseMotion.ts @@ -0,0 +1,143 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file releaseMotion.ts + * @input The shared touch presentation resolver, the motion foundation's + * cubic-bézier and the sheet element's computed transition + * @output Exports readReleaseMotion (which timing a sheet's release runs on: + * UIKit's springs, or the theme's timing for the interactive-transition + * driver to play) + * @position Internal to BottomSheet; read once per gesture by + * useSheetGestures + * + * A finger or a pen where the primary pointer is coarse (`readTouchPresentation`; + * one touch model on every touch device, iOS and Android alike) moves the + * sheet the way a UIKit sheet does: its release runs on UIKit's springs, + * handed the finger's velocity (spec:AST-094 FR13). A mouse, even where the + * primary pointer is coarse, and anything where it is fine keep the theme's + * motion: the duration and easing its stylesheet gives `transform`, as today + * (spec:AST-017 FR59). The drag's tracking follows the same answer. + * Either way the curve plays on the compositor, through the same animator. + * + * The theme timing is read from the element's computed transition when a + * drag begins, before the drag turns transitions off, so a theme or a + * caller's `transition` keeps meaning what it did. Under reduced motion the + * animator replaces either curve (design:reduced-motion). + * + * SYNC: When modified, update /packages/core/src/BottomSheet/useSheetGestures.ts + */ + +import {cubicBezier, type BezierPoints} from '../motion/bezier'; +import {readTouchPresentation} from './sheetTouch'; + +export type ReleaseMotion = + | {readonly kind: 'uikit'} + | { + readonly kind: 'theme'; + /** How long a settle takes, ms (0: transitions are off). */ + readonly durationMs: number; + readonly easing: (progress: number) => number; + }; + +const UIKIT: ReleaseMotion = {kind: 'uikit'}; + +// The CSS keywords, as cubic-bézier control points. +const KEYWORDS: Record = { + ease: [0.25, 0.1, 0.25, 1], + 'ease-in': [0.42, 0, 1, 1], + 'ease-out': [0, 0, 0.58, 1], + 'ease-in-out': [0.42, 0, 0.58, 1], +}; + +/** Splits a computed list on its top-level commas (not those inside `cubic-bezier(…)`). */ +function splitList(list: string): string[] { + const items: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < list.length; i++) { + const char = list[i]; + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + } else if (char === ',' && depth === 0) { + items.push(list.slice(start, i).trim()); + start = i + 1; + } + } + items.push(list.slice(start).trim()); + return items; +} + +function parseTime(value: string): number | null { + const time = Number.parseFloat(value); + if (!Number.isFinite(time)) { + return null; + } + return value.trim().endsWith('ms') ? time : time * 1000; +} + +function parseEasing(value: string): ((progress: number) => number) | null { + if (value === 'linear') { + return progress => progress; + } + const keyword = KEYWORDS[value]; + if (keyword != null) { + return cubicBezier(keyword); + } + const match = /^cubic-bezier\(([^)]*)\)$/.exec(value); + if (match == null) { + return null; + } + const points = match[1].split(',').map(Number); + if (points.length !== 4 || points.some(point => !Number.isFinite(point))) { + return null; + } + return cubicBezier(points as unknown as BezierPoints); +} + +/** + * The timing a release of `element` dragged by `pointerType` runs on: UIKit's + * for a finger or a pen where the primary pointer is coarse, else the + * duration and easing its stylesheet transitions `transform` with. An + * easing it cannot read (`steps()`) plays linearly over that duration; no + * element or no readable duration settles at once. + */ +export function readReleaseMotion( + element: HTMLElement | null, + pointerType: string, +): ReleaseMotion { + if ( + readTouchPresentation().isTouch && + (pointerType === 'touch' || pointerType === 'pen') + ) { + return UIKIT; + } + if (element == null || typeof getComputedStyle !== 'function') { + return {kind: 'theme', durationMs: 0, easing: progress => progress}; + } + const style = getComputedStyle(element); + if (style.transitionProperty === 'none') { + return {kind: 'theme', durationMs: 0, easing: progress => progress}; + } + const properties = splitList(style.transitionProperty); + const index = properties.findIndex( + property => property === 'transform' || property === 'all', + ); + if (index < 0) { + return {kind: 'theme', durationMs: 0, easing: progress => progress}; + } + const pick = (list: string) => { + const items = splitList(list); + return items[index % items.length] ?? ''; + }; + const duration = parseTime(pick(style.transitionDuration)); + const delay = parseTime(pick(style.transitionDelay)) ?? 0; + if (duration == null) { + return {kind: 'theme', durationMs: 0, easing: progress => progress}; + } + const easing = + parseEasing(pick(style.transitionTimingFunction)) ?? + ((progress: number) => progress); + return {kind: 'theme', durationMs: Math.max(0, duration + delay), easing}; +} diff --git a/packages/core/src/BottomSheet/sheetRelease.test.ts b/packages/core/src/BottomSheet/sheetRelease.test.ts new file mode 100644 index 0000000000000..b2d58e55925ed --- /dev/null +++ b/packages/core/src/BottomSheet/sheetRelease.test.ts @@ -0,0 +1,763 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file sheetRelease.test.ts + * @input vitest, resolveSheetRelease and the UIKit constants table + * @output Holds the release rule to UIKit's own releases: 164 drags of a + * UISheetPresentationController `[.medium(), .large()]` sheet read from + * UIKit (offset and velocity of the sheet interaction's last drag update + * before the lift, the detent it settled on, and for the 79 whose spring + * was read the damping of the spring it attached); plus the rule's own + * edges + * @position Testing; pure, no DOM + */ + +import {describe, expect, it} from 'vitest'; +import {sheet, springs} from '../motion/uikit'; +import type {ReleaseRule} from '../motion/interactive'; +import {solveSpring, toSpring} from '../motion/spring'; +import { + limitOvershoot, + resolveDefaultRelease, + resolveSheetRelease, + sheetReleaseRule, +} from './sheetRelease'; + +/** + * Measured on the iOS 26.2 simulator (iPhone 17 Pro, 402 × 874 pt), in + * UIKit's own interaction offsets: the sheet's top at the large detent is at + * 62 pt, at the medium detent 404.32 pt, and gone at 874 pt. + * [case, offset at the lift (pt), velocity (pt/s, + = down), outcome, ζ of + * the release spring (null: a translate release, whose spring was not read)] + */ +const UIKIT_RELEASES: ReadonlyArray< + [string, number, number, 'large' | 'medium' | 'dismissed', number | null] +> = [ + ['off-300.v0', 120.99, -12.5, 'large', 1], + ['off-300.v500', 122.65, -481.4, 'large', 1], + ['off-300.v1500', 116.65, -1495.6, 'large', 0.8], + ['off-300.v3000', 129.32, -2903.5, 'large', 0.8], + ['off-230.v0', 184.32, 0, 'large', 1], + ['off-230.v500', 188.65, -472, 'large', 1], + ['off-230.v1500', 199.65, -1590, 'large', 0.8], + ['off-230.v3000', 225.65, -4037.7, 'large', 0.8], + ['off-170.v0', 244.32, 0, 'medium', 1], + ['off-170.v500', 250.99, -494.8, 'large', 1], + ['off-170.v1500', 282.99, -2323, 'large', 0.8], + ['off-170.v3000', 258.65, -3292.1, 'large', 0.8], + ['off-120.v0', 294.32, 0, 'medium', 1], + ['off-120.v500', 300.65, -466, 'medium', 1], + ['off-120.v1500', 296.32, -2209.6, 'large', 0.8], + ['off-120.v3000', 356.32, -686.1, 'medium', 1], + ['off-60.v0', 354.32, 0, 'medium', 1], + ['off-60.v500', 365.65, -493.5, 'medium', 1], + ['off-60.v1500', 368.32, -574.9, 'medium', 1], + ['off60.v0', 454.32, 0, 'medium', null], + ['off60.v500', 446.99, 481.5, 'medium', null], + ['off60.v1500', 428.32, 450.6, 'medium', null], + ['off120.v0', 514.32, 0, 'medium', null], + ['off120.v500', 503.65, 453.3, 'medium', null], + ['off120.v1500', 512.32, 1969.9, 'dismissed', null], + ['off120.v3000', 476.32, 1139.7, 'dismissed', null], + ['off200.v0', 594.32, 0, 'medium', null], + ['off200.v500', 583.65, 494.5, 'medium', null], + ['off200.v1500', 579.32, 1400, 'dismissed', null], + ['off200.v3000', 579.32, 3094.6, 'dismissed', null], + ['off300.v0', 694.32, 0, 'dismissed', null], + ['off300.v500', 687.65, 478.4, 'dismissed', null], + ['off300.v1500', 666.65, 1489.2, 'dismissed', null], + ['off300.v3000', 679.32, 3008.2, 'dismissed', null], + ['up.end356.v900', 351.65, -926.5, 'medium', 1], + ['up.end356.v1000', 352.65, -996.7, 'medium', 1], + ['down.end500.v1000', 501.99, 991, 'medium', null], + ['down.end500.v1200', 500.65, 1212.4, 'dismissed', null], + ['up.long.v400', 277.65, -381.2, 'medium', 1], + ['down.end600.v200', 590.32, 210.5, 'medium', null], + ['zeta.towardLarge.v720', 160.32, -726.6, 'large', 1], + ['zeta.towardLarge.v760', 160.65, -736.9, 'large', 1], + ['zeta.towardLarge.v800', 167.65, -746.9, 'large', 1], + ['zeta.towardLarge.v840', 161.32, -837.8, 'large', 1], + ['zeta.awayFromTarget.v760', 224.32, 801.4, 'medium', 1], + ['zeta.awayFromTarget.v840', 224.32, 1147.3, 'medium', 0.8], + ['zeta.awayFromTarget.v1000', 208.32, 76, 'large', 1], +]; + +/** + * The second capture, placed to separate the candidate laws: releases from + * 90 pt above to 180 pt below the medium detent at 600 to 1400 pt/s in + * either direction, the finger at a constant speed for its last 0.15 s and + * lifting while moving (iOS 26.2 simulator, Oct 24 2026). Same columns; ζ + * is null where the release dismissed or its spring was not read. + */ +const UIKIT_FLICK_RELEASES: ReadonlyArray< + readonly [ + string, + number, + number, + 'large' | 'medium' | 'dismissed', + 1 | 0.8 | null, + ] +> = [ + ['d-90.v-1400', 315.99, -1403.9, 'large', 0.8], + ['d-90.v-1200', 310.99, -1195.1, 'large', 0.8], + ['d-90.v-1000', 312.65, -1090.0, 'large', 0.8], + ['d-90.v-800', 310.99, -746.9, 'medium', 1], + ['d-90.v-600', 339.32, -607.5, 'medium', 1], + ['d-90.v600', 319.32, 567.3, 'medium', 1], + ['d-90.v800', 320.99, 776.4, 'medium', 1], + ['d-90.v1000', 324.32, 979.0, 'medium', 1], + ['d-90.v1200', 324.32, 1122.4, 'medium', 0.8], + ['d-90.v1400', 315.99, 1432.4, 'medium', 0.8], + ['d-45.v-1400', 345.99, -1399.9, 'large', 0.8], + ['d-45.v-1200', 369.32, -1288.8, 'large', 0.8], + ['d-45.v-1000', 346.32, -1028.3, 'large', 0.8], + ['d-45.v-800', 349.32, -868.6, 'medium', 1], + ['d-45.v-600', 349.32, -628.2, 'medium', 1], + ['d-45.v600', 372.32, 565.6, 'medium', 1], + ['d-45.v800', 359.32, 814.1, 'medium', 1], + ['d-45.v1000', 352.65, 1290.9, 'medium', 0.8], + ['d-45.v1200', 349.32, 1279.1, 'medium', 0.8], + ['d-45.v1400', 367.65, 2318.6, 'medium', 0.8], + ['d0.v-1400', 417.65, -2014.5, 'large', 0.8], + ['d0.v-1200', 404.32, -1222.1, 'large', 0.8], + ['d0.v-1000', 402.65, -920.3, 'medium', 1], + ['d0.v-800', 400.99, -927.9, 'medium', 1], + ['d0.v-600', 399.32, -594.7, 'medium', 1], + ['d0.v600', 404.32, 533.8, 'medium', null], + ['d0.v800', 414.32, 819.5, 'medium', null], + ['d0.v1000', 397.65, 1088.7, 'medium', 0.8], + ['d0.v1200', 404.32, 1164.9, 'dismissed', null], + ['d0.v1400', 414.32, 1873.1, 'dismissed', null], + ['d30.v-1400', 424.32, -928.4, 'medium', null], + ['d30.v-1200', 424.32, -1402.5, 'medium', null], + ['d30.v-1000', 424.32, -1014.9, 'medium', null], + ['d30.v-800', 427.65, -814.4, 'medium', null], + ['d30.v-600', 429.32, -582.8, 'medium', null], + ['d30.v600', 450.99, 605.9, 'medium', null], + ['d30.v800', 437.65, 803.7, 'medium', null], + ['d30.v1000', 444.32, 977.4, 'medium', null], + ['d30.v1200', 444.32, 1184.7, 'dismissed', null], + ['d30.v1400', 420.99, 1397.1, 'dismissed', null], + ['d60.v-1400', 454.32, -1723.7, 'medium', null], + ['d60.v-1200', 464.32, -1470.6, 'medium', null], + ['d60.v-1000', 450.99, -947.3, 'medium', null], + ['d60.v-800', 454.32, -806.7, 'medium', null], + ['d60.v-600', 450.99, -596.6, 'medium', null], + ['d60.v600', 472.65, 570.9, 'medium', null], + ['d60.v800', 484.32, 806.8, 'medium', null], + ['d60.v1000', 474.32, 967.4, 'medium', null], + ['d60.v1200', 474.32, 1179.4, 'dismissed', null], + ['d60.v1400', 450.99, 1306.2, 'dismissed', null], + ['d90.v-1400', 489.32, -1430.1, 'medium', null], + ['d90.v-1200', 484.32, -1213.0, 'medium', null], + ['d90.v-1000', 484.32, -1071.7, 'medium', null], + ['d90.v-800', 484.32, -924.6, 'medium', null], + ['d90.v-600', 489.32, -610.9, 'medium', null], + ['d90.v600', 479.32, 615.4, 'medium', null], + ['d90.v800', 497.65, 780.5, 'medium', null], + ['d90.v1000', 507.65, 975.6, 'medium', null], + ['d90.v1200', 504.32, 1177.0, 'dismissed', null], + ['d90.v1400', 490.99, 1443.1, 'dismissed', null], + ['d120.v-1400', 525.99, -1430.3, 'medium', null], + ['d120.v-1200', 507.65, -1164.3, 'medium', null], + ['d120.v-1000', 519.32, -1026.1, 'medium', null], + ['d120.v-800', 510.99, -807.4, 'medium', null], + ['d120.v-600', 519.32, -607.2, 'medium', null], + ['d120.v600', 502.65, 605.0, 'medium', null], + ['d120.v800', 484.32, 792.8, 'medium', null], + ['d120.v1000', 529.32, 965.5, 'medium', null], + ['d120.v1200', 534.32, 1177.8, 'dismissed', null], + ['d120.v1400', 522.65, 1408.6, 'dismissed', null], + ['d150.v-1200', 554.32, -1240.1, 'medium', null], + ['d150.v-1000', 552.65, -981.5, 'medium', null], + ['d150.v-800', 540.99, -794.3, 'medium', null], + ['d150.v-600', 537.65, -583.2, 'medium', null], + ['d150.v600', 537.65, 600.3, 'medium', null], + ['d150.v800', 537.65, 810.5, 'medium', null], + ['d150.v1000', 535.99, 1067.8, 'dismissed', null], + ['d150.v1200', 544.32, 1226.5, 'dismissed', null], + ['d150.v1400', 567.65, 1419.1, 'dismissed', null], + ['d180.v-1000', 582.65, -1063.9, 'medium', null], + ['d180.v-800', 570.99, -786.2, 'medium', null], + ['d180.v-600', 574.32, -645.2, 'medium', null], + ['d180.v600', 565.99, 601.9, 'medium', null], + ['d180.v800', 554.32, 800.5, 'medium', null], + ['d180.v1000', 554.32, 990.4, 'dismissed', null], + ['d180.v1200', 524.32, 1183.9, 'dismissed', null], + ['d180.v1400', 600.99, 1424.8, 'dismissed', null], +]; + +/** + * The third capture, back-outs (iOS 26.2 simulator, Oct 25 2026): from the + * medium detent, a drag past the midpoint toward large (to about 174 pt), + * or 90 % of the way to gone, then 40 pt back toward medium at a constant + * speed, lifting while moving. Below 250 pt/s none returns to medium: it + * settles where it is nearest (`sheet.release.minProjectionSpeed`). + */ +const UIKIT_BACKOUT_RELEASES: ReadonlyArray< + readonly [string, number, number, 'large' | 'medium' | 'dismissed', 1 | null] +> = [ + ['back.large.v100.r1', 229.32, 140.0, 'large', 1], + ['back.large.v250.r1', 224.32, 161.4, 'large', 1], + ['back.gone.v100.r1', 778.32, -96.3, 'dismissed', null], + ['back.gone.v250.r1', 781.65, -239.1, 'dismissed', null], + ['back.large.v100.r2', 223.65, 60.2, 'large', 1], + ['back.large.v250.r2', 224.32, 200.9, 'large', 1], + ['back.gone.v100.r2', 778.32, -95.9, 'dismissed', null], + ['back.gone.v250.r2', 779.32, -263.8, 'dismissed', null], + ['back.large.v100.r3', 224.32, 91.9, 'large', 1], + ['back.large.v250.r3', 222.32, 242.4, 'large', 1], + ['back.gone.v100.r3', 777.32, -105.4, 'dismissed', null], + ['back.gone.v250.r3', 777.32, -246.1, 'dismissed', null], + ['back.large224.v300', 224.32, 300.6, 'medium', 1], + ['back.large224.v400', 220.99, 403.9, 'medium', 1], + ['back.large224.v500', 224.32, 451.7, 'medium', 1], + ['back.large224.v650', 224.32, 634.4, 'medium', 1], + ['back.large224.v800', 217.65, 970.0, 'medium', 1], + ['back.large204.v300', 204.32, 327.3, 'medium', 1], + ['back.large204.v500', 196.32, 566.5, 'medium', 1], + ['back.large204.v800', 204.32, 963.1, 'medium', 1], + ['back.large244.v300', 239.32, 296.4, 'medium', 1], + ['back.large244.v500', 244.32, 501.7, 'medium', 1], + ['back.large224.v250.r1', 224.32, 243.6, 'large', 1], + ['back.large224.v262.r1', 224.32, 254.4, 'medium', 1], + ['back.large224.v275.r1', 224.32, 269.8, 'medium', 1], + ['back.large224.v288.r1', 224.32, 282.8, 'medium', 1], + ['back.large224.v250.r2', 222.32, 250.6, 'medium', 1], + ['back.large224.v262.r2', 221.99, 264.7, 'medium', 1], + ['back.large224.v275.r2', 224.32, 272.4, 'medium', 1], + ['back.large224.v288.r2', 222.32, 287.2, 'medium', 1], +]; + +const LARGE = 62; +const MEDIUM = 404.32; +const GONE = 874; + +describe('resolveSheetRelease against UIKit', () => { + // Every measured drag began at the medium detent. A release moving back + // toward it cancels there (spec:AST-094 FR37), where UIKit's own sheet + // settled 14 of those back-outs on the stop nearest them and flicked one + // (`d0.v-1400`) to large (listed below); on every other release the rule + // lands where UIKit landed. + const movingBack = (offset: number, velocity: number) => + velocity !== 0 && Math.sign(velocity) === Math.sign(MEDIUM - offset); + const releases = [ + ...UIKIT_RELEASES, + ...UIKIT_FLICK_RELEASES, + ...UIKIT_BACKOUT_RELEASES, + ]; + for (const [name, offset, velocity, outcome, zeta] of releases) { + const expected = movingBack(offset, velocity) ? 'medium' : outcome; + const note = expected === outcome ? '' : ` (UIKit: ${outcome})`; + it(`${name}: ${velocity} pt/s from ${offset} pt → ${expected}${note}`, () => { + const release = resolveSheetRelease({ + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + position: offset, + velocity, + start: MEDIUM, + }); + const landed = + release.kind === 'dismiss' + ? 'dismissed' + : release.index === 0 + ? 'large' + : 'medium'; + expect(landed).toBe(expected); + if (zeta != null && expected === outcome) { + expect(release.spring).toBe( + zeta === 1 ? springs.sheetDetent : springs.sheetReleaseFast, + ); + } + }); + } + + // The same releases with the cancel out of the way: each one starts where + // it is lifted, so FR37 never applies and every release lands where + // UIKit's sheet landed. With a start at the medium detent the cancel decides + // every release moving back toward it, and the slowest projected speed, + // the projection time and the tolerance on a stop would go untested on + // those. + describe('with no back-out to cancel (each release starting where it is lifted)', () => { + for (const [name, offset, velocity, outcome, zeta] of releases) { + it(`${name}: ${velocity} pt/s from ${offset} pt → ${outcome}`, () => { + const release = resolveSheetRelease({ + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + position: offset, + velocity, + start: offset, + }); + const landed = + release.kind === 'dismiss' + ? 'dismissed' + : release.index === 0 + ? 'large' + : 'medium'; + expect(landed).toBe(outcome); + if (zeta != null) { + expect(release.spring).toBe( + zeta === 1 ? springs.sheetDetent : springs.sheetReleaseFast, + ); + } + }); + } + }); + + it('departs from UIKit only on back-outs, which FR37 cancels', () => { + expect( + releases + .filter( + ([, offset, velocity, outcome]) => + movingBack(offset, velocity) && outcome !== 'medium', + ) + .map(([name]) => name), + ).toEqual([ + 'zeta.awayFromTarget.v1000', + 'd0.v-1400', + 'back.large.v100.r1', + 'back.large.v250.r1', + 'back.gone.v100.r1', + 'back.gone.v250.r1', + 'back.large.v100.r2', + 'back.large.v250.r2', + 'back.gone.v100.r2', + 'back.gone.v250.r2', + 'back.large.v100.r3', + 'back.large.v250.r3', + 'back.gone.v100.r3', + 'back.gone.v250.r3', + 'back.large224.v250.r1', + ]); + }); +}); + +describe('resolveSheetRelease', () => { + it('projects linearly: offset plus the velocity times the projection time', () => { + const release = resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 100, + velocity: 500, + start: 0, + }); + expect(release.projected).toBeCloseTo( + 100 + 500 * sheet.release.projectionSeconds, + 9, + ); + }); + + it('at rest, dismisses only past halfway from the shortest detent to gone', () => { + const at = (position: number) => + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position, + velocity: 0, + start: 300, + }).kind; + expect(at(469)).toBe('detent'); + expect(at(471)).toBe('dismiss'); + }); + + it('sends a flick at least to the next position beyond it', () => { + // On the 300 px detent, flicked down at the threshold: its projection + // (about 400 px) is nearer 300, but a flick goes on to the next position. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 300, + velocity: sheet.release.fastSpeed, + start: 0, + }), + ).toMatchObject({kind: 'dismiss', offset: 640}); + // Just under the threshold, the same release projects back to 300. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 300, + velocity: sheet.release.fastSpeed - 1, + start: 0, + }), + ).toMatchObject({kind: 'detent', offset: 300}); + // Within the tolerance a detent counts as passed; a flick up from just + // below 300 goes on to 0 instead of stopping at 300. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 300 + sheet.release.detentTolerance / 2, + velocity: -sheet.release.fastSpeed, + start: 400, + }), + ).toMatchObject({kind: 'detent', offset: 0}); + }); + + it('takes the projection when a flick carries farther than the next position', () => { + // Flicked down at 5000 px/s from 50 px: the next position is 300, but + // the projection (550 px) is nearest gone, so it dismisses. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 50, + velocity: 5000, + start: 0, + }), + ).toMatchObject({kind: 'dismiss', offset: 640}); + }); + + it('settles against the motion when the projection says so', () => { + // Just above the shortest detent, moving up slowly: back down to it. + const release = resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 250, + velocity: -200, + start: 300, + }); + expect(release).toMatchObject({kind: 'detent', index: 1, offset: 300}); + }); + + it('projects nothing below sheet.release.minProjectionSpeed', () => { + // From the tallest detent, past the midpoint toward the shorter one and + // let go moving on toward it just under the speed: it stays with the + // tallest, where it is nearest; just over it, the projection takes it on. + // (Moving back toward where the drag began, FR37 would cancel instead.) + const slow = sheet.release.minProjectionSpeed - 1; + const fast = sheet.release.minProjectionSpeed + 1; + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 140, + velocity: slow, + start: 0, + }), + ).toMatchObject({kind: 'detent', index: 0, projected: 140}); + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 140, + velocity: fast, + start: 0, + }), + ).toMatchObject({kind: 'detent', index: 1}); + }); + + it('cancels a release moving back toward its start, whatever the travel so far (spec:AST-094 FR37)', () => { + // Dragged from the 300 px detent to 600 px (gone at 640 px) and let go + // moving back up: back to 300, though the projection is nearest gone. + for (const velocity of [-100, -400, -1500]) { + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 600, + velocity, + start: 300, + }), + ).toMatchObject({kind: 'detent', index: 1, offset: 300}); + } + // Past the midpoint toward the tallest detent, moving back down: 300. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 120, + velocity: 80, + start: 300, + }), + ).toMatchObject({kind: 'detent', index: 1, offset: 300}); + // Moving on, away from the start, the projection decides as before. + expect( + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position: 600, + velocity: 100, + start: 300, + }), + ).toMatchObject({kind: 'dismiss'}); + }); + + it('cancels to a detent, never to gone: a drag begun where a finger caught the sheet near gone (spec:AST-094 FR37)', () => { + // Caught at 720 pt, past the midpoint from medium to gone, dragged up to + // 600 and let go moving back down: a cancel commits nothing, so the sheet + // goes to the detent nearest the start, medium, where the same lift from + // a drag begun on medium settles too. + for (const velocity of [60, 400, 1500]) { + expect( + resolveSheetRelease({ + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + position: 600, + velocity, + start: 720, + }), + ).toMatchObject({kind: 'detent', index: 1, offset: MEDIUM}); + } + expect( + resolveSheetRelease({ + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + position: 600, + velocity: 60, + start: MEDIUM, + }), + ).toMatchObject({kind: 'detent', index: 1, offset: MEDIUM}); + }); + + it('never dismisses a sheet a drag may not dismiss', () => { + const release = resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: null, + position: 600, + velocity: 3000, + start: 300, + }); + expect(release).toMatchObject({kind: 'detent', index: 1, offset: 300}); + }); + + it('picks the spring by speed alone, from sheet.release.fastSpeed', () => { + const spring = (velocity: number, position = 150) => + resolveSheetRelease({ + detents: [0, 300], + dismissedOffset: 640, + position, + velocity, + // Released where the drag began: the cancel never applies. + start: position, + }).spring; + const fast = sheet.release.fastSpeed; + expect(spring(fast - 1)).toBe(springs.sheetDetent); + expect(spring(-(fast - 1))).toBe(springs.sheetDetent); + expect(spring(fast)).toBe(springs.sheetReleaseFast); + expect(spring(-fast)).toBe(springs.sheetReleaseFast); + // Dismissals: the swipe spring when fast, the detent spring when slow. + expect(spring(fast, 600)).toBe(springs.sheetDismissSwipe); + expect(spring(0, 600)).toBe(springs.sheetDetent); + }); + + it('needs a detent', () => { + expect(() => + resolveSheetRelease({ + detents: [], + dismissedOffset: 640, + position: 0, + velocity: 0, + start: 0, + }), + ).toThrow(RangeError); + }); +}); + +describe("resolveDefaultRelease (under a mouse: today's rule)", () => { + const base = { + detents: [0, 300], + shortestDetentHeight: 340, + dismissible: true, + startOffset: 300, + }; + + it('dismisses a downward release projected past 40 % of the shortest detent', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + // 420 + 0.6 × 499 = 719 > 300 + 0.4 × 340 = 436. + expect( + resolveDefaultRelease({ + ...base, + position: 420, + velocity: 600, + travel: 120, + direction: 1, + }), + ).toMatchObject({kind: 'dismiss', offset: 640}); + // At rest it dismisses only past the line. + expect( + resolveDefaultRelease({ + ...base, + position: 430, + velocity: 0, + travel: 130, + direction: 1, + }), + ).toMatchObject({kind: 'detent', offset: 300}); + expect( + resolveDefaultRelease({ + ...base, + position: 440, + velocity: 0, + travel: 140, + direction: 1, + }), + ).toMatchObject({kind: 'dismiss'}); + }); + + it('holds the shortest detent instead when a drag may not dismiss', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + expect( + resolveDefaultRelease({ + ...base, + dismissible: false, + position: 420, + velocity: 600, + travel: 120, + direction: 1, + }), + ).toMatchObject({kind: 'detent', index: 1, offset: 300}); + }); + + it('projects nothing for a gesture under 48 px', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + // A fast 20 px nudge up from 300: no projection, back to 300. + expect( + resolveDefaultRelease({ + ...base, + position: 280, + velocity: -2000, + travel: 20, + direction: -1, + }), + ).toMatchObject({kind: 'detent', offset: 300}); + }); + + it('never settles past the detent a drag started from, against its direction', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + // Up 50 px net from 300, then thrown down at the lift: projected + // 250 + 0.6 × 499 = 549 would land on 500, but an upward drag stops at + // its start. + expect( + resolveDefaultRelease({ + ...base, + detents: [0, 300, 500], + position: 250, + velocity: 600, + travel: 50, + direction: -1, + }), + ).toMatchObject({kind: 'detent', offset: 300}); + }); +}); + +describe('limitOvershoot', () => { + /** How far the spring goes past `to`, px. */ + const overshootOf = (from: number, to: number, velocity: number) => { + const curve = solveSpring(toSpring(springs.sheetReleaseFast), { + from, + to, + velocity, + }); + let worst = 0; + for (let t = 0; t <= 1.5; t += 1 / 480) { + worst = Math.max(worst, to - curve.value(t)); + } + return worst; + }; + + it('keeps a hand-off whose overshoot fits', () => { + expect(limitOvershoot(springs.sheetReleaseFast, 5, 0, -1000, 48)).toBe( + -1000, + ); + }); + + it('caps a throw that would overshoot past the reserve', () => { + // Released 5 px below fully open at 3000 px/s: the 80 % damped spring + // would carry the sheet about 60 px above it, past the 48 px reserve. + expect(overshootOf(5, 0, -3000)).toBeGreaterThan(55); + const capped = limitOvershoot(springs.sheetReleaseFast, 5, 0, -3000, 48); + expect(capped).toBeGreaterThan(-3000); + expect(capped).toBeLessThan(-1500); + expect(overshootOf(5, 0, capped)).toBeLessThanOrEqual(48.5); + }); + + it('keeps a release let go above the tallest detent, still moving up, within the reserve', () => { + // Let go at the 48 px wall above fully open moving up at 1500 px/s: the + // spring would carry the sheet about 72 px above fully open first. + expect(overshootOf(-48, 0, -1500)).toBeGreaterThan(65); + const capped = limitOvershoot(springs.sheetReleaseFast, -48, 0, -1500, 48); + expect(overshootOf(-48, 0, capped)).toBeLessThanOrEqual(48.5); + }); + + it('keeps a downward release from above the tallest detent as it is', () => { + // Moving down from the wall it only falls back toward the detent. + expect(limitOvershoot(springs.sheetReleaseFast, -48, 0, 600, 48)).toBe(600); + }); +}); + +describe('sheetReleaseRule (the interactive-transition driver\u2019s rule)', () => { + const context = { + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + isUIKit: true, + shortestDetentHeight: GONE - MEDIUM, + startOffset: LARGE, + today: {direction: 0, travel: 0, velocity: 0}, + }; + + it('is UIKit\u2019s rule under a finger, from where the drag began, for all 134 measured releases', () => { + for (const [, offset, velocity] of [ + ...UIKIT_RELEASES, + ...UIKIT_FLICK_RELEASES, + ]) { + const release = resolveSheetRelease({ + detents: [LARGE, MEDIUM], + dismissedOffset: GONE, + position: offset, + velocity, + start: context.startOffset, + }); + expect( + sheetReleaseRule({position: offset, finger: offset, velocity}, context), + ).toEqual({ + target: release.offset, + motion: release.spring, + outcome: release.kind, + index: release.index, + exits: release.kind === 'dismiss', + }); + } + }); + + it('is today\u2019s rule under a mouse, and keeps a sheet that cannot be dismissed', () => { + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const today = {direction: 1, travel: 300, velocity: 900}; + const landing = sheetReleaseRule( + {position: 700, finger: 700, velocity: 1200}, + {...context, isUIKit: false, today}, + ); + const release = resolveDefaultRelease({ + detents: [LARGE, MEDIUM], + shortestDetentHeight: GONE - MEDIUM, + dismissible: true, + position: 700, + velocity: 900, + travel: 300, + direction: 1, + startOffset: LARGE, + }); + expect(landing.target).toBe(release.offset); + expect(landing.outcome).toBe(release.kind); + const kept = sheetReleaseRule( + {position: 700, finger: 700, velocity: 1200}, + {...context, isUIKit: false, today, dismissedOffset: null}, + ); + expect(kept.outcome).toBe('detent'); + expect(kept.exits).toBe(false); + }); + + it('is a release rule the driver takes', () => { + const rule: ReleaseRule< + 'detent' | 'dismiss', + Parameters[1] + > = sheetReleaseRule; + expect( + rule( + {position: 800, finger: 800, velocity: 1500}, + {...context, track: undefined}, + ).exits, + ).toBe(true); + }); +}); diff --git a/packages/core/src/BottomSheet/sheetRelease.ts b/packages/core/src/BottomSheet/sheetRelease.ts new file mode 100644 index 0000000000000..355495c482961 --- /dev/null +++ b/packages/core/src/BottomSheet/sheetRelease.ts @@ -0,0 +1,417 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file sheetRelease.ts + * @input The UIKit constants table (`sheet.release`, the sheet springs) and + * the motion foundation's snap-point projection + * @output Exports resolveSheetRelease, which says where a released sheet goes + * and on which spring under a finger; resolveDefaultRelease, today's rule + * everywhere else; and the SheetRelease type + * @position Internal to BottomSheet; pure (no DOM, no React). Called by + * useSheetGestures when a drag ends. + * + * Under a finger (a coarse primary pointer, `readTouchPresentation`), a + * released sheet goes where UISheetPresentationController sends it. The + * dismissed position (the sheet fully below the screen) counts as one of the + * positions. + * - Below `sheet.release.fastSpeed`, to the position nearest its release + * offset projected `sheet.release.projectionSeconds` ahead at the release + * velocity. Below `sheet.release.minProjectionSpeed` nothing is + * projected: it lands on whatever is nearest where it was let go; at + * rest the sheet dismisses once it is more than halfway from its + * shortest detent to gone. A sheet released just above a detent while + * moving up, but slowly, falls back to it. + * - From that speed on, a flick: at least to the next position beyond where + * it is, in its direction (on a position within + * `sheet.release.detentTolerance`, the one after it), or to the + * projection's nearest position if that is farther. + * - Either way, a release whose velocity points back toward where the + * drag began cancels, to the detent nearest that start, whatever the + * travel so far (spec:AST-094 FR37): a cancel commits nothing, so it never + * dismisses, even for a drag begun where a finger caught the sheet near + * gone. UIKit's own sheet does not: measured, a + * back-out let go below 250 pt/s settles where it is nearest (sheet.ts). + * + * The sheet gets there on UIKit's release spring, picked by the same speed + * (critically damped below it, 80 % damped from it on), and starting at the + * finger's velocity. + * + * Offsets are px down from the tallest detent (0 = fully open), velocities + * px/s with positive meaning downward, as useSheetGestures measures them. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/BottomSheet/sheetRelease.test.ts + * - /packages/core/src/BottomSheet/useSheetGestures.ts + */ + +import {selectSnapPoint} from '../motion/projection'; +import {solveSpring, toSpring, type SpringDescription} from '../motion/spring'; +import type {ReleaseInput, ReleaseOutcome} from '../motion/interactive'; +import {sheet} from '../motion/uikit/sheet'; +import { + SHEET_DETENT_SPRING, + SHEET_DISMISS_SWIPE_SPRING, + SHEET_RELEASE_FAST_SPRING, +} from '../motion/uikit/springs'; +import {resolveSettleOffset} from './snapOffsets'; + +export interface SheetRelease { + /** A detent to settle on, or a dismissal. */ + kind: 'detent' | 'dismiss'; + /** Index into the detents passed in; -1 for a dismissal. */ + index: number; + /** Where the sheet goes: the detent's offset, or the dismissed offset. */ + offset: number; + /** The projected rest offset the choice was made from. */ + projected: number; + /** The spring that takes it there, handed the release velocity. */ + spring: SpringDescription; +} + +/** The deceleration rate whose projection reaches `seconds` of the velocity. */ +function rateForProjection(seconds: number): number { + // WWDC18's projection coasts (v / 1000) · r / (1 − r): r / (1 − r) = 1000·τ. + const ms = seconds * 1000; + return ms / (1 + ms); +} + +const PROJECTION_RATE = rateForProjection(sheet.release.projectionSeconds); + +/** The index of the position nearest `offset` (the first on a tie). */ +function nearestIndex( + positions: ReadonlyArray, + offset: number, +): number { + let best = 0; + for (let i = 1; i < positions.length; i += 1) { + if (Math.abs(positions[i] - offset) < Math.abs(positions[best] - offset)) { + best = i; + } + } + return best; +} + +/** + * Where a sheet released at `position` moving at `velocity` settles, and on + * which spring. + * + * @example + * ``` + * // Detents at 0 (open) and 300 px; gone at 640 px. Released at 300 px + * // moving down at 1200 px/s: a flick, so past the 300 px detent it is on + * // to the next position down, although its projection (420 px) is nearer + * // 300. + * resolveSheetRelease({ + * detents: [0, 300], + * dismissedOffset: 640, + * position: 300, + * velocity: 1200, + * start: 0, + * }); // {kind: 'dismiss', offset: 640, spring: SHEET_DISMISS_SWIPE_SPRING, …} + * ``` + */ +export function resolveSheetRelease({ + detents, + dismissedOffset, + position, + velocity, + start, +}: { + /** Detent offsets, ascending (the tallest first). */ + detents: ReadonlyArray; + /** The offset at which the sheet is fully off screen; null when a drag may not dismiss it. */ + dismissedOffset: number | null; + position: number; + velocity: number; + /** + * Where the drag began (a detent, or where a finger caught the sheet): a + * release moving back toward it cancels (FR37). Every drag has one, so it + * is required: leaving it out cannot skip the cancel. + */ + start: number; +}): SheetRelease { + if (detents.length === 0) { + throw new RangeError('resolveSheetRelease needs at least one detent'); + } + const positions = + dismissedOffset == null ? [...detents] : [...detents, dismissedOffset]; + // Below the slowest projected speed the release is where it was let go. + const projectedVelocity = + Math.abs(velocity) < sheet.release.minProjectionSpeed ? 0 : velocity; + const choice = selectSnapPoint(positions, { + position, + velocity: projectedVelocity, + rate: PROJECTION_RATE, + }); + const isFast = Math.abs(velocity) >= sheet.release.fastSpeed; + let index = choice.index; + if (isFast) { + // A flick goes at least to the next position beyond where it is. + const tolerance = sheet.release.detentTolerance; + let next = -1; + if (velocity > 0) { + next = positions.findIndex(p => p > position + tolerance); + } else { + for (let i = positions.length - 1; i >= 0 && next === -1; i -= 1) { + if (positions[i] < position - tolerance) { + next = i; + } + } + } + if (next !== -1 && (velocity > 0 ? next > index : next < index)) { + index = next; + } + } + // spec:AST-094 FR37: a release whose velocity points back toward where + // the drag began cancels, whatever the travel so far, to the detent nearest + // that start. A cancel commits nothing, so it never dismisses: a drag begun + // where a finger caught the sheet on its way to gone returns to a detent. + if (velocity !== 0 && Math.sign(velocity) === Math.sign(start - position)) { + index = nearestIndex(detents, start); + } + if (dismissedOffset != null && index === detents.length) { + // GUESS (spec:AST-094 FR2): a slow dismissal plays `SHEET_DETENT_SPRING`. + // None of the measured releases that dismissed had its spring read; + // MEASUREMENTS.md's M-SH09 (drags released past the dismiss boundary, + // their release spring read) will confirm or replace it. + return { + kind: 'dismiss', + index: -1, + offset: dismissedOffset, + projected: choice.projected, + spring: isFast ? SHEET_DISMISS_SWIPE_SPRING : SHEET_DETENT_SPRING, + }; + } + return { + kind: 'detent', + index, + offset: positions[index], + projected: choice.projected, + spring: isFast ? SHEET_RELEASE_FAST_SPRING : SHEET_DETENT_SPRING, + }; +} + +// Today's rule, which a Material variant may replace. The release is judged +// where the sheet would come to rest: its position plus the distance a +// surface moving at the finger's speed travels while it decelerates at 0.998 +// per ms. A gesture shorter than the travel floor projects nothing. +const DEFAULT_DECELERATION_RATE = 0.998; +const DEFAULT_PROJECTION_MS = + DEFAULT_DECELERATION_RATE / (1 - DEFAULT_DECELERATION_RATE); +const DEFAULT_PROJECTION_MIN_TRAVEL = 48; // px traveled during the gesture +/** Past this fraction of the shortest detent's height below it, a release dismisses. */ +export const DEFAULT_DISMISS_OVERSHOOT_RATIO = 0.4; + +/** + * Where a sheet released under a mouse + * settles: today's rule. A downward release projected past the dismiss line + * (`DEFAULT_DISMISS_OVERSHOOT_RATIO` of the shortest detent's height below + * it) dismisses; any other settles on the detent nearest its projection in + * the drag's direction, never back past the detent it started from. The + * spring is unused there: these releases run on the theme's timing. + * + * @example + * ``` + * // Detents at 0 and 300 px, the shortest showing 340 px; a 120 px drag + * // down from 300 px released at 600 px/s: projected 719 px, past the + * // dismiss line at 436 px. + * resolveDefaultRelease({ + * detents: [0, 300], + * shortestDetentHeight: 340, + * dismissible: true, + * position: 420, + * velocity: 600, + * travel: 120, + * direction: 1, + * startOffset: 300, + * }); // {kind: 'dismiss', offset: 640, …} + * ``` + */ +export function resolveDefaultRelease({ + detents, + shortestDetentHeight, + dismissible, + position, + velocity, + travel, + direction, + startOffset, +}: { + /** Detent offsets, ascending (the tallest first). */ + detents: ReadonlyArray; + /** How much of the sheet the shortest detent shows, px. */ + shortestDetentHeight: number; + /** Whether a drag may dismiss the sheet. */ + dismissible: boolean; + position: number; + /** + * The release velocity, px/s: the velocity tracker's at the lift, the one + * estimate every pointer reads (`spec:AST-100` FR2, FR4); 0 after a still + * pointer. + */ + velocity: number; + /** How far the gesture traveled, px. */ + travel: number; + /** The drag's direction: 1 down, -1 up, 0 none. */ + direction: number; + /** The detent offset the drag started from. */ + startOffset: number; +}): SheetRelease { + if (detents.length === 0) { + throw new RangeError('resolveDefaultRelease needs at least one detent'); + } + const maxOffset = detents[detents.length - 1]; + const dismissLine = + maxOffset + shortestDetentHeight * DEFAULT_DISMISS_OVERSHOOT_RATIO; + const projected = + travel > DEFAULT_PROJECTION_MIN_TRAVEL + ? position + (velocity / 1000) * DEFAULT_PROJECTION_MS + : position; + if (direction > 0 && projected > dismissLine) { + return dismissible + ? { + kind: 'dismiss', + index: -1, + offset: maxOffset + shortestDetentHeight, + projected, + spring: SHEET_DETENT_SPRING, + } + : { + kind: 'detent', + index: detents.length - 1, + offset: maxOffset, + projected, + spring: SHEET_DETENT_SPRING, + }; + } + const offset = resolveSettleOffset( + Math.min(Math.max(projected, 0), maxOffset), + [...detents], + direction, + startOffset, + ); + return { + kind: 'detent', + index: detents.indexOf(offset), + offset, + projected, + spring: SHEET_DETENT_SPRING, + }; +} + +/** + * The release velocity, scaled down if needed so `spring`, released at + * `from` moving at `velocity`, rises above `to` by at most `limit` px + * (offsets are px down, so above is smaller), or no higher than it starts + * when it starts higher than that. A sheet released onto its tallest detent + * rises above it when thrown up from below, or when let go while still + * stretched above it and moving up; the sheet extends `limit` px below the + * screen, so past that the page would show under it. + */ +export function limitOvershoot( + spring: SpringDescription, + from: number, + to: number, + velocity: number, + limit: number, +): number { + // Measured above `to` wherever the release starts: from below, the + // overshoot; from above, how far it rises before it falls back. + const allowed = Math.max(limit, to - from); + const overshoot = (v: number) => { + const curve = solveSpring(toSpring(spring), {from, to, velocity: v}); + const end = Math.min(curve.settleTime(0.1), 3); + let worst = 0; + for (let t = 0; t <= end; t += 1 / 240) { + worst = Math.max(worst, to - curve.value(t)); + } + return worst; + }; + if (overshoot(velocity) <= allowed) { + return velocity; + } + let low = 0; + let high = 1; + for (let i = 0; i < 24; i += 1) { + const mid = (low + high) / 2; + if (overshoot(velocity * mid) <= allowed) { + low = mid; + } else { + high = mid; + } + } + return velocity * low; +} + +/** Where a sheet's release can land: a detent, or gone. */ +export type SheetReleaseOutcome = 'detent' | 'dismiss'; + +/** What the sheet's release rule knows besides where the gesture let go. */ +export interface SheetReleaseContext { + /** Detent offsets, ascending (the tallest first). */ + readonly detents: ReadonlyArray; + /** Where the sheet is gone; null when it can't be dismissed. */ + readonly dismissedOffset: number | null; + /** UIKit's rule (a finger); otherwise today's (a mouse). */ + readonly isUIKit: boolean; + /** How much of the sheet the shortest detent shows, px (today's rule). */ + readonly shortestDetentHeight: number; + /** + * The offset the drag started from: today's rule reads it, and under + * UIKit's rule a release moving back toward it cancels (FR37). + */ + readonly startOffset: number; + /** + * Today's inputs: the drag's direction and travel, and the velocity + * tracker's release velocity at the lift, the same estimate as + * `input.velocity` (`spec:AST-100` FR2: none reads another estimate). + */ + readonly today: {direction: number; travel: number; velocity: number}; +} + +/** A sheet release's landing: the driver's outcome, and which detent. */ +export interface SheetReleaseLanding extends ReleaseOutcome { + readonly motion: SpringDescription; + /** Index into the detents; -1 for a dismissal. */ + readonly index: number; +} + +/** + * The sheet's release as an interactive-transition rule + * (motion/interactive.ts): positions are the sheet's offsets, px down from + * fully open, and velocities px/s. Under a finger UIKit's rule + * (`resolveSheetRelease`), under a mouse today's (`resolveDefaultRelease`); + * a dismissal exits, so under reduced motion it fades out in place. + */ +export function sheetReleaseRule( + input: ReleaseInput, + context: SheetReleaseContext, +): SheetReleaseLanding { + const release = context.isUIKit + ? resolveSheetRelease({ + detents: context.detents, + dismissedOffset: context.dismissedOffset, + position: input.position, + velocity: input.velocity, + // A release moving back toward where the drag began cancels + // (spec:AST-094 FR37). + start: context.startOffset, + }) + : resolveDefaultRelease({ + detents: context.detents, + shortestDetentHeight: context.shortestDetentHeight, + dismissible: context.dismissedOffset != null, + position: input.position, + velocity: context.today.velocity, + travel: context.today.travel, + direction: context.today.direction, + startOffset: context.startOffset, + }); + return { + target: release.offset, + motion: release.spring, + outcome: release.kind, + index: release.index, + exits: release.kind === 'dismiss', + }; +} diff --git a/packages/core/src/BottomSheet/sheetSelectionLock.test.ts b/packages/core/src/BottomSheet/sheetSelectionLock.test.ts new file mode 100644 index 0000000000000..d8206ed84b5de --- /dev/null +++ b/packages/core/src/BottomSheet/sheetSelectionLock.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {lockSheetSelection} from './sheetSelectionLock'; + +/** A sheet with a row of text in it. */ +function sheetWithRow() { + const sheet = document.createElement('div'); + const row = document.createElement('span'); + row.textContent = 'Place 3'; + sheet.appendChild(row); + document.body.appendChild(sheet); + return {sheet, row}; +} + +/** What a long press on `node` does: Chromium fires `selectstart` first. */ +function longPressSelects(node: Node): boolean { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + node.dispatchEvent(event); + return !event.defaultPrevented; +} + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); + +describe('lockSheetSelection', () => { + it('elsewhere, cancels the selection a long press would start and changes no style', () => { + vi.stubGlobal('CSS', {supports: () => false}); + const {sheet, row} = sheetWithRow(); + const release = lockSheetSelection([sheet], row); + expect(longPressSelects(row)).toBe(false); + expect(sheet.getAttribute('style')).toBeNull(); + expect(row.getAttribute('style')).toBeNull(); + release(); + expect(longPressSelects(row)).toBe(true); + // Idempotent: a second release changes nothing. + release(); + expect(longPressSelects(row)).toBe(true); + }); + + it('elsewhere, leaves a text field its selection, as the gesture system does: a held finger places its caret', () => { + vi.stubGlobal('CSS', {supports: () => false}); + const {sheet, row} = sheetWithRow(); + const field = document.createElement('input'); + const notes = document.createElement('textarea'); + sheet.append(field, notes); + const release = lockSheetSelection([sheet], field); + expect(longPressSelects(field)).toBe(true); + expect(longPressSelects(notes)).toBe(true); + // The row's own text is still refused, from its text node too. + expect(longPressSelects(row)).toBe(false); + expect(longPressSelects(row.firstChild as Text)).toBe(false); + release(); + }); + + it('on iOS WebKit, turns user-select and the callout off on the whole sheet until the release', () => { + vi.stubGlobal('CSS', { + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + }); + const {sheet, row} = sheetWithRow(); + sheet.style.setProperty('user-select', 'text'); + const release = lockSheetSelection([sheet], row); + expect(sheet.style.getPropertyValue('user-select')).toBe('none'); + // On the whole sheet, not the row: on the element under the finger alone, + // iOS selected a word beside it. + expect(row.getAttribute('style')).toBeNull(); + release(); + expect(sheet.style.getPropertyValue('user-select')).toBe('text'); + }); +}); diff --git a/packages/core/src/BottomSheet/sheetSelectionLock.ts b/packages/core/src/BottomSheet/sheetSelectionLock.ts new file mode 100644 index 0000000000000..cb2e2ceff5a25 --- /dev/null +++ b/packages/core/src/BottomSheet/sheetSelectionLock.ts @@ -0,0 +1,100 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file sheetSelectionLock.ts + * @input The elements a finger holds (the sheet) and the touch's target + * @output Exports lockSheetSelection: no text selection starts under a + * finger holding the sheet until the returned release runs (idempotent) + * @position BottomSheet's selection lock under a finger (useSheetGestures), + * in the shape of the gesture system's lockSelection + * (gestures/selectionLock.ts), which takes the same split + * + * The answer is the same on every engine: a finger held still on a caught + * sheet starts no selection, no loupe and no callout. How it is refused + * differs, because a style change repaints everything it covers: + * - Where iOS WebKit runs (IOS_SUPPORTS_QUERY), only `user-select` refuses a + * long-press selection: WebKit fires no `selectstart` for one. It covers + * the whole sheet: on the element under the finger alone, the long press + * selected a word beside it (iOS 26.2 simulator, 4 of 4 takes). + * - Elsewhere the long press fires `selectstart`, and cancelling it refuses + * the selection with no style change, so nothing repaints (Chromium on + * Android, measured on the emulator: the toggle re-rastered the sheet at + * the touch-down and at the lift). + * + * SYNC: When modified, check gestures/selectionLock.ts. + */ + +import {lockSelection} from '../gestures/selectionLock'; +import {IOS_SUPPORTS_QUERY} from '../utils/touchPresentation'; + +/** Inputs whose text a held finger edits (gestures/selectionLock.ts). */ +const TEXT_ENTRY_TYPES = new Set([ + 'text', + 'search', + 'email', + 'url', + 'tel', + 'password', + 'number', +]); + +/** + * A text field keeps its selection under a held finger, as in the gesture + * system's lock: the finger places its caret there. + */ +function isTextEntry(node: EventTarget | null): boolean { + const element = + node instanceof Element + ? node + : node instanceof Node + ? node.parentElement + : null; + if (element == null) { + return false; + } + if (element.localName === 'textarea') { + return true; + } + if (element.localName === 'input') { + return TEXT_ENTRY_TYPES.has((element as HTMLInputElement).type); + } + return (element as HTMLElement).isContentEditable; +} + +/** Whether the engine is iOS WebKit (the only one with the touch callout). */ +function isIosWebKit(): boolean { + // Not every environment has `CSS.supports` (jsdom has `CSS.escape` only). + return ( + typeof CSS !== 'undefined' && + typeof CSS.supports === 'function' && + CSS.supports(IOS_SUPPORTS_QUERY) + ); +} + +export function lockSheetSelection( + elements: Iterable, + target: EventTarget | null, +): () => void { + const held = [...elements]; + if (isIosWebKit()) { + return lockSelection(held, target); + } + const refuse = (event: Event) => { + if (!isTextEntry(event.target)) { + event.preventDefault(); + } + }; + for (const element of held) { + element.addEventListener('selectstart', refuse); + } + let isReleased = false; + return () => { + if (isReleased) { + return; + } + isReleased = true; + for (const element of held) { + element.removeEventListener('selectstart', refuse); + } + }; +} diff --git a/packages/core/src/BottomSheet/sheetSelfHold.ts b/packages/core/src/BottomSheet/sheetSelfHold.ts new file mode 100644 index 0000000000000..4584e87c04eee --- /dev/null +++ b/packages/core/src/BottomSheet/sheetSelfHold.ts @@ -0,0 +1,220 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file sheetSelfHold.ts + * @input React (useEffect) and the DOM's pointer, touch, mouse and click + * events + * @output Exports useSheetSelfHold, the entrance hold of a non-modal sheet + * @position Internal to BottomSheet. Under a finger a presentation takes no + * input while it enters (`spec:AST-094` FR35); around a non-modal sheet only + * a tap or a drag on the sheet is held, and the page stays live + * (`component:BottomSheet` FR11). A modal sheet holds the viewport through + * the layer runtime's input hold instead (Layer/inputHold). + */ + +import {useEffect, type RefObject} from 'react'; + +interface SelfHold { + holders: number; + pointers: Set; + touches: Set; + // Clicks still to come from held presses that lifted, each with its expiry. + clicks: number[]; + detach: () => void; +} + +const holds = new WeakMap(); + +// A click follows its lift within a frame or two; one that has not come by +// then never will. +const CLICK_WINDOW_MS = 1000; + +function attach(element: Element): SelfHold { + const swallow = (event: Event) => { + if (event.cancelable) { + event.preventDefault(); + } + event.stopPropagation(); + }; + const maybeDetach = () => { + const hold = holds.get(element); + if ( + hold != null && + hold.holders === 0 && + hold.pointers.size === 0 && + hold.touches.size === 0 && + hold.clicks.length === 0 + ) { + hold.detach(); + holds.delete(element); + } + }; + const expireClicks = (hold: SelfHold) => { + const now = performance.now(); + hold.clicks = hold.clicks.filter(expiry => expiry > now); + }; + const lifted = (hold: SelfHold) => { + hold.clicks.push(performance.now() + CLICK_WINDOW_MS); + setTimeout(() => { + expireClicks(hold); + maybeDetach(); + }, CLICK_WINDOW_MS + 50); + }; + const onPointerDown = (event: Event) => { + const hold = holds.get(element); + if (hold == null || hold.holders === 0) { + return; + } + hold.pointers.add((event as PointerEvent).pointerId); + // No focus move, no compatibility mouse events, no activation. + swallow(event); + }; + const onPointer = (event: Event) => { + const hold = holds.get(element); + const id = (event as PointerEvent).pointerId; + if (hold == null || !hold.pointers.has(id)) { + return; + } + event.stopPropagation(); + if (event.type === 'pointerup' || event.type === 'pointercancel') { + hold.pointers.delete(id); + if (event.type === 'pointerup') { + lifted(hold); + } + maybeDetach(); + } + }; + const onTouchStart = (event: Event) => { + const hold = holds.get(element); + if (hold == null || hold.holders === 0) { + return; + } + for (const touch of Array.from((event as TouchEvent).changedTouches)) { + hold.touches.add(touch.identifier); + } + // Not cancelled: a pinch still zooms the page (spec:AST-094 FR10). + event.stopPropagation(); + }; + const onTouchMove = (event: Event) => { + const hold = holds.get(element); + const touchEvent = event as TouchEvent; + if ( + hold == null || + !Array.from(touchEvent.changedTouches).some(touch => + hold.touches.has(touch.identifier), + ) + ) { + return; + } + event.stopPropagation(); + // One finger: no scroll. Two: the page's pinch. + if (touchEvent.touches.length === 1 && event.cancelable) { + event.preventDefault(); + } + }; + const onTouchEnd = (event: Event) => { + const hold = holds.get(element); + if (hold == null) { + return; + } + let wasHeld = false; + for (const touch of Array.from((event as TouchEvent).changedTouches)) { + if (hold.touches.delete(touch.identifier)) { + wasHeld = true; + } + } + if (wasHeld) { + event.stopPropagation(); + maybeDetach(); + } + }; + const onMouse = (event: Event) => { + const hold = holds.get(element); + if (hold == null) { + return; + } + expireClicks(hold); + if (hold.holders > 0 || hold.clicks.length > 0) { + swallow(event); + } + }; + const onClick = (event: Event) => { + const hold = holds.get(element); + if (hold == null) { + return; + } + expireClicks(hold); + if (hold.holders > 0 || hold.clicks.length > 0) { + hold.clicks.shift(); + swallow(event); + maybeDetach(); + } + }; + const listeners: [string, (event: Event) => void][] = [ + ['pointerdown', onPointerDown], + ['pointermove', onPointer], + ['pointerup', onPointer], + ['pointercancel', onPointer], + ['touchstart', onTouchStart], + ['touchmove', onTouchMove], + ['touchend', onTouchEnd], + ['touchcancel', onTouchEnd], + ['mousedown', onMouse], + ['mouseup', onMouse], + ['click', onClick], + ]; + for (const [type, listener] of listeners) { + element.addEventListener(type, listener, {capture: true, passive: false}); + } + return { + holders: 0, + pointers: new Set(), + touches: new Set(), + clicks: [], + detach: () => { + for (const [type, listener] of listeners) { + element.removeEventListener(type, listener, {capture: true}); + } + }, + }; +} + +/** + * While `isHeld`, a press, touch or click that begins on `ref`'s element is + * held for its whole life, as UIKit drops a touch that begins during a + * presentation: it activates nothing, moves no focus, starts no drag, and + * its one-finger moves scroll nothing. Input anywhere else is untouched, and + * keys and assistive technology stay live. + */ +export function useSheetSelfHold( + ref: RefObject, + isHeld: boolean, +): void { + useEffect(() => { + const element = ref.current; + if (!isHeld || element == null) { + return; + } + let hold = holds.get(element); + if (hold == null) { + hold = attach(element); + holds.set(element, hold); + } + hold.holders += 1; + const current = hold; + return () => { + current.holders -= 1; + if ( + current.holders === 0 && + current.pointers.size === 0 && + current.touches.size === 0 && + current.clicks.length === 0 + ) { + current.detach(); + holds.delete(element); + } + }; + }, [ref, isHeld]); +} diff --git a/packages/core/src/BottomSheet/sheetTouch.ts b/packages/core/src/BottomSheet/sheetTouch.ts new file mode 100644 index 0000000000000..cb1b3505b2e8f --- /dev/null +++ b/packages/core/src/BottomSheet/sheetTouch.ts @@ -0,0 +1,27 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file sheetTouch.ts + * @input utils/touchPresentation's readTouchPresentation and the theme's + * default compact definition + * @output readTouchPresentation(): the touch presentation now, for a decision + * a BottomSheet takes at one moment (a release, a present, the dim) + * @position Internal to BottomSheet + * + * The sheet's touch decisions key on the primary pointer alone (a finger: + * `isTouch`); the compact half of the answer is read against the default + * definition, which none of them consults. + */ + +import {DEFAULT_COMPACT_DEFINITION} from '../theme/themeAdaptations'; +import { + readTouchPresentation as readPresentation, + type TouchPresentation, +} from '../utils/touchPresentation'; + +export function readTouchPresentation(): TouchPresentation { + return readPresentation( + typeof window === 'undefined' ? null : window, + DEFAULT_COMPACT_DEFINITION, + ); +} diff --git a/packages/core/src/BottomSheet/snapOffsets.ts b/packages/core/src/BottomSheet/snapOffsets.ts index 7e6d1a776adf7..38550acc0c9b3 100644 --- a/packages/core/src/BottomSheet/snapOffsets.ts +++ b/packages/core/src/BottomSheet/snapOffsets.ts @@ -188,8 +188,9 @@ export function peekOffsetFor( * The scrim is full down to the shortest stop that is still a working surface, * and only thins past it — onto a peek, where it holds at * `MIN_PEEK_SCRIM_OPACITY` (a glance state, and the sheet is still modal), or - * out through the dismiss overshoot toward `dismissOffset`, where it clears - * completely because the sheet is leaving. + * out toward `dismissOffset`, linearly, clearing completely there. Pass the + * dismissed position (the sheet's top at the bottom of the screen): UIKit's + * dim is linear in the sheet's position from its shortest detent to there. * * So a sheet whose stops are all working heights keeps a full scrim at every * one of them; only the sliver of a peek, or a sheet on its way out, dims less. diff --git a/packages/core/src/BottomSheet/useSheetGestures.test.ts b/packages/core/src/BottomSheet/useSheetGestures.test.ts index a500f205d021b..41c108db05c65 100644 --- a/packages/core/src/BottomSheet/useSheetGestures.test.ts +++ b/packages/core/src/BottomSheet/useSheetGestures.test.ts @@ -9,8 +9,64 @@ * SYNC: When useSheetGestures.ts changes, update tests to match new behavior */ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {dirname, join} from 'node:path'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + afterAll, +} from 'vitest'; import {act, renderHook} from '@testing-library/react'; +import {useState} from 'react'; +import {END_HOLD_MS} from '../motion/animator'; +/** The properties a played animation changes over its keyframes. */ +function changedProperties(animation: {keyframes: Keyframe[]}): string[] { + const first = (animation.keyframes[0] ?? {}) as Record; + const changed = new Set(); + for (const frame of animation.keyframes) { + for (const [key, value] of Object.entries(frame)) { + if (key !== 'offset' && key !== 'easing' && key !== 'composite') { + if (String(value) !== String(first[key])) { + changed.add(key); + } + } + } + } + return [...changed]; +} + +/** When a played animation stops changing, in ms after its start. */ +function settledMs(animation: { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; +}): number { + const frames = animation.keyframes as Record[]; + const last = frames[frames.length - 1]; + const same = (a: Record, b: Record) => + Object.keys({...a, ...b}).every( + key => + key === 'offset' || + key === 'easing' || + key === 'composite' || + String(a[key]) === String(b[key]), + ); + let i = frames.length - 1; + while (i > 0 && same(frames[i - 1], last)) { + i -= 1; + } + const offset = + typeof frames[i].offset === 'number' + ? (frames[i].offset as number) + : i / Math.max(1, frames.length - 1); + return offset * (animation.options.duration as number); +} + +import {solveSpring, toSpring} from '../motion/spring'; +import {sheet, springs} from '../motion/uikit'; import { useSheetGestures, type UseSheetGesturesOptions, @@ -36,6 +92,10 @@ function pointerEvent( clientY, timeStamp, currentTarget: target, + // A finger where a test stubs a coarse primary pointer, a mouse + // otherwise: the sheet takes UIKit's presentation only for a finger or + // a pen there (spec:AST-094 FR13). + pointerType: matchMedia('(pointer: coarse)').matches ? 'touch' : 'mouse', preventDefault: vi.fn(), } as unknown as React.PointerEvent; } @@ -57,12 +117,145 @@ beforeEach(() => { removeEventListener: vi.fn(), }), ); + // The release springs run on the motion foundation's animator, whose clock + // is performance.now() and whose curves end on a timer: fake both, so a + // test decides when a spring has landed. + vi.useFakeTimers({ + toFake: [ + 'performance', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); +/** + * A finger: a coarse primary pointer, here in WebKit on iOS. Every touch + * device takes UIKit's rule and springs (one touch model, AST-094); a mouse + * keeps today's (releaseMotion.ts, sheetRelease.ts). + */ +/** + * A coarse primary pointer, where the sheet's touch listeners exist + * (spec:AST-094 FR24); every other query stays unmatched. + */ +function stubCoarsePointer() { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); +} + +function stubIosIdiom({reducedMotion = false}: {reducedMotion?: boolean} = {}) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query.includes('pointer: coarse') || + (reducedMotion && query.includes('prefers-reduced-motion')), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', { + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + escape: (value: string) => value, + }); +} + +/** Lets every running spring reach its end (the animator's clock and timers). */ +function land() { + act(() => { + vi.advanceTimersByTime(2000); + }); +} + +/** The sheet's travel channel (`translate`), in px down. */ +function travel(el: HTMLElement): number { + const [, y = '0px'] = el.style.translate.split(' '); + return Number.parseFloat(y); +} + +/** A keyframe's vertical travel, px (its `translate`). */ +function translateYOf(frame: Keyframe): number { + return Number.parseFloat(String(frame.translate).split(' ')[1]); +} + +/** + * The keyframes a recorded animation plays up to `untilMs` after its start, + * as (ms, value). The animator compiles a curve into keyframes on a 30 Hz + * grid, splitting each chord until it is within a quarter of a pixel of the + * curve: every keyframe lies on the curve, and a point between two is the + * browser's interpolation, so a curve is checked at its keyframes. + */ +function keyframesUntil( + animation: {keyframes: Keyframe[]; options: KeyframeAnimationOptions}, + read: (frame: Keyframe) => number, + untilMs: number, +): {t: number; value: number}[] { + const duration = animation.options.duration as number; + const frames = animation.keyframes + .map(frame => ({ + t: (frame.offset as number) * duration, + value: read(frame), + })) + .filter(frame => frame.t <= untilMs); + // The grid puts a keyframe in every 30 Hz interval. + expect(frames.length).toBeGreaterThanOrEqual( + Math.floor(untilMs / (1000 / 30)), + ); + return frames; +} + +/** + * A finger moving from `from` to `to` (px) between `t0` and `t1` (ms) on + * pointer events every `step` ms (60 Hz by default): the estimator reads + * gaps longer than its assume-stopped window as a finger at rest, so a + * throw is a stream, not two samples. + */ +function stream( + hook: Hook, + target: HTMLElement, + { + from, + to, + t0, + t1, + step = 1000 / 60, + finger = false, + }: { + from: number; + to: number; + t0: number; + t1: number; + step?: number; + /** Moves from a finger rather than a mouse. */ + finger?: boolean; + }, +) { + const count = Math.max(1, Math.round((t1 - t0) / step)); + for (let i = 1; i <= count; i++) { + (finger ? moveFinger : move)( + hook, + from + ((to - from) * i) / count, + t0 + ((t1 - t0) * i) / count, + target, + ); + } +} + function setup(options: Partial = {}) { const onDismiss = vi.fn(); const hook = renderHook( @@ -95,15 +288,48 @@ function down(hook: Hook, y: number, t: number, target: HTMLElement) { hook.result.current.handleProps.onPointerDown(pointerEvent(y, t, target)), ); } +// The same press and move from a finger (pointerType "touch"). +function fingerEvent(clientY: number, timeStamp: number, target: HTMLElement) { + return { + ...pointerEvent(clientY, timeStamp, target), + pointerType: 'touch', + } as unknown as React.PointerEvent; +} +function downFinger(hook: Hook, y: number, t: number, target: HTMLElement) { + act(() => hook.result.current.sheetRef(target)); + act(() => + hook.result.current.handleProps.onPointerDown(fingerEvent(y, t, target)), + ); +} +function moveFinger(hook: Hook, y: number, t: number, target: HTMLElement) { + act(() => + hook.result.current.handleProps.onPointerMove(fingerEvent(y, t, target)), + ); +} function move(hook: Hook, y: number, t: number, target: HTMLElement) { act(() => hook.result.current.handleProps.onPointerMove(pointerEvent(y, t, target)), ); } +/** + * The finger comes to rest at `y`: two still moves after it gets there. + * UIKit's velocity filter (0.2 × the newest event's speed + 0.8 × the one + * before) only reads zero after two of them, and never decays on its own: a + * finger that stops sending events keeps its last speed. + */ +function restAt(hook: Hook, y: number, t: number, target: HTMLElement) { + move(hook, y, t, target); + move(hook, y, t + 50, target); + move(hook, y, t + 100, target); +} + function up(hook: Hook, y: number, t: number, target: HTMLElement) { act(() => hook.result.current.handleProps.onPointerUp(pointerEvent(y, t, target)), ); + // The release spring lands before the test goes on, as a finger that lifts + // and comes back later would find it. + land(); } describe('useSheetGestures', () => { @@ -111,7 +337,9 @@ describe('useSheetGestures', () => { const {hook, onDismiss} = setup({snapHeights: () => [200]}); const t = makeTarget(); down(hook, 0, 0, t); - // 60px in 20ms = 3px/ms, and 60px > the flick distance floor. + // 60px in 20ms = 3px/ms, and 60px > the flick distance floor (two + // moves: the release filter needs two moved samples, spec:AST-100 FR2). + move(hook, 30, 10, t); move(hook, 60, 20, t); up(hook, 60, 22, t); expect(onDismiss).toHaveBeenCalledTimes(1); @@ -128,6 +356,7 @@ describe('useSheetGestures', () => { }); const t = makeTarget(); down(hook, 0, 0, t); + move(hook, 30, 10, t); move(hook, 60, 20, t); up(hook, 60, 22, t); @@ -150,6 +379,7 @@ describe('useSheetGestures', () => { onSnap.mockClear(); // Now flick up fast: 60px in 20ms = 3px/ms, over the distance floor. down(hook, 200, 1000, t); + move(hook, 170, 1010, t); move(hook, 140, 1020, t); up(hook, 140, 1022, t); expect(onDismiss).not.toHaveBeenCalled(); @@ -157,9 +387,141 @@ describe('useSheetGestures', () => { expect(hook.result.current.settledOffset).toBe(0); }); - describe('release projection', () => { - // A release is judged where the sheet would coast to at the finger's - // speed, the way a thrown scroll view is, not where the finger left it. + describe("release projection under a finger (UIKit's rule)", () => { + beforeEach(() => stubIosIdiom()); + + // A release is judged where the sheet would be after + // sheet.release.projectionSeconds at the finger's velocity, not where the + // finger left it; the dismissed position (gone below the screen) counts + // as one of the positions (sheetRelease.ts). Here: detents at 0 and 200, + // gone at 400. + it('dismisses a throw released short of halfway to gone', () => { + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + // 1600 px/s: the finger stops at 160, but the release projects to + // 160 + 1600 × 0.1 = 320, nearer gone (400) than the detent (200). + stream(hook, t, {from: 0, to: 160, t0: 0, t1: 100}); + up(hook, 160, 102, t); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('lands a throw on the next detent once it projects past halfway', () => { + const onSnap = vi.fn(); + const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap}); + const t = makeTarget(); + down(hook, 0, 0, t); + // 600 px/s from 60: projected 60 + 68 = 128, past halfway to 200. + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 100}); + up(hook, 60, 102, t); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(200); + expect(onSnap).toHaveBeenLastCalledWith(200); + }); + + it('returns a gentle throw that does not project past halfway', () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + // 300 px/s from 60: projected 94, still nearer the open detent. + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 200}); + up(hook, 60, 202, t); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('reads a throw that begins after a hold at its own speed, not the average since touch-down', () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + move(hook, 0, 250, t); // a still hold on the handle + // then 1200 px/s: projected 60 + 137 = 197 → the 200 detent. The + // average since touch-down (200 px/s) would have projected 83 → open. + stream(hook, t, {from: 0, to: 60, t0: 250, t1: 300}); + up(hook, 60, 302, t); + expect(hook.result.current.settledOffset).toBe(200); + }); + + it('reads the velocity from the timestamps the events carry, not from when they are handled', () => { + // The same throw, handled late and all at once: the release still + // projects at the speed the finger moved. + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + vi.advanceTimersByTime(500); // a busy main thread, then the batch + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); + up(hook, 150, 102, t); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('keeps the throw through a still finger lifted within 40 ms, as UIKit does', () => { + // A still finger sends no events to lower the speed: UIKit reports + // the last moving velocity at the lift (spec:AST-100 FR2). + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 20, step: 10}); // 3000 px/s ... + up(hook, 60, 55, t); // ... then 35 ms still, then the lift + // Projected 60 + 3000 × 0.1 = 360, nearest gone (400). + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('cancels a release moving back toward where the drag began, whatever the travel so far (spec:AST-094 FR37)', () => { + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + // From the tallest detent down past the midpoint to 150 px ... + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 300}); + // ... then back up at 50 px/s and let go at 146 px, nearest the 200 px + // detent: the drag cancels to where it began. + move(hook, 148, 340, t); + move(hook, 146, 380, t); + up(hook, 146, 382, t); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('releases no throw from a finger held still for longer than 40 ms before the lift', () => { + // UIKit would keep the last motion through a silent rest; the release + // filter reads 0 after its stillness interval, as Android's does, so a + // finger that stopped never flicks (spec:AST-100 FR2). + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 20}); // 3000 px/s ... + up(hook, 60, 400, t); // ... then 380 ms still, then the lift + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('releases no throw from a finger that came to rest before lifting', () => { + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 20}); + restAt(hook, 60, 100, t); + up(hook, 60, 400, t); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('settles a throw that would project past halfway to gone at the shortest detent when canDismiss is false', () => { + const {hook, onDismiss} = setup({ + canDismiss: false, + snapHeights: () => [200], + }); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); + up(hook, 150, 102, t); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(200); + }); + }); + + describe("release projection under a mouse (today's rule)", () => { + // Under a mouse, a release is judged where the + // sheet would coast to at the finger's speed (0.998 per ms), in the + // drag's direction, as today. it('dismisses on a medium throw released short of the dismiss line', () => { const {hook, onDismiss} = setup({snapHeights: () => [200]}); const t = makeTarget(); @@ -203,19 +565,36 @@ describe('useSheetGestures', () => { const t = makeTarget(); down(hook, 0, 0, t); move(hook, 0, 250, t); // a still hold on the handle - move(hook, 30, 275, t); // then 60px in 50ms + move(hook, 20, 270, t); // then 60px in 50ms, the last 40 in 30 + move(hook, 40, 285, t); move(hook, 60, 300, t); up(hook, 60, 302, t); expect(onDismiss).toHaveBeenCalledTimes(1); }); - it('reads a single move after a hold as a move, not as the whole hold', () => { + it('reads a single move after a hold over the hold, as for every pointer (spec:AST-100 FR4)', () => { const {hook, onDismiss} = setup({snapHeights: () => [200]}); const t = makeTarget(); down(hook, 0, 0, t); move(hook, 60, 300, t); // one sample, 300ms after touch-down up(hook, 60, 302, t); - expect(onDismiss).toHaveBeenCalledTimes(1); + // The release filter reads no velocity from a single move + // (spec:AST-100 FR2), under a mouse as under a finger: the sheet settles + // where it is nearest. + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('throws nothing from a mouse held still for over 40 ms before the lift (spec:AST-100 FR2)', () => { + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 40}); // 1500 px/s ... + up(hook, 60, 95, t); // ... 55 ms still, then the lift + // The speed over the last 100 ms used to keep the throw; the release + // velocity every pointer reads is 0 after a still pointer. + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(0); }); it('releases no throw from a finger that rested before lifting', () => { @@ -254,6 +633,20 @@ describe('useSheetGestures', () => { expect(onDismiss).not.toHaveBeenCalled(); }); + it('projects a fast short nudge onto the next detent under a finger', () => { + stubIosIdiom(); + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + // Fast (2000 px/s) but only 20px of travel: projected 20 + 228 = 248, + // which lands on the 200 detent; gone (400) is further. + down(hook, 0, 0, t); + move(hook, 10, 5, t); + move(hook, 20, 10, t); + up(hook, 20, 12, t); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(200); + }); + it('settles to the nearest detent on a slow drag', () => { const onSnap = vi.fn(); const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap}); @@ -365,6 +758,7 @@ describe('useSheetGestures', () => { const t = makeTarget(); down(hook, 0, 0, t); move(hook, 320, 700, t); + restAt(hook, 320, 800, t); up(hook, 320, 1100, t); expect(hook.result.current.settledOffset).toBe(340); expect(hook.result.current.settledLayoutOffset).toBe(0); @@ -386,6 +780,30 @@ describe('useSheetGestures', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + it('uses the visible shortest detent height for the halfway line under a finger', () => { + stubIosIdiom(); + const {hook, onDismiss} = setup({ + snapHeights: () => [200], + offscreenBlockEndInset: 48, + }); + const t = makeTarget(); + + // Visible full height is 352px, so the 200px stop is offset 152 and the + // sheet is gone at 352. Released at rest, it dismisses past halfway + // between them (252px); the hidden reserve must not move that line. + down(hook, 0, 0, t); + move(hook, 250, 1000, t); + restAt(hook, 250, 1100, t); + up(hook, 250, 2000, t); + expect(onDismiss).not.toHaveBeenCalled(); + + down(hook, 152, 3000, t); + move(hook, 254, 4000, t); + restAt(hook, 254, 4100, t); + up(hook, 254, 5000, t); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('a downward drag from a middle detent never snaps back up past it', () => { // Three detents on a 600px sheet: full (offset 0), mid 360 (offset 240), // short 160 (offset 440). Rest at the mid detent, then drag DOWN a modest @@ -470,6 +888,7 @@ describe('useSheetGestures', () => { down(hook, 0, 0, t); move(hook, 170, 400, t); move(hook, 340, 900, t); + restAt(hook, 340, 1000, t); up(hook, 340, 1400, t); expect(hook.result.current.settledOffset).toBe(340); // Last reported opacity (on settle) is the peek floor, not fully hidden — @@ -478,18 +897,70 @@ describe('useSheetGestures', () => { expect(values[values.length - 1]).toBeCloseTo(0.3); }); - it('magnetically settles the live drag onto a nearby detent', () => { - // Detents for a 400px sheet with snaps 300 and 200 -> visible-height snaps - // become offsets [0, 100, 200]. Approaching the middle detent (offset 100) - // from just below (raw ~90) — inside MAGNET_RANGE and BELOW the shortest - // detent so the between-detents magnet applies — should ease toward 100. + it('tracks the finger 1:1 between detents and into the dismiss zone', () => { + // UIKit's presentation: a finger where the primary pointer is coarse. + stubIosIdiom(); + // UIKit's sheet follows the finger exactly (interaction offset = detent + // offset + translation, no magnet); the release picks the detent. Offsets + // for a 400px sheet with snaps 300 and 200: [0, 100, 200], gone at 400. + const {hook} = setup({snapHeights: () => [200, 300]}); + const t = makeTarget(); + downFinger(hook, 0, 0, t); + for (const y of [10, 60, 90, 99, 100, 101, 140, 199, 230, 320]) { + moveFinger(hook, y, y * 2, t); + expect(liveOffset(hook)).toBe(y); + } + }); + + it('under a mouse, eases the live drag onto a detent within 40 px, as before (spec:AST-017 FR59)', () => { + // Offsets [0, 100, 200] for a 400px sheet with snaps 300 and 200. 30 px + // short of the 100 detent the pull is 1 - (30 / 40)^2 of the gap. const {hook} = setup({snapHeights: () => [200, 300]}); const t = makeTarget(); down(hook, 0, 0, t); - move(hook, 90, 100, t); // raw offset 90, 10px from the 100 detent - const off = liveOffset(hook); - expect(off).toBeGreaterThan(90); // pulled up toward 100 - expect(off).toBeLessThanOrEqual(100); + move(hook, 70, 100, t); + expect(liveOffset(hook)).toBeCloseTo(70 + 30 * (1 - (30 / 40) ** 2), 6); + // 40 px or more from every detent, and past the shortest one, 1:1. + move(hook, 150, 150, t); + expect(liveOffset(hook)).toBe(150); + move(hook, 320, 200, t); + expect(liveOffset(hook)).toBe(320); + }); + + describe('one answer for the tracking and the release (spec:AST-094 FR13)', () => { + // Offsets [0, 100, 200] for a 400px sheet with snaps 300 and 200: 10 px + // short of the 100 detent, today's drag eases toward it; UIKit's tracks + // 1:1. The release reads the same answer (releaseMotion.test.ts). + function dragTo90(pointerType: 'touch' | 'pen' | 'mouse'): number { + const {hook} = setup({snapHeights: () => [200, 300]}); + const t = makeTarget(); + act(() => hook.result.current.sheetRef(t)); + const as = (y: number, ms: number) => + ({...pointerEvent(y, ms, t), pointerType}) as React.PointerEvent; + act(() => hook.result.current.handleProps.onPointerDown(as(0, 0))); + act(() => hook.result.current.handleProps.onPointerMove(as(90, 100))); + return liveOffset(hook); + } + + it("keeps a finger on today's drag where the primary pointer is fine", () => { + const offset = dragTo90('touch'); + expect(offset).toBeGreaterThan(90); + expect(offset).toBeLessThanOrEqual(100); + }); + + it("keeps a mouse on today's drag where the primary pointer is coarse", () => { + stubIosIdiom(); + expect(matchMedia('(pointer: coarse)').matches).toBe(true); + const offset = dragTo90('mouse'); + expect(offset).toBeGreaterThan(90); + expect(offset).toBeLessThanOrEqual(100); + }); + + it('tracks a finger or a pen 1:1 where the primary pointer is coarse', () => { + stubIosIdiom(); + expect(dragTo90('touch')).toBe(90); + expect(dragTo90('pen')).toBe(90); + }); }); it('translates the surface live during a drag', () => { @@ -499,9 +970,9 @@ describe('useSheetGestures', () => { move(hook, 50, 100, t); expect(liveOffset(hook)).toBe(50); expect(hook.result.current.isDragging).toBe(true); - // Written to the element, not rendered: the move costs one style write - // and no commit. - expect(t.style.transform).toBe('translateY(50px)'); + // Written to the element's travel channel, not rendered: the move costs + // one style write and no commit. + expect(travel(t)).toBe(50); }); it('renders nothing for a move that keeps the layout split', () => { @@ -520,12 +991,34 @@ describe('useSheetGestures', () => { move(hook, 30, 50, t); move(hook, 60, 100, t); move(hook, 90, 150, t); - expect(t.style.transform).toBe('translateY(90px)'); + expect(travel(t)).toBe(90); // One render announces that the sheet is traveling; the moves after it // render nothing. expect(renders - rendersAtDown).toBeLessThanOrEqual(1); }); + it('keeps the sheet under the finger until the host renders the height a drag above its base restores', async () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + // Rest at the 200px working detent: the sheet gives 200px up as layout, + // and the host's transform holds the rest of the detent (0 here). + down(hook, 0, 0, t); + move(hook, 200, 400, t); + up(hook, 200, 800, t); + expect(hook.result.current.settledLayoutOffset).toBe(200); + // A move 50px above the base, handled before React renders the restored + // height: the travel is written against the height still on screen + // (200px less layout), so the sheet is drawn where the finger is ... + down(hook, 200, 1000, t); + hook.result.current.handleProps.onPointerMove(pointerEvent(150, 1100, t)); + expect(liveOffset(hook)).toBe(150); + expect(travel(t)).toBe(150 - 200); + // ... and the commit that restores the full height moves the travel + // with it, before anything paints. + await act(async () => {}); + expect(travel(t)).toBe(150); + }); + it('restores the full layout height when a drag crosses above its base', () => { const onSnap = vi.fn(); const {hook} = setup({snapHeights: () => [200], onSnap}); @@ -540,8 +1033,8 @@ describe('useSheetGestures', () => { down(hook, 200, 1000, t); move(hook, 150, 1100, t); expect(liveOffset(hook)).toBe(150); - expect(liveOffset(hook)).toBe(150); - expect(t.style.transform).toBe('translateY(150px)'); + // The full height is back (layout 0), so the travel carries all 150px. + expect(travel(t)).toBe(150); }); it('returns to the settled detent when a context menu interrupts a drag', () => { @@ -691,16 +1184,34 @@ describe('useSheetGestures', () => { expect(hook.result.current.isDragging).toBe(false); }); - it('rubber-bands an upward drag past the fully-open position', () => { + it('stretches a finger dragged up past the fully-open position by the rubber band, within the reserve (spec:AST-094 FR3)', () => { + // UIKit's presentation: a finger where the primary pointer is coarse. + stubIosIdiom(); + // (1 - 1 / (x * 0.55 / d + 1)) * d past the tallest detent, d the sheet's + // 400 px height, no farther than the 48 px the sheet reserves below. + const band = (x: number) => + (1 - 1 / ((x * 0.55) / SHEET_HEIGHT + 1)) * SHEET_HEIGHT; + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + downFinger(hook, 100, 0, t); + moveFinger(hook, 60, 50, t); // 40 px past the top of a rested-at-top sheet + expect(liveOffset(hook)).toBeCloseTo(-band(40), 6); + moveFinger(hook, 20, 100, t); // 80 px past it + expect(liveOffset(hook)).toBeCloseTo(-band(80), 6); + moveFinger(hook, -100, 150, t); // 200 px past it: the reserve + expect(liveOffset(hook)).toBe(-48); + moveFinger(hook, 130, 200, t); // back down: the finger's travel from the press + expect(liveOffset(hook)).toBe(30); + }); + + it('under a mouse, stretches an upward drag past the fully-open position at 0.35, within the reserve, as before (spec:AST-017 FR59)', () => { const {hook} = setup({snapHeights: () => [200]}); const t = makeTarget(); down(hook, 100, 0, t); - move(hook, 20, 100, t); // drag up 80px past the top of a rested-at-top sheet - // Overscroll is allowed but damped: a resisted fraction of the raw -80, - // and negative (above the top), not the raw distance and not clamped to 0. - const off = liveOffset(hook); - expect(off).toBeLessThan(0); - expect(off).toBeGreaterThan(-80); // resisted, so smaller in magnitude + move(hook, 20, 100, t); // 80 px past the top of a rested-at-top sheet + expect(liveOffset(hook)).toBeCloseTo(-80 * 0.35, 6); + move(hook, -100, 150, t); // 200 px past it: the reserve + expect(liveOffset(hook)).toBe(-48); }); it('dismisses a single-height sheet on a slow drag past the floor', () => { @@ -797,6 +1308,9 @@ describe('useSheetGestures', () => { }); describe('body touch handoff', () => { + // Touch listeners exist only where the primary pointer is coarse. + beforeEach(() => stubCoarsePointer()); + // Attach a real DOM node via the body ref, then dispatch touch events. The // hook's non-passive touchmove listener is how the handoff works on touch // devices (pointer events get cancelled once native panning starts). @@ -851,6 +1365,39 @@ describe('useSheetGestures', () => { expect(hook.result.current.isDragging).toBe(true); }); + it('promotes a top pull-down from a finger where the primary pointer is fine (a touchscreen laptop), as on a phone', () => { + // A touchscreen laptop: a mouse is the primary pointer, and a finger on + // the screen sends touches, which the browser would scroll the content + // with. The body's touch listeners serve that finger as they serve a + // phone's: under a fine pointer a component may listen on its own + // element (spec:AST-094 FR24). + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('any-pointer: coarse'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + const {hook} = setup({snapHeights: () => [200]}); + const el = makeScroller({ + scrollTop: 0, + clientHeight: 200, + scrollHeight: 800, + }); + act(() => hook.result.current.sheetRef(el)); + act(() => hook.result.current.bodyProps.ref(el)); + let move: Event | undefined; + act(() => { + touch(el, 'touchstart', 0); + move = touch(el, 'touchmove', 50); + }); + expect(hook.result.current.isDragging).toBe(true); + // The pull is the sheet's, not the content's scroll. + expect(move?.defaultPrevented).toBe(true); + }); + it('promotes a bottom pull-up (touch) into a sheet drag that travels', () => { const {hook} = setup({snapHeights: () => [200]}); // scrolled to the bottom: scrollTop + clientHeight === scrollHeight @@ -1088,6 +1635,9 @@ describe('useSheetGestures', () => { }); describe('body touch handoff from a nested scroller', () => { + // Touch listeners exist only where the primary pointer is coarse. + beforeEach(() => stubCoarsePointer()); + // A host that scrolls content in a box of its own inside the body (a // pinned header and footer around a scrolling middle, a grid) never moves // the body's scrollTop. The edge that decides the handoff is the edge of @@ -1175,7 +1725,7 @@ describe('useSheetGestures', () => { expect(pull?.defaultPrevented).toBe(true); expect(hook.result.current.isDragging).toBe(true); expect(liveOffset(hook)).toBe(60); - expect(body.style.transform).toBe('translateY(60px)'); + expect(travel(body)).toBe(60); }); it('chains to a scrolled body before the sheet when the nested box is at its top', () => { @@ -1249,6 +1799,9 @@ describe('useSheetGestures', () => { }); describe('body touch handoff mid-gesture', () => { + // Touch listeners exist only where the primary pointer is coarse. + beforeEach(() => stubCoarsePointer()); + // The reported bug: the finger lands mid-content, swipes up, REACHES the // end of the content, and keeps pulling in the same continuous gesture. // Arming decided at touchstart can never see that, and by the time the @@ -1407,6 +1960,8 @@ describe('useSheetGestures', () => { // things moving at once is the failure mode; the sheet yields. expect(hook.result.current.isDragging).toBe(false); expect(hook.result.current.settledOffset).toBe(200); + // The sheet springs back to its detent while the content scrolls. + land(); act(() => { el.scrollTop = 600; // scrolled back to the end @@ -1636,3 +2191,1958 @@ describe('useSheetGestures', () => { }); }); }); + +describe('useSheetGestures: the release on the compositor', () => { + // A finger: releases run on UIKit's springs + // (releaseMotion.ts). A mouse keeps the theme's timing. + function stubPointer({ + coarse, + reducedMotion = false, + }: { + coarse: boolean; + reducedMotion?: boolean; + }) { + if (coarse) { + stubIosIdiom({reducedMotion}); + return; + } + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + (coarse && query.includes('pointer: coarse')) || + (reducedMotion && query.includes('prefers-reduced-motion')), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + } + beforeEach(() => stubPointer({coarse: true})); + + interface Played { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + } + /** Records the Web Animations the animator plays on `el` (jsdom has none). */ + function recordAnimations(el: HTMLElement): Played[] { + const played: Played[] = []; + el.animate = vi.fn((keyframes, options) => { + played.push({ + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + }); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }); + return played; + } + /** The travel (px) a played animation has at `ms` after its start. */ + function travelAt(animation: Played, ms: number): number { + const duration = animation.options.duration as number; + const frames = animation.keyframes.map(frame => ({ + t: (frame.offset as number) * duration, + y: Number.parseFloat(String(frame.translate).split(' ')[1]), + })); + for (let i = 1; i < frames.length; i++) { + if (ms <= frames[i].t) { + const a = frames[i - 1]; + const b = frames[i]; + return a.y + ((b.y - a.y) * (ms - a.t)) / (b.t - a.t); + } + } + return frames[frames.length - 1].y; + } + + it('springs to the detent on the slow-release spring, handed the finger velocity', () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + const played = recordAnimations(t); + down(hook, 0, 0, t); + // 600 px/s, released at 60: lands on the 200 detent (sheetRelease). + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(60, 102, t)), + ); + expect(played).toHaveLength(1); + const curve = solveSpring(toSpring(springs.sheetDetent), { + from: 60, + to: 200, + velocity: 600, + }); + // On the closed-form spring, at every keyframe ... + for (const {t: ms, value} of keyframesUntil(played[0], translateYOf, 400)) { + expect(value).toBeCloseTo(curve.value(ms / 1000), 1); + } + // ... which is the spring handed the finger's 600 px/s (M3): one + // started at rest would trail it by more than 5 px 16 ms in. + const atRest = solveSpring(toSpring(springs.sheetDetent), { + from: 60, + to: 200, + velocity: 0, + }); + expect(travelAt(played[0], 16) - atRest.value(0.016)).toBeGreaterThan(5); + // Composited: keyframes played linearly on one Web Animation, so the + // main thread is not involved. + expect(played[0].options).toMatchObject({easing: 'linear'}); + // The inline style holds what is on screen while the spring runs, and the + // end is written when it retires. + expect(travel(t)).toBe(60); + expect(translateYOf(played[0].keyframes.at(-1) as Keyframe)).toBe(200); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(travel(t)).toBe(200); + }); + + it('takes a fast release on the 80 % damped spring', () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + const played = recordAnimations(t); + down(hook, 0, 0, t); + // A fast throw down (|v| ≥ sheet.release.fastSpeed) from the top: a + // flick, to the next detent down (200); 58 px + 1308 px/s × 0.1 s + // projects to 189, nearest that detent too. + const speed = sheet.release.fastSpeed + 300; + const to = speed * 0.044; + stream(hook, t, {from: 0, to, t0: 0, t1: 44}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(to, 45, t)), + ); + expect(hook.result.current.settledOffset).toBe(200); + const from = travelAt(played.at(-1) as Played, 0); + const curve = solveSpring(toSpring(springs.sheetReleaseFast), { + from, + to: 200, + velocity: speed, + }); + for (const {t: ms, value} of keyframesUntil( + played.at(-1) as Played, + translateYOf, + 200, + )) { + expect(value).toBeCloseTo(curve.value(ms / 1000), 1); + } + }); + + it('lets a finger catch the settling sheet where the curve has it, without a jump', () => { + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(60, 102, t)), + ); + const curve = solveSpring(toSpring(springs.sheetDetent), { + from: 60, + to: 200, + velocity: 600, + }); + act(() => { + vi.advanceTimersByTime(80); + }); + const onScreen = curve.value(0.08); + // The finger lands 80 ms into the settle and holds still. + act(() => + hook.result.current.handleProps.onPointerDown(pointerEvent(500, 1000, t)), + ); + expect(hook.result.current.isDragging).toBe(true); + expect(liveOffset(hook)).toBeCloseTo(onScreen, 0); + expect(travel(t)).toBeCloseTo(onScreen, 0); + // And it drags from there. + move(hook, 480, 1050, t); + expect(liveOffset(hook)).toBeCloseTo(onScreen - 20, 0); + }); + + it('on iOS, turns text selection and the callout off on the whole sheet while a finger holds or drags it, and back on at the lift', () => { + // iOS WebKit fires no selectstart for a long-press selection (iOS 26.2 + // simulator), so only user-select refuses one there, and only on the whole + // sheet: on the element under the finger alone, the long press selected a + // word beside it (4 of 4 takes). + stubIosIdiom(); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.setProperty('user-select', 'text'); + const touch = (y: number, time: number) => + ({ + ...pointerEvent(y, time, t), + pointerType: 'touch', + }) as unknown as React.PointerEvent; + act(() => hook.result.current.sheetRef(t)); + act(() => hook.result.current.handleProps.onPointerDown(touch(0, 0))); + expect(t.style.getPropertyValue('user-select')).toBe('none'); + for (const [y, time] of [ + [20, 33], + [40, 66], + [60, 100], + ]) { + act(() => hook.result.current.handleProps.onPointerMove(touch(y, time))); + } + act(() => hook.result.current.handleProps.onPointerUp(touch(60, 102))); + expect(t.style.getPropertyValue('user-select')).toBe('text'); + act(() => { + vi.advanceTimersByTime(80); + }); + // A finger catches it mid-settle and holds still: off at the touch-down, + // before a long press would select, ... + act(() => hook.result.current.handleProps.onPointerDown(touch(500, 1000))); + expect(hook.result.current.isDragging).toBe(true); + expect(t.style.getPropertyValue('user-select')).toBe('none'); + // ... and the sheet's own value is back when it lets go. + act(() => hook.result.current.handleProps.onPointerUp(touch(500, 1600))); + expect(t.style.getPropertyValue('user-select')).toBe('text'); + }); + + it('elsewhere, cancels the selection a finger holding or dragging the sheet would start, and changes no style', () => { + stubCoarsePointer(); + vi.stubGlobal('CSS', {supports: () => false, escape: (v: string) => v}); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + const row = document.createElement('span'); + t.appendChild(row); + t.style.setProperty('user-select', 'text'); + const touch = (y: number, time: number) => + ({ + ...pointerEvent(y, time, t), + target: row, + pointerType: 'touch', + }) as unknown as React.PointerEvent; + // A long press on the row: Chromium fires `selectstart` there first. + const longPressSelects = () => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + row.dispatchEvent(event); + return !event.defaultPrevented; + }; + act(() => hook.result.current.sheetRef(t)); + const style = t.getAttribute('style'); + act(() => hook.result.current.handleProps.onPointerDown(touch(0, 0))); + expect(longPressSelects()).toBe(false); + // No style changes: one would repaint the whole sheet at the touch-down + // and again at the lift. + expect(t.getAttribute('style')).toBe(style); + expect(row.getAttribute('style')).toBeNull(); + for (const [y, time] of [ + [20, 33], + [40, 66], + [60, 100], + ]) { + act(() => hook.result.current.handleProps.onPointerMove(touch(y, time))); + } + act(() => hook.result.current.handleProps.onPointerUp(touch(60, 102))); + expect(longPressSelects()).toBe(true); + act(() => { + vi.advanceTimersByTime(80); + }); + // A finger catches it mid-settle and holds still: refused from the + // touch-down, before a long press would select, until it lets go. + act(() => hook.result.current.handleProps.onPointerDown(touch(500, 1000))); + expect(hook.result.current.isDragging).toBe(true); + expect(longPressSelects()).toBe(false); + expect(t.style.getPropertyValue('user-select')).toBe('text'); + act(() => hook.result.current.handleProps.onPointerUp(touch(500, 1600))); + expect(longPressSelects()).toBe(true); + }); + + it('leaves text selection to the browser for a finger where the primary pointer is fine (spec:AST-094 FR19)', () => { + // A touchscreen laptop: a finger drags the handle through today's drag, + // and selection and the callout stay the browser's. + stubPointer({coarse: false}); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.setProperty('user-select', 'text'); + const longPressSelects = () => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + t.dispatchEvent(event); + return !event.defaultPrevented; + }; + act(() => hook.result.current.sheetRef(t)); + act(() => + hook.result.current.handleProps.onPointerDown(fingerEvent(0, 0, t)), + ); + for (const [y, time] of [ + [20, 33], + [40, 66], + [60, 100], + ]) { + act(() => + hook.result.current.handleProps.onPointerMove(fingerEvent(y, time, t)), + ); + } + expect(hook.result.current.isDragging).toBe(true); + expect(t.style.getPropertyValue('user-select')).toBe('text'); + expect(longPressSelects()).toBe(true); + act(() => + hook.result.current.handleProps.onPointerUp(fingerEvent(60, 102, t)), + ); + expect(t.style.getPropertyValue('user-select')).toBe('text'); + }); + + it('carries a drag-dismissed sheet off screen on the release spring, for the host to wait on', async () => { + // A host that closes when asked. + const onDismiss = vi.fn(); + const snapHeights = () => [200]; + const hook = renderHook(() => { + const [isOpen, setIsOpen] = useState(true); + return useSheetGestures({ + isOpen, + snapHeights, + onDismiss: () => { + onDismiss(); + setIsOpen(false); + }, + }); + }); + const t = makeTarget(); + const played = recordAnimations(t); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); // 1500 px/s + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 102, t)), + ); + expect(onDismiss).toHaveBeenCalledTimes(1); + const exit = hook.result.current.gestureExitRef.current; + expect(exit).not.toBeNull(); + // Rested at the open detent, so the resting transform is none. + expect(exit?.transform).toBe('none'); + // The inline style holds what is on screen while the spring runs, and the + // end is written when it retires: no frame shows where it is going. The + // played curve ends gone, the sheet's whole 400px below where it rests + // open. + expect(travel(t)).toBe(150); + expect(translateYOf(played[0].keyframes.at(-1) as Keyframe)).toBe(400); + const curve = solveSpring(toSpring(springs.sheetDismissSwipe), { + from: 150, + to: 400, + velocity: 1500, + }); + expect(travelAt(played[0], 50)).toBeCloseTo(curve.value(0.05), 1); + // The exit ends when the spring lands, and the end is written then. + vi.advanceTimersByTime(2000); + await expect(exit?.finished).resolves.toBe(true); + expect(travel(t)).toBe(400); + }); + + it('keeps a fast throw onto the tallest detent within the reserve below the screen', () => { + const {hook} = setup({ + snapHeights: () => [200], + offscreenBlockEndInset: 48, + }); + const t = makeTarget(); + const played = recordAnimations(t); + downFinger(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 600, finger: true}); + // Thrown back up at 3000 px/s, released 5 px below fully open. + stream(hook, t, {from: 150, to: 5, t0: 600, t1: 648, finger: true}); + act(() => + hook.result.current.handleProps.onPointerUp(fingerEvent(5, 650, t)), + ); + expect(hook.result.current.settledOffset).toBe(0); + const release = played.at(-1) as Played; + // The sheet extends 48 px below the screen: it never rises past that. + for (let ms = 0; ms <= 600; ms += 4) { + expect(travelAt(release, ms)).toBeGreaterThanOrEqual(-48.5); + } + }); + + it('keeps a finger let go above fully open, still moving up, within the reserve below the screen', () => { + const {hook} = setup({ + snapHeights: () => [200], + offscreenBlockEndInset: 48, + }); + const t = makeTarget(); + const played = recordAnimations(t); + downFinger(hook, 400, 0, t); + // Up past fully open until the band holds the sheet at the 48 px wall, + // the finger still moving up at 1500 px/s when it lifts. + stream(hook, t, {from: 400, to: 100, t0: 0, t1: 200, finger: true}); + expect(liveOffset(hook)).toBe(-48); + act(() => + hook.result.current.handleProps.onPointerUp(fingerEvent(100, 201, t)), + ); + expect(hook.result.current.settledOffset).toBe(0); + const release = played.at(-1) as Played; + for (let ms = 0; ms <= 600; ms += 4) { + expect(travelAt(release, ms)).toBeGreaterThanOrEqual(-48.5); + } + }); + + it('plays the measured fast release whatever a `--motion-…` variable says, within the reserve (spec:AST-094 FR4 names no sheet role)', () => { + // A theme scope that sets the fast release's variable to a bouncier + // spring: the sheet's springs are not theme roles, so the release plays + // the measured spring and the overshoot cap holds on it. + const scope = document.createElement('div'); + scope.setAttribute('data-astryx-theme', 'bouncy-release'); + const plain = document.createElement('div'); + document.body.append(scope, plain); + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + // Custom properties inherit: anything inside the scope reads its value. + (element: Element, pseudo?: string | null) => + scope.contains(element) + ? ({ + getPropertyValue: (name: string) => + name === '--motion-springs-sheet-release-fast' + ? '0.5s 0.6' + : '', + } as CSSStyleDeclaration) + : computed(element, pseudo), + ); + const throwIn = (parent: HTMLElement): Played => { + const {hook} = setup({ + snapHeights: () => [200], + offscreenBlockEndInset: 48, + }); + const t = makeTarget(); + parent.append(t); + const played = recordAnimations(t); + downFinger(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 600, finger: true}); + // Thrown back up at 3000 px/s, released 5 px below fully open. + stream(hook, t, {from: 150, to: 5, t0: 600, t1: 648, finger: true}); + act(() => + hook.result.current.handleProps.onPointerUp(fingerEvent(5, 650, t)), + ); + expect(hook.result.current.settledOffset).toBe(0); + return played.at(-1) as Played; + }; + const themed = throwIn(scope); + const measured = throwIn(plain); + for (let ms = 0; ms <= 600; ms += 4) { + expect(travelAt(themed, ms)).toBeCloseTo(travelAt(measured, ms), 6); + expect(travelAt(themed, ms)).toBeGreaterThanOrEqual(-48.5); + } + }); + + it('settles a drag dismissal as unfinished when the sheet unmounts mid-exit', async () => { + const snapHeights = () => [200]; + // A host that closes when asked, as the sheet's own host does. + const hook = renderHook(() => { + const [isOpen, setIsOpen] = useState(true); + return useSheetGestures({ + isOpen, + snapHeights, + onDismiss: () => setIsOpen(false), + }); + }); + const t = makeTarget(); + recordAnimations(t); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); // 1500 px/s + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 102, t)), + ); + const exit = hook.result.current.gestureExitRef.current; + expect(exit).not.toBeNull(); + // Unmounted before the spring lands: its timer must not finish it. + hook.unmount(); + vi.advanceTimersByTime(2000); + await expect(exit?.finished).resolves.toBe(false); + }); + + it('springs back to the detent when the host keeps a drag-dismissed sheet open', () => { + // A controlled host that does not close: isOpen stays true. + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 102, t)), + ); + expect(onDismiss).toHaveBeenCalledTimes(1); + // The commit after the request finds the sheet still open. + expect(hook.result.current.gestureExitRef.current).toBeNull(); + expect(travel(t)).toBe(0); + land(); + expect(travel(t)).toBe(0); + expect(hook.result.current.settledOffset).toBe(0); + }); + + it('cross-fades a release under reduced motion: no spring, 200 ms or less (design:reduced-motion DR2)', () => { + stubPointer({coarse: true, reducedMotion: true}); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + const played = recordAnimations(t); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(60, 102, t)), + ); + expect(hook.result.current.settledOffset).toBe(200); + // The sheet fades; nothing runs longer than the record's 200 ms. + expect(played.some(a => changedProperties(a).includes('opacity'))).toBe( + true, + ); + for (const animation of played) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it('fades a drag-dismissed sheet out in place under reduced motion', () => { + stubPointer({coarse: true, reducedMotion: true}); + const snapHeights = () => [200]; + const hook = renderHook(() => { + const [isOpen, setIsOpen] = useState(true); + return useSheetGestures({ + isOpen, + snapHeights, + onDismiss: () => setIsOpen(false), + }); + }); + const t = makeTarget(); + const played = recordAnimations(t); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 102, t)), + ); + expect(hook.result.current.gestureExitRef.current).not.toBeNull(); + const fade = played.find(a => changedProperties(a).includes('opacity')); + expect(fade).toBeDefined(); + // Fades out where it is, within 200 ms. + expect(Number((fade as Played).keyframes[0].opacity)).toBeCloseTo(1, 2); + expect(settledMs(fade as Played)).toBeLessThanOrEqual(200.5); + for (const animation of played) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it('keeps the theme timing under a mouse, without the velocity hand-off', () => { + stubPointer({coarse: false}); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + // What the panel's stylesheet gives the sheet's transform. + t.style.transitionProperty = 'transform, opacity'; + t.style.transitionDuration = '410ms, 410ms'; + t.style.transitionTimingFunction = + 'cubic-bezier(0.24, 1, 0.4, 1), cubic-bezier(0.24, 1, 0.4, 1)'; + const played = recordAnimations(t); + down(hook, 0, 0, t); + // 200 px/s: today's rule projects 60 + 0.2 × 499 = 160, onto 200. + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 300}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(60, 302, t)), + ); + expect(played).toHaveLength(1); + // 410 ms of curve, then the animator's end-state hold. + expect((played[0].options.duration as number) - END_HOLD_MS).toBeCloseTo( + 410, + 0, + ); + // The theme's easing from 60 to 200, whatever the finger's speed. + const ease = (p: number) => { + // cubic-bezier(0.24, 1, 0.4, 1), by bisection on x. + const bez = (a: number, b: number, u: number) => + 3 * a * u * (1 - u) ** 2 + 3 * b * u * u * (1 - u) + u ** 3; + let lo = 0; + let hi = 1; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + if (bez(0.24, 0.4, mid) < p) { + lo = mid; + } else { + hi = mid; + } + } + return bez(1, 1, (lo + hi) / 2); + }; + for (const {t: ms, value} of keyframesUntil(played[0], translateYOf, 409)) { + expect(value).toBeCloseTo(60 + 140 * ease(ms / 410), 1); + } + // The inline style holds what is on screen while the curve runs, and the + // end is written when it retires. + expect(travel(t)).toBe(60); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(travel(t)).toBe(200); + }); + + /** A finger on Android: coarse, in an engine without `-webkit-touch-callout`. One touch model: it gets what a finger on iOS gets. */ + function stubAndroidFinger({reducedMotion = false} = {}) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query.includes('pointer: coarse') || + (reducedMotion && query.includes('prefers-reduced-motion')), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', {supports: () => false, escape: (v: string) => v}); + } + + it("takes UIKit's rule and spring under a finger on Android too (one touch model)", () => { + stubAndroidFinger(); + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.transitionProperty = 'transform'; + t.style.transitionDuration = '410ms'; + t.style.transitionTimingFunction = 'cubic-bezier(0.24, 1, 0.4, 1)'; + const played = recordAnimations(t); + down(hook, 0, 0, t); + // 600 px/s over 100 px: UIKit's rule lands on 200 (100 + 0.1 × 600 = + // 160), where today's would project past the dismiss line. + stream(hook, t, {from: 0, to: 100, t0: 0, t1: 167}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(100, 169, t)), + ); + expect(onDismiss).not.toHaveBeenCalled(); + expect(hook.result.current.settledOffset).toBe(200); + // On the slow-release spring, not the theme's 410 ms curve. + expect(played).toHaveLength(1); + const curve = solveSpring(toSpring(springs.sheetDetent), { + from: 100, + to: 200, + velocity: 600, + }); + expect(travelAt(played[0], 100)).toBeCloseTo(curve.value(0.1), 0); + }); + + it('cross-fades a touch release on Android under reduced motion, 200 ms or less (DR2)', () => { + stubAndroidFinger({reducedMotion: true}); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.transitionProperty = 'transform'; + t.style.transitionDuration = '410ms'; + t.style.transitionTimingFunction = 'ease'; + const played = recordAnimations(t); + down(hook, 0, 0, t); + // 500 px/s to 150 px: UIKit's rule lands on 200 (150 + 0.1 × 500). + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 300}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 302, t)), + ); + expect(hook.result.current.settledOffset).toBe(200); + expect(played.some(a => changedProperties(a).includes('opacity'))).toBe( + true, + ); + for (const animation of played) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it('lands a mouse release at once under reduced motion (DR3)', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('prefers-reduced-motion'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {hook} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.transitionProperty = 'transform'; + t.style.transitionDuration = '410ms'; + t.style.transitionTimingFunction = 'ease'; + const played = recordAnimations(t); + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 60, t0: 0, t1: 300}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(60, 302, t)), + ); + expect(hook.result.current.settledOffset).toBe(200); + // Nothing animates: every played value holds still. + for (const animation of played) { + expect(changedProperties(animation)).toEqual([]); + } + }); + + it('fades a finger release under reduced motion with a fine primary pointer too (DR2)', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('prefers-reduced-motion'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + const {hook} = setup({snapHeights: () => [200]}); + // A finger on a touchscreen whose primary pointer is fine drags the + // handle through its pointer events (the touch listeners stay off + // there, spec:AST-094 FR24). + const el = makeTarget(); + document.body.appendChild(el); + act(() => hook.result.current.sheetRef(el)); + el.style.transitionProperty = 'transform'; + el.style.transitionDuration = '410ms'; + el.style.transitionTimingFunction = 'ease'; + const played = recordAnimations(el); + // 150 px down, then still before the lift (on the page clock): it + // settles on the 200 px detent. + const t0 = performance.now(); + act(() => + hook.result.current.handleProps.onPointerDown(fingerEvent(0, t0, el)), + ); + for (const [y, dt] of [ + [75, 100], + [150, 200], + ]) { + act(() => { + vi.advanceTimersByTime(100); + hook.result.current.handleProps.onPointerMove( + fingerEvent(y, t0 + dt, el), + ); + }); + } + expect(hook.result.current.isDragging).toBe(true); + act(() => { + vi.advanceTimersByTime(150); + hook.result.current.handleProps.onPointerUp( + fingerEvent(150, t0 + 350, el), + ); + }); + expect(hook.result.current.settledOffset).toBe(200); + // The release fades, though the primary pointer is fine: a finger + // started it. + expect(played.some(a => changedProperties(a).includes('opacity'))).toBe( + true, + ); + for (const animation of played) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it("registers its body's own touch listeners under every primary pointer, and the catch's only where it is coarse (spec:AST-094 FR24)", () => { + const touchTypes = (spy: {mock: {calls: unknown[][]}}): string[] => + spy.mock.calls + .map((call: unknown[]) => String(call[0])) + .filter((type: string) => type.startsWith('touch')); + for (const coarse of [false, true]) { + if (coarse) { + stubCoarsePointer(); + } else { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + } + const {hook} = setup({snapHeights: () => [200]}); + const dialog = document.createElement('dialog'); + document.body.append(dialog); + const sheet = makeTarget(); + const body = document.createElement('div'); + sheet.append(body); + dialog.append(sheet); + const onBody = vi.spyOn(body, 'addEventListener'); + const onDialog = vi.spyOn(dialog, 'addEventListener'); + act(() => hook.result.current.sheetRef(sheet)); + act(() => hook.result.current.bodyProps.ref(body)); + // A touchscreen laptop's finger pulls the body as a phone's does: under + // a fine pointer a component may listen on its own element. + expect(touchTypes(onBody)).toContain('touchmove'); + if (coarse) { + expect(touchTypes(onDialog)).toContain('touchstart'); + } else { + expect(touchTypes(onDialog)).toEqual([]); + } + hook.unmount(); + } + }); + + it('leaves a drag-dismissed sheet to the host exit under a mouse, from where it was', () => { + stubPointer({coarse: false}); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const t = makeTarget(); + t.style.transitionProperty = 'transform'; + t.style.transitionDuration = '410ms'; + t.style.transitionTimingFunction = 'ease'; + down(hook, 0, 0, t); + stream(hook, t, {from: 0, to: 150, t0: 0, t1: 100}); + act(() => + hook.result.current.handleProps.onPointerUp(pointerEvent(150, 102, t)), + ); + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(hook.result.current.gestureExitRef.current).toBeNull(); + // The travel is folded into the transform the host's exit runs from. + expect(t.style.transform).toBe('translateY(150px)'); + expect(travel(t)).toBe(0); + }); + + it('reads touch velocity from the touch events, however late they are handled', () => { + const {hook, onDismiss} = setup({snapHeights: () => [200]}); + const el = document.createElement('div'); + Object.defineProperties(el, { + scrollTop: {value: 0, writable: true}, + clientHeight: {value: 200}, + scrollHeight: {value: 800}, + }); + el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect; + act(() => hook.result.current.sheetRef(el)); + act(() => hook.result.current.bodyProps.ref(el)); + const touch = (type: string, y: number, timeStamp: number) => { + const ev = new Event(type, {bubbles: true, cancelable: true}); + Object.defineProperty(ev, 'changedTouches', { + value: [{identifier: 1, clientY: y}], + }); + Object.defineProperty(ev, 'touches', { + value: type === 'touchend' ? [] : [{identifier: 1, clientY: y}], + }); + Object.defineProperty(ev, 'timeStamp', {value: timeStamp}); + el.dispatchEvent(ev); + }; + // A pull down at 1500 px/s, every event delivered in one late batch. + act(() => { + touch('touchstart', 0, 0); + for (let i = 1; i <= 6; i++) { + touch('touchmove', i * 25, (i * 100) / 6); + } + touch('touchend', 150, 101); + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); + +describe('useSheetGestures: the dim follows the sheet', () => { + // A finger: the release runs on UIKit's springs and the dim follows the + // sheet (releaseMotion.ts); under a mouse, today's dim. + beforeEach(() => stubIosIdiom()); + + interface Played { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions & {pseudoElement?: string}; + cancel: ReturnType; + } + function recordAnimations(el: HTMLElement): Played[] { + const played: Played[] = []; + el.animate = vi.fn((keyframes, options) => { + const cancel = vi.fn(); + played.push({ + keyframes: keyframes as Keyframe[], + options: options as Played['options'], + cancel, + }); + return {cancel, startTime: 0} as unknown as Animation; + }); + return played; + } + /** A played property's value `ms` after its start. */ + function valueAt( + animation: Played, + ms: number, + read: (frame: Keyframe) => number, + ): number { + const duration = animation.options.duration as number; + const frames = animation.keyframes.map(frame => ({ + t: (frame.offset as number) * duration, + v: read(frame), + })); + for (let i = 1; i < frames.length; i++) { + if (ms <= frames[i].t) { + const a = frames[i - 1]; + const b = frames[i]; + return a.v + ((b.v - a.v) * (ms - a.t)) / (b.t - a.t); + } + } + return frames[frames.length - 1].v; + } + const travelOf = (frame: Keyframe) => + Number.parseFloat(String(frame.translate).split(' ')[1]); + const opacityOf = (frame: Keyframe) => Number(frame.opacity); + // A 400px sheet with a 200px stop: offsets [0, 200], gone at 400. UIKit's + // dim is full at the shortest detent and above, linear to clear at gone. + const dimAt = (offset: number) => + Math.min(1, Math.max(0, (400 - offset) / (400 - 200))); + + /** A sheet target inside a modal dialog, whose ::backdrop is the scrim. */ + function inDialog() { + const dialog = document.createElement('dialog'); + const target = makeTarget(); + dialog.appendChild(target); + document.body.appendChild(dialog); + return {dialog, target}; + } + + it('clears the dim at the dismissed position, linear from the shortest detent', () => { + const onScrimOpacity = vi.fn(); + const {hook} = setup({snapHeights: () => [200], onScrimOpacity}); + const t = makeTarget(); + down(hook, 0, 0, t); + for (const y of [150, 200, 250, 300, 380]) { + move(hook, y, y, t); + expect(Number(onScrimOpacity.mock.calls.at(-1)?.[0])).toBeCloseTo( + dimAt(y), + 9, + ); + } + }); + + it("pins the host's easing for the gesture and hands it back when the sheet lands", async () => { + const onScrimOpacity = vi.fn(); + const onScrimTracking = vi.fn(); + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity, + onScrimTracking, + }); + const {target} = inDialog(); + recordAnimations(target); + down(hook, 0, 0, target); + expect(onScrimTracking.mock.calls).toEqual([[true]]); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + // Still the release's: the sheet is on its way to the 200 detent. + expect(onScrimTracking.mock.calls).toEqual([[true]]); + land(); + // The spring's end resolves on a microtask. + await Promise.resolve(); + expect(onScrimTracking.mock.calls).toEqual([[true], [false]]); + expect(onScrimOpacity).toHaveBeenLastCalledWith(1); + }); + + it('plays the dim on the backdrop in lockstep with the release', () => { + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + onScrimTracking: vi.fn(), + }); + const {dialog, target} = inDialog(); + const sheetAnimations = recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + expect(hook.result.current.settledOffset).toBe(200); + const sheetAnimation = sheetAnimations.at(-1) as Played; + const dim = backdrop.at(-1) as Played; + expect(dim.options.pseudoElement).toBe('::backdrop'); + // Each moment's dim is the one for where the sheet is at that moment. + for (const ms of [0, 16, 50, 100, 200, 300]) { + const offset = valueAt(sheetAnimation, ms, travelOf); + expect(valueAt(dim, ms, opacityOf)).toBeCloseTo(dimAt(offset), 2); + } + expect(valueAt(dim, 0, opacityOf)).toBeCloseTo(dimAt(290), 2); + }); + + it("plays no dim on a backdrop that isn't the visible scrim, and still releases the sheet", () => { + // A sheet stacked above another: the stack's one visible backdrop is the + // other's, and this one's stays transparent. + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + onScrimTracking: vi.fn(), + isScrimShown: () => false, + }); + const {dialog, target} = inDialog(); + const sheetAnimations = recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + expect(hook.result.current.settledOffset).toBe(200); + expect(sheetAnimations.length).toBeGreaterThan(0); + expect(backdrop).toEqual([]); + }); + + it('hands the dim back unpinned when the sheet closes during a release, writing nothing over the close', () => { + const onScrimOpacity = vi.fn(); + const onScrimTracking = vi.fn(); + const snapHeights = () => [200]; + const hook = renderHook( + ({isOpen}: {isOpen: boolean}) => + useSheetGestures({ + isOpen, + snapHeights, + onDismiss: vi.fn(), + onScrimOpacity, + onScrimTracking, + }), + {initialProps: {isOpen: true}}, + ); + const {dialog, target} = inDialog(); + recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + const dim = backdrop.at(-1) as Played; + expect(onScrimTracking).toHaveBeenLastCalledWith(true); + // Closed (Escape, back, isOpen false) before the spring lands. + act(() => { + vi.advanceTimersByTime(60); + }); + const writes = onScrimOpacity.mock.calls.length; + hook.rerender({isOpen: false}); + // At once: the release's dim stops and the host's easing is back. + expect(onScrimTracking).toHaveBeenLastCalledWith(false); + expect(dim.cancel).toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(2000); + }); + // The landing writes nothing over the host's close. + expect(onScrimOpacity.mock.calls.length).toBe(writes); + }); + + it('lets a finger catch the dim where it is', () => { + const onScrimOpacity = vi.fn(); + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity, + onScrimTracking: vi.fn(), + }); + const {dialog, target} = inDialog(); + recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + const dim = backdrop.at(-1) as Played; + // 60 ms into the settle a finger lands on the sheet. + act(() => { + vi.advanceTimersByTime(60); + }); + const expected = valueAt(dim, 60, opacityOf); + down(hook, 250, 900, target); + expect(dim.cancel).toHaveBeenCalled(); + const caught = Number(onScrimOpacity.mock.calls.at(-1)?.[0]); + expect(caught).toBeGreaterThan(dimAt(290)); + expect(caught).toBeLessThan(1); + expect(caught).toBeCloseTo(expected, 1); + }); + + it("lands a release onto the peek on the peek's lighter scrim, in lockstep", () => { + // A 400px sheet with a 300px stop and an 80px peek: offsets [0, 100, 320]. + // The dim is full down to the 100 stop, thins to the peek's floor (0.3) + // at 320, and stays there. + const {hook} = setup({ + snapHeights: () => [300, 80], + onScrimOpacity: vi.fn(), + onScrimTracking: vi.fn(), + }); + const {dialog, target} = inDialog(); + const sheetAnimations = recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 300, t0: 0, t1: 600}); + restAt(hook, 300, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(300, 800, target), + ), + ); + expect(hook.result.current.settledOffset).toBe(320); + const peekDim = (offset: number) => + offset <= 100 + ? 1 + : offset >= 320 + ? 0.3 + : 1 - (0.7 * (offset - 100)) / 220; + const sheetAnimation = sheetAnimations.at(-1) as Played; + const dim = backdrop.at(-1) as Played; + for (const ms of [0, 16, 50, 100, 200, 300]) { + const offset = valueAt(sheetAnimation, ms, travelOf); + expect(valueAt(dim, ms, opacityOf)).toBeCloseTo(peekDim(offset), 2); + } + expect(valueAt(dim, dim.options.duration as number, opacityOf)).toBeCloseTo( + 0.3, + 2, + ); + }); + + it('fades the dim to its end with the release under reduced motion, 200 ms or less', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + query.includes('pointer: coarse') || + query.includes('prefers-reduced-motion'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + onScrimTracking: vi.fn(), + }); + const {dialog, target} = inDialog(); + recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + expect(hook.result.current.settledOffset).toBe(200); + const dim = backdrop.at(-1) as Played; + // From the dim where the finger left it to the detent's, within 200 ms. + expect(valueAt(dim, 0, opacityOf)).toBeCloseTo(dimAt(290), 2); + expect(settledMs(dim)).toBeLessThanOrEqual(200.5); + expect(valueAt(dim, 200, opacityOf)).toBeCloseTo(dimAt(200), 2); + }); + + it('follows the sheet under a finger on Android too (one touch model)', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', {supports: () => false, escape: (v: string) => v}); + const onScrimTracking = vi.fn(); + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + onScrimTracking, + }); + const {dialog, target} = inDialog(); + recordAnimations(target); + const backdrop = recordAnimations(dialog); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + // Pinned, and the release's dim played on the backdrop, as on iOS. + expect(onScrimTracking).toHaveBeenCalledWith(true); + const dim = backdrop.at(-1) as Played; + expect(dim.options.pseudoElement).toBe('::backdrop'); + expect(valueAt(dim, 0, opacityOf)).toBeCloseTo(dimAt(290), 2); + }); + + it.each([['a mouse', {coarse: false}]])( + "keeps today's dim under %s: the host eases it, clear 40 %% below the shortest detent", + (_, {coarse}) => { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: coarse && query.includes('pointer: coarse'), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + vi.stubGlobal('CSS', {supports: () => false, escape: (v: string) => v}); + const onScrimOpacity = vi.fn(); + const onScrimTracking = vi.fn(); + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity, + onScrimTracking, + }); + const {dialog, target} = inDialog(); + const played = recordAnimations(dialog); + down(hook, 0, 0, target); + // 40 px below the 200 px detent: today's dim clears 80 px below it. + move(hook, 240, 500, target); + expect(onScrimOpacity).toHaveBeenLastCalledWith(expect.closeTo(0.5, 9)); + restAt(hook, 240, 600, target); + up(hook, 240, 1000, target); + // Never pinned, and no dim played on the backdrop: the host's own + // easing carries it, as today. + expect(onScrimTracking).not.toHaveBeenCalled(); + expect(played).toHaveLength(0); + expect(onScrimOpacity).toHaveBeenLastCalledWith(1); + }, + ); +}); + +describe('useSheetGestures: present and dismiss under a finger', () => { + // A finger, on any touch device: the panel calls present/dismiss under one + // (BottomSheetPanel). + function stubMedia({reducedMotion = false}: {reducedMotion?: boolean} = {}) { + stubIosIdiom({reducedMotion}); + } + beforeEach(() => { + stubMedia(); + vi.stubGlobal('innerHeight', 800); + }); + + interface Played { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions & {pseudoElement?: string}; + } + function recordAnimations(el: HTMLElement): Played[] { + const played: Played[] = []; + el.animate = vi.fn((keyframes, options) => { + played.push({ + keyframes: keyframes as Keyframe[], + options: options as Played['options'], + }); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }); + return played; + } + function valueAt( + animation: Played, + ms: number, + read: (frame: Keyframe) => number, + ): number { + const duration = animation.options.duration as number; + const frames = animation.keyframes.map(frame => ({ + t: (frame.offset as number) * duration, + v: read(frame), + })); + for (let i = 1; i < frames.length; i++) { + if (ms <= frames[i].t) { + const a = frames[i - 1]; + const b = frames[i]; + return a.v + ((b.v - a.v) * (ms - a.t)) / (b.t - a.t); + } + } + return frames[frames.length - 1].v; + } + const travelOf = (frame: Keyframe) => + Number.parseFloat(String(frame.translate ?? '0px 0px').split(' ')[1]); + const opacityOf = (frame: Keyframe) => Number(frame.opacity); + + /** A sheet resting with its top at 400 of an 800 px viewport, in a dialog. */ + function presentedSheet(options: Partial = {}) { + const onScrimOpacity = vi.fn(); + const onScrimTracking = vi.fn(); + const {hook} = setup({onScrimOpacity, onScrimTracking, ...options}); + const dialog = document.createElement('dialog'); + const target = makeTarget(); + target.getBoundingClientRect = () => + ({top: 400, height: SHEET_HEIGHT}) as DOMRect; + dialog.appendChild(target); + document.body.appendChild(dialog); + const sheetAnimations = recordAnimations(target); + const backdrop = recordAnimations(dialog); + act(() => hook.result.current.sheetRef(target)); + return { + hook, + target, + sheetAnimations, + backdrop, + onScrimOpacity, + onScrimTracking, + }; + } + + it('plans a reopen from the tallest detent, not from where the last close left the sheet', async () => { + // The last session ended at the 96 px peek (offset 304, a 0.3 scrim). + const {hook, target, sheetAnimations, backdrop} = presentedSheet({ + snapHeights: () => [96], + }); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 300, t0: 0, t1: 900}); + restAt(hook, 300, 950, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(300, 1100, target), + ), + ); + // The release lands (its completion settles in microtasks). + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(hook.result.current.settledOffset).toBe(304); + // Presented again before the reopen reset has rendered. + act(() => { + hook.result.current.present(); + }); + await act(async () => { + await Promise.resolve(); + }); + const travel = sheetAnimations.at(-1) as Played; + // From the whole sheet below the screen to the tallest detent. + expect(valueAt(travel, 0, travelOf)).toBeCloseTo(400, 0); + expect(valueAt(travel, 2000, travelOf)).toBeCloseTo(0, 0); + // The dim rises to the full scrim, not the peek's 0.3. + const dim = backdrop.at(-1) as Played; + expect(valueAt(dim, 2000, opacityOf)).toBeCloseTo(1, 2); + }); + + it('presents from below the screen on the sheet spring, its dim on the same curve', async () => { + const {hook, sheetAnimations, backdrop, onScrimTracking} = presentedSheet(); + let started = false; + act(() => { + started = hook.result.current.present(); + }); + expect(started).toBe(true); + // The dim is taken over at once, the travel once the dialog has laid out. + expect(onScrimTracking.mock.calls).toEqual([[true]]); + await act(async () => { + await Promise.resolve(); + }); + const travel = sheetAnimations.at(-1) as Played; + const curve = solveSpring(toSpring(springs.sheetPresent), { + from: 400, + to: 0, + }); + for (const {t: ms, value} of keyframesUntil(travel, travelOf, 400)) { + expect(value).toBeCloseTo(curve.value(ms / 1000), 1); + } + // The dim rises with the spring's progress, clear below the screen. + const dim = backdrop.at(-1) as Played; + expect(dim.options.pseudoElement).toBe('::backdrop'); + for (const ms of [0, 50, 100, 200, 400]) { + expect(valueAt(dim, ms, opacityOf)).toBeCloseTo( + 1 - curve.value(ms / 1000) / 400, + 2, + ); + } + act(() => { + vi.advanceTimersByTime(2000); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(onScrimTracking.mock.calls).toEqual([[true], [false]]); + }); + + it('dismisses off screen on the sheet spring, for the host to wait on', async () => { + const {hook, sheetAnimations, backdrop} = presentedSheet(); + let exit: ReturnType = null; + act(() => { + exit = hook.result.current.dismiss(); + }); + // The exit starts from rest; the dim joins its start once known. + await act(async () => { + await Promise.resolve(); + }); + expect(exit).not.toBeNull(); + expect(hook.result.current.gestureExitRef.current).toBe(exit); + const travel = sheetAnimations.at(-1) as Played; + const curve = solveSpring(toSpring(springs.sheetDismiss), { + from: 0, + to: 400, + }); + for (const {t: ms, value} of keyframesUntil(travel, travelOf, 400)) { + expect(value).toBeCloseTo(curve.value(ms / 1000), 1); + } + const dim = backdrop.at(-1) as Played; + for (const ms of [0, 50, 100, 200]) { + expect(valueAt(dim, ms, opacityOf)).toBeCloseTo( + 1 - curve.value(ms / 1000) / 400, + 2, + ); + } + // A second close asks for the same exit. + act(() => { + expect(hook.result.current.dismiss()).toBe(exit); + }); + }); + + it('fades in place under reduced motion, 200 ms or less (design:reduced-motion DR2)', async () => { + stubMedia({reducedMotion: true}); + const {hook, sheetAnimations, backdrop} = presentedSheet(); + act(() => { + hook.result.current.present(); + }); + await act(async () => { + await Promise.resolve(); + }); + const fade = sheetAnimations.find(a => + changedProperties(a).includes('opacity'), + ); + expect(fade).toBeDefined(); + const opacity = fade as Played; + expect(valueAt(opacity, 0, opacityOf)).toBeCloseTo(0, 2); + expect(valueAt(opacity, 200, opacityOf)).toBeCloseTo(1, 2); + // Nothing runs longer than 200 ms: no spring, the dim included. + for (const animation of [...sheetAnimations, ...backdrop]) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it('fades out in place under reduced motion, 200 ms or less', async () => { + stubMedia({reducedMotion: true}); + const {hook, sheetAnimations, backdrop} = presentedSheet(); + act(() => { + hook.result.current.present(); + }); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + await act(async () => { + await Promise.resolve(); + }); + const before = sheetAnimations.length; + act(() => { + hook.result.current.dismiss(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const exit = sheetAnimations + .slice(before) + .find(a => changedProperties(a).includes('opacity')); + expect(exit).toBeDefined(); + expect(valueAt(exit as Played, 0, opacityOf)).toBeCloseTo(1, 2); + for (const animation of [...sheetAnimations, ...backdrop]) { + expect(settledMs(animation)).toBeLessThanOrEqual(200.5); + } + }); + + it('leaves the entrance and exit to the stylesheet under a mouse', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {hook, sheetAnimations, onScrimTracking} = presentedSheet(); + act(() => { + expect(hook.result.current.present()).toBe(false); + expect(hook.result.current.dismiss()).toBeNull(); + }); + expect(sheetAnimations).toHaveLength(0); + expect(onScrimTracking).not.toHaveBeenCalled(); + }); + describe('during the entrance (UIKit, F4 b59)', () => { + // A sheet presented at its full height with a second stop at 200 px. + const entrance = solveSpring(toSpring(springs.sheetPresent), { + from: 400, + to: 0, + }); + const entranceMs = entrance.settleTime(0.05) * 1000; + async function startEntrance() { + const onPresentingChange = vi.fn(); + const sheet = presentedSheet({ + snapHeights: () => [200], + onPresentingChange, + }); + act(() => { + sheet.hook.result.current.present(); + }); + await act(async () => { + await Promise.resolve(); + }); + return {...sheet, onPresentingChange}; + } + async function settle(ms: number) { + act(() => { + vi.advanceTimersByTime(ms); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + } + + it('holds input from the start of the entrance until the sheet lands', async () => { + const {onPresentingChange} = await startEntrance(); + expect(onPresentingChange.mock.calls).toEqual([[true]]); + await settle(150); + expect(onPresentingChange.mock.calls).toEqual([[true]]); + await settle(entranceMs + 100); + expect(onPresentingChange.mock.calls).toEqual([[true], [false]]); + }); + + it('leaves a touch on the drawn sheet to the hold while the sheet presents', async () => { + const {hook, target} = await startEntrance(); + for (const [key, value] of Object.entries({ + offsetTop: 400, + offsetLeft: 0, + offsetWidth: 400, + offsetHeight: 400, + offsetParent: null, + })) { + Object.defineProperty(target, key, {value, configurable: true}); + } + await settle(100); + const dialog = target.closest('dialog') as HTMLDialogElement; + const y = 400 + entrance.value(0.1) + 20; + const ev = new Event('touchstart', {bubbles: true, cancelable: true}); + const point = {identifier: 9, clientX: 100, clientY: y}; + Object.defineProperty(ev, 'changedTouches', {value: [point]}); + Object.defineProperty(ev, 'touches', {value: [point]}); + act(() => { + dialog.dispatchEvent(ev); + }); + // Not caught: no drag began, the entrance runs on. + expect(hook.result.current.isDragging).toBe(false); + }); + + it('a close at 100 ms waits for the sheet to land, then dismisses from the detent', async () => { + const {hook, sheetAnimations, backdrop} = await startEntrance(); + await settle(100); + const before = sheetAnimations.length; + let exit: ReturnType = null; + act(() => { + exit = hook.result.current.dismiss(); + }); + expect(exit).not.toBeNull(); + let done = false; + void (exit as unknown as {finished: Promise}).finished.then( + () => { + done = true; + }, + ); + // No reversal: the entrance runs on. + expect(sheetAnimations).toHaveLength(before); + await settle(entranceMs); + // Landed: a full dismissal from the detent, the dim from its rest. + const leave = sheetAnimations.at(-1) as Played; + expect(sheetAnimations.length).toBeGreaterThan(before); + expect(valueAt(leave, 0, travelOf)).toBeCloseTo(0, 0); + expect( + valueAt(leave, leave.options.duration as number, travelOf), + ).toBeCloseTo(400, 0); + const curve = solveSpring(toSpring(springs.sheetDismiss), { + from: 0, + to: 400, + }); + for (const {t: ms, value} of keyframesUntil(leave, travelOf, 200)) { + expect(value).toBeCloseTo(curve.value(ms / 1000), 1); + } + const fade = backdrop.at(-1) as Played; + expect(valueAt(fade, 0, opacityOf)).toBeCloseTo(1, 2); + expect(done).toBe(false); + await settle(2000); + expect(done).toBe(true); + }); + + it('a close during a release leaves from where the sheet is, the dim from where it is', async () => { + const {hook, target, sheetAnimations, backdrop} = presentedSheet({ + snapHeights: () => [200], + }); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + const release = sheetAnimations.at(-1) as Played; + const releaseDim = backdrop.at(-1) as Played; + act(() => { + vi.advanceTimersByTime(60); + }); + const y = valueAt(release, 60, travelOf); + const dim = valueAt(releaseDim, 60, opacityOf); + act(() => { + hook.result.current.dismiss(); + }); + await act(async () => { + await Promise.resolve(); + }); + const leave = sheetAnimations.at(-1) as Played; + expect(valueAt(leave, 0, travelOf)).toBeCloseTo(y, 0); + expect(valueAt(backdrop.at(-1) as Played, 0, opacityOf)).toBeCloseTo( + dim, + 2, + ); + }); + }); +}); + +describe('useSheetGestures: a finger on the drawn sheet during a release', () => { + // WebKit (iOS Safari 26.2) hit-tests an element a compositor animation is + // moving at its un-animated position: a touch on the moving sheet arrives + // on whatever sat there when the release began (here, the dialog: the + // scrim). The hook maps it through the sheet's drawn rect instead. + beforeEach(() => stubIosIdiom()); + + interface Played { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + cancel: ReturnType; + } + function recordAnimations(el: HTMLElement): Played[] { + const played: Played[] = []; + el.animate = vi.fn((keyframes, options) => { + const cancel = vi.fn(); + played.push({ + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + cancel, + }); + return {cancel, startTime: 0} as unknown as Animation; + }); + return played; + } + function travelAt(animation: Played, ms: number): number { + const duration = animation.options.duration as number; + const frames = animation.keyframes.map(frame => ({ + t: (frame.offset as number) * duration, + v: Number.parseFloat(String(frame.translate).split(' ')[1]), + })); + for (let i = 1; i < frames.length; i++) { + if (ms <= frames[i].t) { + const a = frames[i - 1]; + const b = frames[i]; + return a.v + ((b.v - a.v) * (ms - a.t)) / (b.t - a.t); + } + } + return frames[frames.length - 1].v; + } + function touchOnDialog( + dialog: HTMLElement, + type: string, + y: number, + id = 7, + ): Event { + const ev = new Event(type, {bubbles: true, cancelable: true}); + const point = {identifier: id, clientX: 100, clientY: y}; + Object.defineProperty(ev, 'changedTouches', {value: [point]}); + Object.defineProperty(ev, 'touches', { + value: type === 'touchend' || type === 'touchcancel' ? [] : [point], + }); + dialog.dispatchEvent(ev); + return ev; + } + /** A 400px sheet whose layout box starts 300px down, in a modal dialog, released toward its 200px stop. */ + function releasing() { + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + }); + const dialog = document.createElement('dialog'); + const target = makeTarget(); + for (const [key, value] of Object.entries({ + offsetTop: 300, + offsetLeft: 0, + offsetWidth: 400, + offsetHeight: 400, + offsetParent: null, + })) { + Object.defineProperty(target, key, {value, configurable: true}); + } + dialog.appendChild(target); + document.body.appendChild(dialog); + const sheetAnimations = recordAnimations(target); + recordAnimations(dialog); + act(() => hook.result.current.sheetRef(target)); + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + act(() => + hook.result.current.handleProps.onPointerUp( + pointerEvent(290, 800, target), + ), + ); + const release = sheetAnimations.at(-1) as Played; + act(() => { + vi.advanceTimersByTime(60); + }); + // Where the sheet is drawn 60 ms into its release. + const y = travelAt(release, 60); + return {hook, dialog, target, release, y, drawnTop: 300 + y}; + } + + it('catches the sheet at touch-down when the touch lands on the scrim inside the drawn sheet', () => { + const {hook, dialog, release, y, drawnTop} = releasing(); + expect(hook.result.current.settledOffset).toBe(200); + act(() => { + touchOnDialog(dialog, 'touchstart', drawnTop + 20); + }); + // Caught where it is drawn: the release is abandoned in place. + expect(release.cancel).toHaveBeenCalled(); + expect(hook.result.current.activeOffsetRef.current).toBeCloseTo(y, 0); + // The finger drives it from there. + act(() => { + touchOnDialog(dialog, 'touchmove', drawnTop + 40); + }); + expect(hook.result.current.activeOffsetRef.current).toBeCloseTo(y + 20, 0); + act(() => { + touchOnDialog(dialog, 'touchend', drawnTop + 40); + }); + // The tap's click on the stale target is not a scrim tap. + const click = new MouseEvent('click', {bubbles: true, cancelable: true}); + const onClick = vi.fn(); + document.addEventListener('click', onClick); + dialog.dispatchEvent(click); + document.removeEventListener('click', onClick); + expect(click.defaultPrevented).toBe(true); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('leaves a touch above the drawn sheet to the scrim', () => { + const {hook, dialog, release, drawnTop} = releasing(); + act(() => { + touchOnDialog(dialog, 'touchstart', drawnTop - 30); + touchOnDialog(dialog, 'touchend', drawnTop - 30); + }); + expect(release.cancel).not.toHaveBeenCalled(); + const click = new MouseEvent('click', {bubbles: true, cancelable: true}); + dialog.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + expect(hook.result.current.settledOffset).toBe(200); + }); + + it('leaves touches alone when nothing travels', () => { + const {hook} = setup({snapHeights: () => [200]}); + const dialog = document.createElement('dialog'); + const target = makeTarget(); + Object.defineProperty(target, 'offsetHeight', {value: 400}); + dialog.appendChild(target); + document.body.appendChild(dialog); + act(() => hook.result.current.sheetRef(target)); + act(() => { + touchOnDialog(dialog, 'touchstart', 10); + touchOnDialog(dialog, 'touchend', 10); + }); + const click = new MouseEvent('click', {bubbles: true, cancelable: true}); + dialog.dispatchEvent(click); + expect(click.defaultPrevented).toBe(false); + expect(hook.result.current.activeOffsetRef.current).toBe(0); + }); +}); + +describe('useSheetGestures: the release storyboards (interactive-transition driver)', () => { + // Frame-by-frame values of each release: the sheet's travel and the dim on + // the ::backdrop, every 20 ms, against a fixture recorded from the release + // code before it moved onto the interactive-transition driver + // (motion/interactive.ts). UPDATE_STORYBOARDS=1 rewrites the fixture. + interface Played { + keyframes: Keyframe[]; + options: KeyframeAnimationOptions & {pseudoElement?: string}; + } + /** Per scenario: every curve played on the sheet and on its ::backdrop, sampled. */ + type Storyboard = Record; + const FIXTURE = join(__dirname, '__fixtures__/release-storyboards.json'); + const STEP_MS = 20; + const SPAN_MS = 800; + const recorded: Storyboard = {}; + + function recordAnimations(el: HTMLElement): Played[] { + const played: Played[] = []; + el.animate = vi.fn((keyframes, options) => { + played.push({ + keyframes: keyframes as Keyframe[], + options: options as Played['options'], + }); + return {cancel: vi.fn(), startTime: 0} as unknown as Animation; + }); + return played; + } + function valueAt( + animation: Played, + ms: number, + read: (frame: Keyframe) => number, + ): number { + const duration = animation.options.duration as number; + const frames = animation.keyframes.map(frame => ({ + t: (frame.offset as number) * duration, + v: read(frame), + })); + for (let i = 1; i < frames.length; i++) { + if (ms <= frames[i].t) { + const a = frames[i - 1]; + const b = frames[i]; + return b.t > a.t ? a.v + ((b.v - a.v) * (ms - a.t)) / (b.t - a.t) : b.v; + } + } + return frames[frames.length - 1].v; + } + const travelOf = (frame: Keyframe) => + Number.parseFloat(String(frame.translate ?? '0px 0px').split(' ')[1]); + const opacityOf = (frame: Keyframe) => Number(frame.opacity); + const samples = (animation: Played, read: (f: Keyframe) => number) => + Array.from( + {length: SPAN_MS / STEP_MS + 1}, + (_, i) => Math.round(valueAt(animation, i * STEP_MS, read) * 1000) / 1000, + ); + // A curve, not a one-step write: more than two keyframes. + const curves = (played: Played[]) => + played.filter(p => p.keyframes.length > 2); + const readOf = (animation: Played) => + animation.keyframes[0].translate != null ? travelOf : opacityOf; + + function scenario( + name: string, + gesture: (hook: Hook, target: HTMLElement) => void, + {pointer = 'touch'}: {pointer?: 'touch' | 'mouse' | 'touch-rm'} = {}, + ) { + it(name, () => { + if (pointer === 'mouse') { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + } else { + stubIosIdiom({reducedMotion: pointer === 'touch-rm'}); + } + const {hook} = setup({ + snapHeights: () => [200], + onScrimOpacity: vi.fn(), + onScrimTracking: vi.fn(), + }); + const dialog = document.createElement('dialog'); + const target = makeTarget(); + dialog.appendChild(target); + document.body.appendChild(dialog); + if (pointer === 'mouse') { + // The theme timing a mouse release reads off the stylesheet, set as + // longhands: the test DOM does not expand the `transition` shorthand, + // so with it the release read no duration and played no curve. + target.style.transitionProperty = 'transform'; + target.style.transitionDuration = '410ms'; + target.style.transitionTimingFunction = 'cubic-bezier(0.2, 0, 0, 1)'; + } + const sheet = recordAnimations(target); + const backdrop = recordAnimations(dialog); + gesture(hook, target); + // Every scenario plays a curve on the sheet: a storyboard with none + // could not fail. + expect(curves(sheet).length).toBeGreaterThan(0); + recorded[name] = { + sheet: curves(sheet).map(animation => + samples(animation, readOf(animation)), + ), + backdrop: curves(backdrop).map(animation => + samples(animation, opacityOf), + ), + }; + dialog.remove(); + }); + } + + // A slow drag from tall to 2/3 of the way to the 200 detent: settles there. + scenario('slow settle onto the detent', (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 140, t0: 0, t1: 700}); + restAt(hook, 140, 720, target); + up(hook, 140, 760, target); + }); + // A throw: past 1008 px/s, the flick branch onto the next detent. + scenario('flick onto the next detent', (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 80, t0: 0, t1: 60}); + up(hook, 80, 62, target); + }); + // Below the 200 detent and back: the dim rides the spring back. + scenario('spring back from below the detent', (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 290, t0: 0, t1: 600}); + restAt(hook, 290, 650, target); + up(hook, 290, 800, target); + }); + // A flick down from the 200 detent: the release carries it off screen. + scenario('flick dismissal', (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 200, t0: 0, t1: 600}); + restAt(hook, 200, 650, target); + up(hook, 200, 800, target); + land(); + down(hook, 200, 2000, target); + stream(hook, target, {from: 200, to: 300, t0: 2000, t1: 2060}); + up(hook, 300, 2062, target); + }); + // A moderate throw up onto the tallest detent: the overshoot cap. + scenario('throw up onto the tallest detent', (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 200, t0: 0, t1: 600}); + restAt(hook, 200, 650, target); + up(hook, 200, 800, target); + land(); + down(hook, 200, 2000, target); + stream(hook, target, {from: 200, to: 120, t0: 2000, t1: 2080}); + up(hook, 120, 2082, target); + }); + // A mouse: today's rule on the theme's timing. + scenario( + 'mouse release on the theme timing', + (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 140, t0: 0, t1: 700}); + restAt(hook, 140, 720, target); + up(hook, 140, 760, target); + }, + {pointer: 'mouse'}, + ); + // Reduced motion under a finger: the release cross-fades in place. + scenario( + 'reduced-motion release', + (hook, target) => { + down(hook, 0, 0, target); + stream(hook, target, {from: 0, to: 140, t0: 0, t1: 700}); + restAt(hook, 140, 720, target); + up(hook, 140, 760, target); + }, + {pointer: 'touch-rm'}, + ); + + afterAll(async () => { + if (process.env.UPDATE_STORYBOARDS === '1') { + // Written as the repository formats JSON, so the fixture stays stable. + const {format, resolveConfig} = await import('prettier'); + const options = (await resolveConfig(FIXTURE)) ?? {}; + mkdirSync(dirname(FIXTURE), {recursive: true}); + writeFileSync( + FIXTURE, + await format(JSON.stringify(recorded), {...options, parser: 'json'}), + ); + } + }); + + it('matches the storyboards recorded before the driver (within 0.5 px and 0.005 opacity)', () => { + if (process.env.UPDATE_STORYBOARDS === '1') { + // Recording: afterAll writes the fixture from this run. + return; + } + // A missing fixture fails: the proof is against curves recorded before + // the move, never against this run's own. + expect( + existsSync(FIXTURE), + 'the storyboard fixture is missing (UPDATE_STORYBOARDS=1 records it)', + ).toBe(true); + const fixture = JSON.parse(readFileSync(FIXTURE, 'utf8')) as Storyboard; + expect(Object.keys(recorded).sort()).toEqual(Object.keys(fixture).sort()); + for (const [name, before] of Object.entries(fixture)) { + const after = recorded[name]; + for (const layer of ['sheet', 'backdrop'] as const) { + expect(after[layer].length, `${name}: ${layer} curves`).toBe( + before[layer].length, + ); + before[layer].forEach((curve, k) => { + // Travel in px, opacity in 0–1. + const tolerance = + Math.max(...curve.map(Math.abs)) > 1.5 ? 0.5 : 0.005; + curve.forEach((value, i) => { + expect( + Math.abs(after[layer][k][i] - value), + `${name}: ${layer} curve ${k} at ${i * STEP_MS} ms`, + ).toBeLessThanOrEqual(tolerance); + }); + }); + } + } + }); +}); diff --git a/packages/core/src/BottomSheet/useSheetGestures.ts b/packages/core/src/BottomSheet/useSheetGestures.ts index b012cf9e847a1..c1d37ee033a36 100644 --- a/packages/core/src/BottomSheet/useSheetGestures.ts +++ b/packages/core/src/BottomSheet/useSheetGestures.ts @@ -4,16 +4,19 @@ /** * @file useSheetGestures.ts - * @input Uses React (useCallback, useEffect, useMemo, useRef, useState) + * @input Uses React (useCallback, useEffect, useMemo, useRef, useState) and + * the core useMediaQuery hook * @output Exports useSheetGestures hook and its option/result types * @position Internal to BottomSheet; not exported from the core entry point * * Drag + snap machinery for the bottom sheet. Tracks a pointer drag down the - * block axis, translates the sliding surface live, and on release settles to - * the detent nearest where the sheet would coast to at the finger's speed, or - * dismisses when that is past the dismiss line. A slow drag projects onto - * itself and places; a throw continues past the finger. This is the core - * behavior split the sheet needs: DRAG places, SWIPE closes. + * block axis, moves the sliding surface live, and on release does what + * UISheetPresentationController does (sheetRelease.ts): it settles on the + * position nearest where the release projects at the finger's velocity, the + * dismissed position counting as one, on UIKit's release spring handed that + * velocity. A slow drag projects onto itself and places; a throw continues + * past the finger. This is the core behavior split the sheet needs: DRAG + * places, SWIPE closes. * * A settled detent is split across two properties: `settledLayoutOffset` is * the part the scrolling area gives up as layout height, and the remainder is @@ -23,13 +26,24 @@ * only a sliver of the sheet — keeps the full height and slides below the * viewport instead of reflowing to that sliver. * - * A live drag writes that transform straight to the sheet element, once per - * input sample, and renders nothing: React state changes when the drag begins - * and ends, and in between only when the layout split changes (a drag that - * crosses above its base detent restores the full height). The live offset - * is readable through `activeOffsetRef`. The host owns the element's - * transform outside a drag and writes the resting value after each commit, - * so a settle animates from wherever the finger left the sheet. + * The host owns the element's `transform` and writes the resting value after + * each commit. A gesture moves the sheet on the separate `translate` channel, + * relative to that resting transform, through the motion foundation's + * animator: a drag sets it once per input sample and renders nothing (React + * state changes when the drag begins and ends, and in between only when the + * layout split changes: a drag that crosses above its base detent restores + * the full height), and the release springs it on the compositor, as Web + * Animations baked from the spring, so the settle keeps its pace while the + * main thread is busy. A finger landing on a settling sheet catches it where + * the curve says it is. When the settle lands, the host writes the new + * resting transform and `translate` returns to zero in the same commit as + * the layout reconcile. The live offset is readable through + * `activeOffsetRef`. Under a mouse the release keeps the theme's duration + * and easing instead of UIKit's springs (releaseMotion.ts), on the same + * channel. Under a finger the host also presents and dismisses the sheet on + * that channel (`present` / `dismiss`): UIKit's sheet spring from below the + * screen and back, the dim on the same spring's progress; under reduced + * motion a fade in place (design:reduced-motion). * * Those are pixels, and the stops behind them are relative to the viewport, so * a sheet at rest re-resolves its detent on `resize` / `orientationchange`, @@ -63,11 +77,15 @@ * intentionally not exported. * * SSR-safe: no window/document access at module scope; all measurement - * happens inside handlers. Respects prefers-reduced-motion by skipping the - * settle transition. + * happens inside handlers. Under prefers-reduced-motion every release and + * spring-back follows design:reduced-motion: the animator's own cross-fade + * replaces the travel (opacity only, 200 ms or less after a touch release or + * on a touch screen; at once with a mouse). * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/useSheetGestures.test.ts + * - /packages/core/src/BottomSheet/sheetRelease.ts + * - /packages/core/src/BottomSheet/releaseMotion.ts */ import { @@ -84,11 +102,39 @@ import { type TouchEvent as ReactTouchEvent, type UIEvent as ReactUIEvent, } from 'react'; - +import {VelocityTracker} from '../gestures/VelocityTracker'; +import {playHaptic} from '../gestures/haptics'; +import {readTouchPresentation} from './sheetTouch'; +import {lockSheetSelection} from './sheetSelectionLock'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; +import {useMediaQuery} from '../hooks'; +import { + createAnimator, + type Animator, + type MotionHandle, +} from '../motion/animator'; +import {rubberBand} from '../motion/rubberBand'; +import type {SpringDescription} from '../motion/spring'; +import {sheet as sheetTable} from '../motion/uikit/sheet'; +import { + SHEET_DETENT_SPRING, + SHEET_DISMISS_SPRING, + SHEET_PRESENT_SPRING, +} from '../motion/uikit/springs'; +import { + createInteractiveTransition, + type MapLayer, + type ReleaseRule, +} from '../motion/interactive'; +import {readReleaseMotion, type ReleaseMotion} from './releaseMotion'; +import { + DEFAULT_DISMISS_OVERSHOOT_RATIO, + limitOvershoot, + sheetReleaseRule, +} from './sheetRelease'; import { computeDetentOffsets, peekOffsetFor, - resolveSettleOffset, scrimOpacityForOffset, } from './snapOffsets'; @@ -101,25 +147,54 @@ interface SheetDetents { peekOffset: number | null; } -// A release is judged where the sheet WOULD come to rest, not where the -// finger left it: the release position plus the distance a surface moving at -// the finger's speed travels while it decelerates. The rate is UIKit's normal -// scroll deceleration (0.998 per ms), so a throw ends where a thrown scroll -// view would, and a slow release projects onto itself. A gesture shorter than -// the travel floor projects nothing: a nudge has no throw to continue. -const DECELERATION_RATE = 0.998; -const PROJECTION_FACTOR = DECELERATION_RATE / (1 - DECELERATION_RATE); // ms -const PROJECTION_MIN_TRAVEL = 48; // px traveled during the gesture -// The release speed is the finger's speed over its last stretch, not between -// its last two samples: one slow sample before the lift must not cancel a -// throw, and a finger that paused before lifting has no speed at all. -const VELOCITY_WINDOW_MS = 100; -// On a slow drag below the shortest detent, dismiss once dragged past it by -// more than this fraction of that detent's height; otherwise snap back to it. -const DISMISS_OVERSHOOT_RATIO = 0.4; -// Within this many px of a detent, the live drag is magnetically eased toward -// it so it "clicks" into place instead of hovering just off the mark. -const MAGNET_RANGE = 40; +// The sheet's moves run on the interactive-transition driver +// (motion/interactive.ts): its travel channel drives, and the dim on the +// dialog's ::backdrop is a layer mapped from it. The sheet decides its own +// targets (sheetRelease.ts) and plays them with `to`, so the driver's own +// release rule is never consulted. +const settleOnly: ReleaseRule<'settle'> = ({position}) => ({ + target: position, + motion: SHEET_DETENT_SPRING, + outcome: 'settle', +}); + +/** The handle of a move the driver settled at once, playing nothing. */ +function settledHandle(finished: Promise): MotionHandle { + const handle: MotionHandle = { + properties: [], + finished, + started: Promise.resolve(performance.now()), + value: () => 0, + velocity: () => 0, + // It played nothing: no curve for a companion to follow. + curve: () => null, + retarget: () => handle, + stop: () => {}, + }; + return handle; +} + +// Gone: the sheet's top at the bottom of the screen. A release under a finger +// can carry the sheet there (sheetRelease.ts). +function dismissedOffsetFor( + maxOffset: number, + shortestDetentHeight: number, +): number { + return maxOffset + shortestDetentHeight; +} +// Where the scrim reaches clear. Under a finger, gone: UIKit's dim is linear +// from the shortest detent to the dismissed position. Under a mouse, today's +// dismiss line: DEFAULT_DISMISS_OVERSHOOT_RATIO of the shortest detent's +// height below it. +function scrimClearOffset( + maxOffset: number, + shortestDetentHeight: number, + isUIKit: boolean, +): number { + return isUIKit + ? dismissedOffsetFor(maxOffset, shortestDetentHeight) + : maxOffset + shortestDetentHeight * DEFAULT_DISMISS_OVERSHOOT_RATIO; +} // The touch path drives the sheet through the same machinery the pointer path // uses, by handing it a pointer-shaped object built from a `Touch`. On iOS // Safari that object is indistinguishable from the real thing: WebKit raises @@ -133,13 +208,6 @@ function isSyntheticTouch(event: ReactPointerEvent): boolean { return (event as SyntheticTouchPointer).syntheticTouch === true; } -// Rubber-band factor for dragging up past fully-open, capped at OVERSCROLL_MAX -// (the sheet reserves that much bottom padding for the lift to reveal). -const OVERSCROLL_RESISTANCE = 0.35; -// SYNC: must match OVERSCROLL_PADDING in BottomSheetPanel.tsx (the reserved -// bottom padding the lift reveals). Kept as a local const rather than a shared -// import so it can be used inside stylex.create there. -const OVERSCROLL_MAX = 48; // Travel past the point where a scrolling gesture ran out of content before it // hands the sheet the rest of the pull. Small enough to feel continuous with // the scroll, large enough that a swipe merely coming to rest on the last pixel @@ -159,45 +227,73 @@ const CONTENT_END_HANDOFF_SLOP = 4; // 8px is the conventional tap slop, a shade over iOS's own recognizer, and // well under what a real pull covers in its first frames. const DRAG_PROMOTION_SLOP = 8; +// Today's drag (a mouse anywhere, and anything where the primary pointer is +// fine) keeps the sheet's pointer behavior (spec:AST-017 FR59): within this +// many px of a detent the live drag eases toward it, so it "clicks" into place +// instead of hovering just off the mark. Under UIKit's presentation the sheet +// tracks 1:1, as UIKit's does. +const MAGNET_RANGE = 40; +// Rubber-band factor for today's drag up past fully open. Under UIKit's +// presentation the stretch is UIKit's rubber band instead (spec:AST-094 FR3). +const OVERSCROLL_RESISTANCE = 0.35; +// How far any drag may lift the sheet past fully open: the bottom padding the +// sheet reserves for the lift to reveal. +// SYNC: must match OVERSCROLL_PADDING in BottomSheetPanel.tsx. +const OVERSCROLL_MAX = 48; -// Short haptic tick on detent settle where supported. iOS Safari doesn't -// expose navigator.vibrate, so this is a no-op there; skipped under -// reduced-motion. -function hapticTick(): void { - if ( - typeof navigator === 'undefined' || - typeof navigator.vibrate !== 'function' - ) { - return; - } - if (prefersReducedMotion()) { - return; - } - navigator.vibrate(8); -} - -// Pull a value toward the nearest of `targets` when within MAGNET_RANGE, easing -// the last stretch so the surface settles crisply onto a detent while dragging. -function magnetize(value: number, targets: number[]): number { +// Pull a value toward the nearest of `targets` when within MAGNET_RANGE, +// easing the last stretch so the surface settles crisply onto a detent while +// dragging. +function magnetize(value: number, targets: ReadonlyArray): number { let nearestTarget = targets[0]; let nearestDist = Math.abs(value - nearestTarget); - for (const t of targets) { - const d = Math.abs(value - t); - if (d < nearestDist) { - nearestDist = d; - nearestTarget = t; + for (const target of targets) { + const distance = Math.abs(value - target); + if (distance < nearestDist) { + nearestDist = distance; + nearestTarget = target; } } if (nearestDist >= MAGNET_RANGE) { return value; } - // Ease-in over the range: pull grows as you approach (t^2), so the click - // feels magnetic near the detent but doesn't fight a deliberate drag-through. + // Ease-in over the range: the pull grows as the drag nears the detent + // (t^2), so it feels magnetic close by without fighting a drag-through. const t = nearestDist / MAGNET_RANGE; const pull = 1 - t * t; return value + (nearestTarget - value) * pull; } +// The live offset for a mouse or pen drag at `raw` (spec:AST-017 FR59): a +// damped, capped stretch up past fully open, 1:1 in the dismiss zone, +// magnetic between detents. +function pointerDragOffset( + raw: number, + offsets: ReadonlyArray, +): number { + if (raw < 0) { + return Math.max(-OVERSCROLL_MAX, raw * OVERSCROLL_RESISTANCE); + } + if (raw > offsets[offsets.length - 1]) { + return raw; + } + return magnetize(raw, offsets); +} + +// The live offset for a finger's drag at `raw`: 1:1 between detents and into +// the dismiss zone, as UIKit's sheet tracks (the release picks the detent), +// and past fully open UIKit's rubber band over the sheet's height +// (spec:AST-094 FR3), no farther than the reserve. +function fingerDragOffset(raw: number, height: number): number { + if (raw >= 0) { + return raw; + } + return Math.max( + -OVERSCROLL_MAX, + rubberBand(raw, height, sheetTable.rubberBand.coefficient), + ); +} + function preservationInsetForOffset( baseOffset: number, targetOffset: number, @@ -335,12 +431,32 @@ export interface UseSheetGesturesOptions { onSnap?: (heightPx: number) => void; /** * Called with the scrim opacity the sheet should show (1 = fully visible, - * 0 = hidden) as the drag moves and on settle. Full while the sheet is at - * or above its mid detent, fading to 0 as it collapses onto the shortest - * "peek" detent — thinning to a faint glance state without fully clearing — and - * on the dismiss overshoot. Lets the owner mirror it onto the scrim. + * 0 = hidden) as the drag moves and on settle. Full at the shortest working + * detent and above; below it, linear in the sheet's position to 0 at the + * dismissed position (UIKit's dim), or to a faint glance state at a "peek" + * detent. Lets the owner mirror it onto the scrim. */ onScrimOpacity?: (opacity: number) => void; + /** + * Given by a host whose scrim is a modal dialog's `::backdrop`. While true, + * the dim follows the sheet with no easing of its own: a gesture writes it + * at once, and a release plays it on the compositor in lockstep with the + * sheet. False hands it back, at the value last written. + */ + onScrimTracking?: (tracking: boolean) => void; + /** + * Given with `onScrimTracking`: whether the dialog's `::backdrop` is the + * visible scrim at this moment. A stack shows one backdrop: a sheet stacked + * above another keeps a transparent one, and a sheet whose scrim another + * took over drops it, so neither plays the dim there. Omitted: always. + */ + isScrimShown?: () => boolean; + /** + * True while the sheet presents under a finger (`present`), false once it + * lands. UIKit drops every touch that begins during a sheet's presentation, + * for its whole life (F4 b59): the host holds input meanwhile. + */ + onPresentingChange?: (presenting: boolean) => void; } export interface SheetContentProps { @@ -370,6 +486,17 @@ export interface SheetBodyProps { onScroll: (event: ReactUIEvent) => void; } +/** + * A drag-dismissed sheet on its way off screen. The release spring carries it + * on the travel channel at the finger's velocity, so the host keeps + * `transform` as given (the resting value, which also holds off a stylesheet + * exit transform) and ends its exit when `finished` settles. + */ +export interface SheetGestureExit { + finished: Promise; + transform: string; +} + export interface UseSheetGesturesResult { /** * Callback ref for the sheet surface. The hook observes it (ResizeObserver) @@ -427,17 +554,52 @@ export interface UseSheetGesturesResult { sheetHeight: number; /** End padding that preserves the scroll position across height changes. */ scrollPreservationInset: number; - /** Layout offset retained until the transform-only snap finishes. */ + /** + * Layout offset retained while a release spring runs; null at rest. The + * host leaves the resting transform alone until it clears. + */ settlingLayoutOffset: number | null; /** - * Reconciles the final layout once the transform-only snap finishes. The - * panel decides WHEN a snap is over (it owns the element and its computed - * transition), so completion cannot be driven from a `transitionend` - * listener alone: with transitions disabled — inline `transition: none`, a - * `0s` duration token, a test harness turning animation off — no event ever - * arrives and the scroll area would keep its full height forever. + * Reconciles the final layout once a release spring lands. The hook calls + * it itself when the spring's curve ends (a timer on the curve's length, + * not an animation event, so it lands with animations disabled too). */ completeScrollAreaSettle: () => void; + /** + * Set while a drag-dismissed sheet is carried off screen by its release + * spring; null otherwise. + */ + gestureExitRef: RefObject; + /** + * Call right after writing a resting transform to the sheet: folds the + * gesture's travel into it. A no-op while travel is animating. + */ + resetTravel: () => void; + /** + * Presents the sheet the way UIKit does under a finger: from below the + * screen to its detent on `SHEET_PRESENT_SPRING`, the dim on the same + * curve (under reduced motion, a fade in place). Call from an effect + * as the sheet opens, before its dialog is shown (a child's effect runs + * before its host's): the dim is taken over at once, and the travel starts + * in a microtask, once every effect has run and the dialog has laid out. + * False under a mouse, whose entrance stays the stylesheet's. + */ + present: () => boolean; + /** + * Calls `callback` once the entrance `present` started has landed or + * ended, at once when none is running; returns its cancel. A sheet that + * replaces another under a finger arrives on the present spring, and the + * handoff waits for it here before the replaced sheet fades. + */ + afterEntrance: (callback: () => void) => () => void; + /** + * Dismisses the sheet the way UIKit does under a finger: off screen on + * `SHEET_DISMISS_SPRING`, the dim on the same curve. During the entrance it + * waits for the sheet to land, then runs from the detent, as UIKit queues + * it. Sets and returns `gestureExitRef`'s exit for the host to wait on; + * null under a mouse. + */ + dismiss: () => SheetGestureExit | null; /** * How much of `settledOffset` is expressed as layout height rather than as a * transform. Equals `settledOffset` at the resizing detents; 0 at fully open @@ -446,13 +608,6 @@ export interface UseSheetGesturesResult { settledLayoutOffset: number; } -function prefersReducedMotion(): boolean { - if (typeof window === 'undefined' || !window.matchMedia) { - return false; - } - return window.matchMedia('(prefers-reduced-motion: reduce)').matches; -} - /** * Drag + snap machinery for the bottom sheet. Returns props to spread on the * grab handle and the sliding surface, plus live drag state. A slow drag @@ -481,6 +636,9 @@ export function useSheetGestures({ snapHeights, onSnap, onScrimOpacity, + onScrimTracking, + isScrimShown, + onPresentingChange, }: UseSheetGesturesOptions): UseSheetGesturesResult { const [dragOffset, setDragOffset] = useState(0); const [settledOffset, setSettledOffset] = useState(0); @@ -582,11 +740,7 @@ export function useSheetGestures({ targetLayoutOffset, naturalEndGap, ); - if ( - shouldAnimate && - Math.abs(renderedOffset - targetOffset) > 0.5 && - !prefersReducedMotion() - ) { + if (shouldAnimate && Math.abs(renderedOffset - targetOffset) > 0.5) { pendingScrollPreservationInsetRef.current = targetInset; settlingLayoutOffsetRef.current = layoutOffset; setSettlingLayoutOffset(layoutOffset); @@ -597,7 +751,7 @@ export function useSheetGestures({ setSettlingLayoutOffset(null); // Released on the detent, so there is no travel left to animate — but // the layout split may still differ from the one the drag rendered - // with (magnetize() lands a slow drag exactly on a detent). Swapping + // with (a drag that ends exactly on a detent). Swapping // height for transform is only invisible while transitions are off: // with them live, the composited transform would animate the whole // swap while the layout height jumped, throwing the sheet the wrong @@ -630,13 +784,133 @@ export function useSheetGestures({ const canDismissRef = useRef(canDismiss); const onSnapRef = useRef(onSnap); const onScrimOpacityRef = useRef(onScrimOpacity); + const onScrimTrackingRef = useRef(onScrimTracking); + const isScrimShownRef = useRef(isScrimShown); + const onPresentingChangeRef = useRef(onPresentingChange); useEffect(() => { onDismissRef.current = onDismiss; canDismissRef.current = canDismiss; onSnapRef.current = onSnap; onScrimOpacityRef.current = onScrimOpacity; + onScrimTrackingRef.current = onScrimTracking; + isScrimShownRef.current = isScrimShown; + onPresentingChangeRef.current = onPresentingChange; }); + // The scrim, while a gesture or its release moves it (`onScrimTracking`): + // the release plays the dim on the dialog's ::backdrop with the same clock + // as the sheet, each frame's value read from where the sheet is on its + // curve, as UIKit recomputes its dim from position every frame. + const scrimAnimatorRef = useRef(null); + const isScrimTrackingRef = useRef(false); + // Whether the dim playing is a release's, which a close takes back. + const scrimOwnerRef = useRef<'release' | null>(null); + // Stops a release's dim where it is, and says where that was. + const stopScrimFollow = useCallback((): number | null => { + const scrim = scrimAnimatorRef.current; + if (scrim == null) { + return null; + } + const current = scrim.get('opacity'); + scrim.dispose(); + scrimAnimatorRef.current = null; + return current; + }, []); + // A gesture takes the dim: a release's dim still playing is picked up + // where it is, and the host stops easing it. Only under a finger (every + // touch device: one touch model), whose dimming view follows the sheet frame + // by frame; under a mouse the host keeps easing the dim toward each value, + // as today. + const beginScrimTracking = useCallback(() => { + if (!readTouchPresentation().isTouch) { + return; + } + scrimOwnerRef.current = null; + const current = stopScrimFollow(); + if (current != null) { + onScrimOpacityRef.current?.(current); + } + if (!isScrimTrackingRef.current && onScrimTrackingRef.current != null) { + isScrimTrackingRef.current = true; + onScrimTrackingRef.current(true); + } + }, [stopScrimFollow]); + // The motion is over: the host gets the dim back at its final value. + const endScrimTracking = useCallback( + (dim: number) => { + scrimOwnerRef.current = null; + stopScrimFollow(); + onScrimOpacityRef.current?.(dim); + if (isScrimTrackingRef.current) { + isScrimTrackingRef.current = false; + onScrimTrackingRef.current?.(false); + } + }, + [stopScrimFollow], + ); + // The sheet is closing: the host gets the dim back as it is, unpinned, so + // its own close fade runs; nothing is written over it. + const releaseScrimTracking = useCallback(() => { + scrimOwnerRef.current = null; + stopScrimFollow(); + if (isScrimTrackingRef.current) { + isScrimTrackingRef.current = false; + onScrimTrackingRef.current?.(false); + } + }, [stopScrimFollow]); + // The dim's own animator on the dialog's ::backdrop, for a release or a + // presentation to play the dim on as a layer of the sheet's travel (the + // interactive-transition driver): only while the dim is tracked, and a + // fresh one each time, so a release's dim never outlives its run. + const scrimLayerAnimator = useCallback((): Animator | null => { + const dialog = sheetElRef.current?.closest('dialog'); + // Only on the stack's visible backdrop: a transparent one stays clear. + if ( + !isScrimTrackingRef.current || + dialog == null || + isScrimShownRef.current?.() === false + ) { + return null; + } + stopScrimFollow(); + const scrim = createAnimator(dialog, {pseudoElement: '::backdrop'}); + scrimAnimatorRef.current = scrim; + return scrim; + }, [stopScrimFollow]); + // The dim map of the last release, for a spring-back that interrupts it. + const lastDimForRef = useRef<((offset: number) => number) | null>(null); + + // The entrance under a finger (`present`): while it runs the host holds + // input, and a close waits for it (see `dismiss`). + const presentingRef = useRef(false); + const entranceRef = useRef | null>(null); + const [entranceWaiters] = useState(() => new Set<() => void>()); + const endPresenting = useCallback(() => { + entranceRef.current = null; + if (presentingRef.current) { + presentingRef.current = false; + onPresentingChangeRef.current?.(false); + } + const waiters = [...entranceWaiters]; + entranceWaiters.clear(); + for (const waiter of waiters) { + waiter(); + } + }, [entranceWaiters]); + const afterEntrance = useCallback( + (callback: () => void) => { + if (!presentingRef.current) { + callback(); + return () => {}; + } + entranceWaiters.add(callback); + return () => { + entranceWaiters.delete(callback); + }; + }, + [entranceWaiters], + ); + // Live drag bookkeeping (refs so pointermove doesn't churn renders). const dragStateRef = useRef<{ // Whether the touch path drives this drag. Such a drag holds no pointer @@ -647,17 +921,20 @@ export function useSheetGestures({ pointerType: string; pointerId: number; startCoord: number; - lastCoord: number; - lastTime: number; - // The finger's recent positions, oldest first, kept to VELOCITY_WINDOW_MS - // (and never fewer than two) for the release speed. - samples: {t: number; y: number}[]; - velocity: number; + // The finger's positions on the timestamps their events carry, for the + // release velocity (the gesture foundation's estimator, UIKit's filter). + tracker: VelocityTracker; height: number; baseOffset: number; baseLayoutOffset: number; renderedOffset: number; + // The layout split the drag asks the host for (setDragOffset) ... layoutOffset: number; + // ... and the one the host has rendered: the travel is always written + // against what is on screen, so a split that changes mid-drag (crossing + // above the base restores the full height) lands in the same commit as + // the travel that pairs with it, never a frame before it. + committedLayoutOffset: number; naturalEndGap: number; hasTraveled: boolean; } | null>(null); @@ -696,11 +973,37 @@ export function useSheetGestures({ previousHeight === renderedHeight ? previousHeight : renderedHeight, ); }, []); + // The gesture's share of the sheet's travel, on the `translate` channel: + // the motion foundation's animator, attached to the sheet by sheetRef. Zero + // at rest, and measured from the resting transform the host writes + // (`travelBaseRef`), so the two add up to the offset less the layout split. + const animatorRef = useRef(null); + animatorRef.current ??= createAnimator(null); + const animator = animatorRef.current; + const travelBaseRef = useRef(0); + // The layout offset the running travel is measured from (a release keeps + // the drag's base layout until it lands, while settledLayoutOffset already + // names the target's). + const travelLayoutBaseRef = useRef(0); + // The settle, return or exit running on the travel channel, if any. + const travelHandleRef = useRef(null); + // Which timing the gesture's release runs on: UIKit's springs under a + // finger, the theme's under a mouse. Read once per drag. + const releaseMotionRef = useRef({kind: 'uikit'}); + // Set while a drag-dismissed sheet is carried off screen by its release + // spring (see SheetGestureExit). + const gestureExitRef = useRef(null); + // Counts the sheet's mounts: the dialog-level touch listeners (see + // `drawnSheetRect`) attach once it is in the DOM. The node itself stays in + // `sheetElRef`, out of React state. + const [sheetMounts, setSheetMounts] = useState(0); const sheetRef = useCallback( (node: HTMLElement | null) => { observerRef.current?.disconnect(); observerRef.current = null; sheetElRef.current = node; + setSheetMounts(mounts => mounts + 1); + animator.attach(node); if (!node || typeof ResizeObserver === 'undefined') { if (node) { recordSheetHeight(node.getBoundingClientRect().height); @@ -723,9 +1026,40 @@ export function useSheetGestures({ ro.observe(node); observerRef.current = ro; }, - [recordSheetHeight], + [animator, recordSheetHeight], + ); + // While a finger drags the sheet under UIKit's presentation, or holds it + // after catching it mid-settle, no text selection starts under it and the + // touch callout is off: a finger held still on a caught sheet would + // otherwise start a text selection (on iOS, with its loupe) under the drag. + // Taken at the catch (the touch-down), before the long press would select, + // and released at the lift (sheetSelectionLock.ts: how each engine refuses + // one). + const selectionLockRef = useRef<(() => void) | null>(null); + const lockSelection = useCallback((target: EventTarget | null) => { + const element = sheetElRef.current; + if (element == null || selectionLockRef.current != null) { + return; + } + selectionLockRef.current = lockSheetSelection([element], target); + }, []); + const unlockSelection = useCallback(() => { + const release = selectionLockRef.current; + selectionLockRef.current = null; + release?.(); + }, []); + + // Unmount: the size observer, and the sheet's animator. Stopping it settles + // any travel still running as unfinished and clears its completion timer, + // so nothing completes on an unmounted sheet. + useEffect( + () => () => { + observerRef.current?.disconnect(); + animator.stop(); + unlockSelection(); + }, + [animator, unlockSelection], ); - useEffect(() => () => observerRef.current?.disconnect(), []); // Reset to the tallest detent each time the sheet re-opens. useEffect(() => { @@ -749,8 +1083,61 @@ export function useSheetGestures({ setScrollPreservationInset(0); // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen setSettlingLayoutOffset(null); + // The sheet's animator too, the compositor `translate` it moves, with + // no exit pending. Reopened before its entrance landed, the entrance + // runs on (a close queued meanwhile stands down at the landing, see + // `dismiss`). Reopened while its exit spring runs, it springs back from + // where it is, keeping that spring's velocity (spec:AST-094 FR6), on the + // present spring. Otherwise it is put back at rest. + const isEntering = entranceRef.current != null; + if (!isEntering) { + const isExitInFlight = + gestureExitRef.current != null && travelHandleRef.current != null; + travelHandleRef.current = null; + if (isExitInFlight) { + // The travel was measured from the exit's base: fold that in, so + // the channel holds the sheet's whole offset from the tallest + // detent. + const base = travelLayoutBaseRef.current + travelBaseRef.current; + travelBaseRef.current = 0; + travelLayoutBaseRef.current = 0; + if (base !== 0) { + animator.rebase({y: base}); + } + const handle = animator.animate( + {y: 0}, + {spring: SHEET_PRESENT_SPRING}, + {crossfadeAs: 'entrance'}, + ); + travelHandleRef.current = handle; + void handle.finished.then(() => { + if (travelHandleRef.current === handle) { + travelHandleRef.current = null; + } + }); + } else { + animator.set({y: 0}); + } + // And the dialog's ::backdrop dim: its animation stops, and the host + // gets its own easing back. + stopScrimFollow(); + if (isScrimTrackingRef.current) { + isScrimTrackingRef.current = false; + onScrimTrackingRef.current?.(false); + } + } + gestureExitRef.current = null; + } + }, [animator, isOpen, recordSettledLayoutOffset, stopScrimFollow]); + + // Syncs the dialog's ::backdrop dim with a close: a release still playing + // it stops, and the host's easing comes back unpinned, so the host's close + // fade runs instead of the page staying dimmed until the dialog closes. + useEffect(() => { + if (!isOpen && scrimOwnerRef.current === 'release') { + releaseScrimTracking(); } - }, [isOpen, recordSettledLayoutOffset]); + }, [isOpen, releaseScrimTracking]); // The fully-open height for detent math. Prefer the ResizeObserver-locked // value; fall back to a live measure of the tracked sheet element if the @@ -874,7 +1261,11 @@ export function useSheetGestures({ scrimOpacityForOffset( target, offsets, - maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO, + scrimClearOffset( + maxOffset, + shortestDetentHeight, + readTouchPresentation().isTouch, + ), peekOffset, ), ); @@ -910,23 +1301,159 @@ export function useSheetGestures({ reanchorToSettledDetent(); }, [reanchorToSettledDetent, snapHeights]); - // The one write a finger sample costs: the sheet's compositor transform, - // on the element, with no render between the input and the paint. The - // value matches what the host renders at rest (offset less the layout - // split), so a settle that follows continues from it. + // The one write a finger sample costs: the sheet's travel channel, with no + // render between the input and the paint. Added to the resting transform + // the drag began on, it makes the offset less the layout split: what the + // host renders at rest, so a settle that follows continues from it. const writeLiveTransform = useCallback( (offset: number, layoutOffset: number) => { - const element = sheetElRef.current; - if (element == null) { - return; + animator.set({y: offset - layoutOffset - travelBaseRef.current}); + }, + [animator], + ); + + // Whether a finger drives the current or last drag, so its release, and a + // spring-back after it, fade under reduced motion whatever the primary + // pointer (design:reduced-motion DR2). + const dragTouchRef = useRef(false); + + // Whether the primary pointer is coarse, subscribed: the catch's touch-only + // listeners exist only then (spec:AST-094 FR24), and come and go with it. + const coarsePrimary = useMediaQuery(TOUCH_QUERY); + + // Moves the sheet to `offset` on the travel channel, holding the layout + // split it is rendered with (`layoutOffset`) until it lands; landing a + // settle reconciles the layout, and the host folds the travel into its new + // resting transform in the same commit. On the compositor: the curve is + // baked into Web Animations, so it keeps its pace while the main thread is + // busy. Under a finger, `spring` handed `velocity` (px/s; the speed on + // screen by default); under a mouse, the theme's timing. + const springTravel = useCallback( + ( + offset: number, + layoutOffset: number, + spring: SpringDescription, + velocity?: number, + dimFor?: (offset: number) => number, + crossfadeAs?: 'exit', + ): MotionHandle => { + const base = layoutOffset + travelBaseRef.current; + travelLayoutBaseRef.current = layoutOffset; + const to = offset - base; + const motion = releaseMotionRef.current; + // One clock for the sheet and its dim. + const at = performance.now(); + const from = animator.get('y', at); + // Under reduced motion the animator's own cross-fade replaces the + // travel (design:reduced-motion). A finger started it, so it fades + // whatever the primary pointer (DR2). + const touchRelease = dragTouchRef.current; + // The dim rides the same curve on the dialog's ::backdrop. + const layers: MapLayer[] = []; + if (dimFor != null) { + const scrim = scrimLayerAnimator(); + if (scrim != null) { + scrimOwnerRef.current = 'release'; + const map = (y: number) => dimFor(base + y); + // Starts from the dim on screen, so a reduced-motion fade does too. + scrim.set({opacity: map(from)}); + layers.push({animator: scrim, property: 'opacity', map}); + } } - const translation = offset - layoutOffset; - element.style.transform = - translation !== 0 ? `translateY(${translation}px)` : ''; + // Thrown up onto the tallest detent, the 80 % damped spring overshoots + // above it; the sheet extends only its reserve below the screen, so + // the hand-off is capped to keep the page from showing under it. + const handed = velocity ?? animator.getVelocity('y', at); + const v = + motion.kind === 'uikit' && offset === 0 + ? limitOvershoot( + spring, + from, + to, + handed, + offscreenBlockEndInsetRef.current, + ) + : handed; + const run = createInteractiveTransition({ + driver: {animator, property: 'y'}, + layers, + rule: settleOnly, + }).to( + to, + motion.kind === 'theme' + ? {durationMs: motion.durationMs, easing: motion.easing} + : spring, + { + velocity: motion.kind === 'theme' ? 0 : v, + touchRelease, + now: at, + ...(crossfadeAs == null ? {} : {crossfadeAs}), + }, + ); + const handle = run.handle ?? settledHandle(run.finished); + travelHandleRef.current = handle; + void handle.finished.then(finished => { + if (travelHandleRef.current !== handle) { + return; + } + travelHandleRef.current = null; + if (!finished) { + return; + } + if (dimFor != null) { + // Closed meanwhile: the release's dim isn't written back. + if (isOpenRef.current) { + endScrimTracking(dimFor(offset)); + } else { + releaseScrimTracking(); + } + } + if (settlingLayoutOffsetRef.current != null) { + completeScrollAreaSettle(); + } + }); + return handle; }, - [], + [ + animator, + completeScrollAreaSettle, + endScrimTracking, + releaseScrimTracking, + scrimLayerAnimator, + ], ); + // The host calls this right after it writes a resting transform: the travel + // that carried the sheet there is folded into that transform. Travel still + // animating keeps its channel. + const resetTravel = useCallback(() => { + if (travelHandleRef.current != null || animator.get('y') === 0) { + return; + } + animator.set({y: 0}); + }, [animator]); + + // Folds the travel into the element's transform: the host's own exit + // transition then runs from where the pointer left the sheet, as it does + // under a mouse. Written while the drag's `transition: none` is still on + // the element, so the fold does not animate. + const foldTravel = useCallback(() => { + const translation = travelBaseRef.current + animator.get('y'); + const element = sheetElRef.current; + if (element != null) { + element.style.transform = + translation !== 0 ? `translateY(${translation}px)` : ''; + } + animator.set({y: 0}); + }, [animator]); + + // Whether a release can animate at all: UIKit's springs always can; the + // theme's timing not when it is zero (transitions turned off). + const releaseAnimates = useCallback(() => { + const motion = releaseMotionRef.current; + return motion.kind === 'uikit' || motion.durationMs > 0; + }, []); + const cancelDrag = useCallback( (target?: HTMLElement) => { const state = dragStateRef.current; @@ -937,6 +1464,7 @@ export function useSheetGestures({ // Clear first: releasePointerCapture() may synchronously dispatch // lostpointercapture, which must observe that this drag is already done. dragStateRef.current = null; + unlockSelection(); if (target?.hasPointerCapture?.(state.pointerId)) { target.releasePointerCapture(state.pointerId); } @@ -948,10 +1476,37 @@ export function useSheetGestures({ state.baseOffset, state.baseLayoutOffset, state.renderedOffset, - state.layoutOffset, + state.committedLayoutOffset, state.naturalEndGap, - true, + releaseAnimates(), ); + const cancelDimFor = (o: number) => { + const {offsets: stops, peekOffset: peek} = resolveDetents(state.height); + const shortest = stops[stops.length - 1]; + return scrimOpacityForOffset( + o, + stops, + scrimClearOffset( + shortest, + visibleHeightForOffset( + state.height, + shortest, + offscreenBlockEndInsetRef.current, + ), + releaseMotionRef.current.kind === 'uikit', + ), + peek, + ); + }; + if (settlingLayoutOffsetRef.current != null) { + springTravel( + state.baseOffset, + state.committedLayoutOffset, + SHEET_DETENT_SPRING, + undefined, + cancelDimFor, + ); + } // An interrupted drag returns to its previous resting detent. Restore // the matching scrim opacity as well so the modal shell cannot remain @@ -963,18 +1518,39 @@ export function useSheetGestures({ maxOffset, offscreenBlockEndInsetRef.current, ); - const dismissOffset = - maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO; - onScrimOpacityRef.current?.( - scrimOpacityForOffset( - state.baseOffset, - offsets, - dismissOffset, - peekOffset, - ), + const clearOffset = scrimClearOffset( + maxOffset, + shortestDetentHeight, + releaseMotionRef.current.kind === 'uikit', ); + if (settlingLayoutOffsetRef.current == null) { + endScrimTracking( + scrimOpacityForOffset( + state.baseOffset, + offsets, + clearOffset, + peekOffset, + ), + ); + } else { + onScrimOpacityRef.current?.( + scrimOpacityForOffset( + state.baseOffset, + offsets, + clearOffset, + peekOffset, + ), + ); + } }, - [prepareScrollAreaSettle, resolveDetents], + [ + endScrimTracking, + prepareScrollAreaSettle, + releaseAnimates, + resolveDetents, + springTravel, + unlockSelection, + ], ); useEffect(() => { @@ -1000,9 +1576,8 @@ export function useSheetGestures({ ( offset: number, velocity: number, + today: {direction: number; travel: number; velocity: number}, height: number, - dir: number, - travel: number, baseOffset: number, baseLayoutOffset: number, renderedOffset: number, @@ -1017,82 +1592,383 @@ export function useSheetGestures({ maxOffset, offscreenBlockEndInsetRef.current, ); - const dismissOffset = - maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO; - const projected = - travel > PROJECTION_MIN_TRAVEL - ? offset + velocity * PROJECTION_FACTOR - : offset; - const settleAt = (target: number) => { - // A peek keeps the full layout height and slides below the viewport; - // every taller detent resizes the scrolling area to what it shows. - const targetLayoutOffset = target === peekOffset ? 0 : target; + const isUIKit = releaseMotionRef.current.kind === 'uikit'; + const dismissedOffset = dismissedOffsetFor( + maxOffset, + shortestDetentHeight, + ); + // Where the scrim reaches clear, and the dim at any offset this release + // passes through. + const clearOffset = scrimClearOffset( + maxOffset, + shortestDetentHeight, + isUIKit, + ); + const dimFor = (o: number) => + scrimOpacityForOffset(o, offsets, clearOffset, peekOffset); + lastDimForRef.current = dimFor; + // UIKit's rule under a finger; today's under a mouse (sheetRelease.ts), + // as the interactive-transition driver's rule: offsets in, offsets out. + const release = sheetReleaseRule( + {position: offset, finger: offset, velocity}, + { + detents: offsets, + dismissedOffset: canDismissRef.current ? dismissedOffset : null, + isUIKit, + shortestDetentHeight, + startOffset: baseOffset, + today, + }, + ); + + if (release.outcome === 'dismiss') { prepareScrollAreaSettle( baseLayoutOffset, - target, - targetLayoutOffset, - renderedOffset, - layoutOffset, + baseOffset, + baseLayoutOffset, + baseLayoutOffset, + baseLayoutOffset, naturalEndGap, - true, + false, ); - recordSettledLayoutOffset(targetLayoutOffset); - settledDetentIndexRef.current = Math.max(0, offsets.indexOf(target)); - setSettledOffset(target); - onSnapRef.current?.( - visibleHeightForOffset( - height, - target, - offscreenBlockEndInsetRef.current, - ), - ); - onScrimOpacityRef.current?.( - scrimOpacityForOffset(target, offsets, dismissOffset, peekOffset), - ); - if (target !== baseOffset) { - hapticTick(); - } - }; - - // A release that would coast past the dismiss line closes the sheet: a - // throw from anywhere, or a slow drag that already crossed it. - if (dir > 0 && projected > dismissOffset) { - if (canDismissRef.current) { - prepareScrollAreaSettle( - baseLayoutOffset, - baseOffset, + if (releaseMotionRef.current.kind === 'uikit') { + // The release spring carries the sheet off screen at the finger's + // velocity; the host's exit waits for it instead of running its own. + const handle = springTravel( + release.target, baseLayoutOffset, - baseLayoutOffset, - baseLayoutOffset, - naturalEndGap, - false, + release.motion, + velocity, + dimFor, + 'exit', ); - setTouchDragDismissal({isTouch, pending: true}); - onDismissRef.current(); + const base = travelBaseRef.current; + gestureExitRef.current = { + finished: handle.finished, + transform: base !== 0 ? `translateY(${base}px)` : 'none', + }; + setDismissRequest(request => request + 1); } else { - settleAt(maxOffset); + // The host's own exit fades the dim from where it is. + endScrimTracking(dimFor(renderedOffset)); + foldTravel(); } + setTouchDragDismissal({isTouch, pending: true}); + onDismissRef.current(); return; } - // Settle to the detent nearest where the release would coast to, in the - // drag direction (never back past the starting detent), de-duped and - // direction-clamped by the util. A throw upward projects past the - // tallest detent and lands on it. - const target = resolveSettleOffset( - Math.min(Math.max(projected, 0), maxOffset), + + const target = release.target; + // A peek keeps the full layout height and slides below the viewport; + // every taller detent resizes the scrolling area to what it shows. + const targetLayoutOffset = target === peekOffset ? 0 : target; + prepareScrollAreaSettle( + baseLayoutOffset, + target, + targetLayoutOffset, + renderedOffset, + layoutOffset, + naturalEndGap, + releaseAnimates(), + ); + if (settlingLayoutOffsetRef.current != null) { + springTravel(target, layoutOffset, release.motion, velocity, dimFor); + } else { + endScrimTracking(dimFor(target)); + } + recordSettledLayoutOffset(targetLayoutOffset); + settledDetentIndexRef.current = Math.max(0, release.index); + setSettledOffset(target); + onSnapRef.current?.( + visibleHeightForOffset( + height, + target, + offscreenBlockEndInsetRef.current, + ), + ); + onScrimOpacityRef.current?.(dimFor(target)); + if (target !== baseOffset) { + // A short tick where the platform vibrates (gestures/haptics.ts). + playHaptic('impactLight'); + } + }, + [ + endScrimTracking, + foldTravel, + prepareScrollAreaSettle, + recordSettledLayoutOffset, + releaseAnimates, + resolveDetents, + springTravel, + ], + ); + + // A drag-dismissed sheet whose host keeps it open (a controlled `open` that + // stays true) springs back to where it rests, as a UIKit sheet does when + // its dismissal is declined. This syncs the sheet's animator (the + // compositor `translate`) with the declined request; the cleanup stops the + // spring it started if the effect runs again or the sheet unmounts. + const [dismissRequest, setDismissRequest] = useState(0); + useLayoutEffect(() => { + if (dismissRequest === 0 || gestureExitRef.current == null || !isOpen) { + return; + } + gestureExitRef.current = null; + const handle = springTravel( + settledOffset, + settledLayoutOffsetRef.current, + SHEET_DETENT_SPRING, + undefined, + lastDimForRef.current ?? undefined, + ); + return () => { + if (travelHandleRef.current === handle) { + animator.stop(['y']); + } + }; + }, [animator, dismissRequest, isOpen, settledOffset, springTravel]); + + // The dim for an offset of a sheet `height` tall: full at the shortest + // working detent and above, linear to clear at gone (UIKit's dim). + const dimForHeight = useCallback( + (offset: number, height: number) => { + const {offsets, peekOffset} = resolveDetents(height); + const shortest = offsets[offsets.length - 1]; + return scrimOpacityForOffset( + offset, offsets, - dir, - baseOffset, + dismissedOffsetFor( + shortest, + visibleHeightForOffset( + height, + shortest, + offscreenBlockEndInsetRef.current, + ), + ), + peekOffset, ); - settleAt(target); }, - [prepareScrollAreaSettle, recordSettledLayoutOffset, resolveDetents], + [resolveDetents], ); + // Present and dismiss: UIKit animates a sheet in and out on one critically + // damped spring (sheetPresent / sheetDismiss) and its dimming view's colour + // on the same spring, from clear to its resting dim and back (so the dim + // follows the spring's progress, not the drag's position map). Under + // reduced motion the sheet fades in or out in place instead, on the + // animator's own cross-fade (crossfadeAs; design:reduced-motion DR2), and + // the dim with it. Under a finger only. + const present = useCallback((): boolean => { + if (!readTouchPresentation().isTouch) { + return false; + } + // One entrance at a time: a second call while it runs joins it. + if (presentingRef.current) { + return true; + } + // Input is held from now until the sheet lands (F4 b59). + presentingRef.current = true; + onPresentingChangeRef.current?.(true); + // Before the host shows the dialog: its backdrop must not start its own + // fade-in. + beginScrimTracking(); + queueMicrotask(() => { + const element = sheetElRef.current; + if (element == null || !isOpenRef.current) { + endScrimTracking(1); + endPresenting(); + return; + } + // Below the screen: the sheet's visible height at its rest. It opens at + // its tallest detent: the reopen reset renders only after this + // microtask, so `settledOffset` may still be where the last close left + // the sheet (a peek, with its lighter scrim). + const height = measureHeight(); + const restOffset = 0; + const distance = visibleHeightForOffset( + height, + restOffset, + offscreenBlockEndInsetRef.current, + ); + const dimFor = (o: number) => dimForHeight(o, height); + if (distance <= 0 || height <= 0) { + endScrimTracking(dimFor(restOffset)); + endPresenting(); + return; + } + travelBaseRef.current = 0; + travelLayoutBaseRef.current = 0; + animator.set({y: distance}); + // From rest, with no start time: the browser starts the entrance when + // it commits it, as Core Animation starts it at its transaction's + // commit, so a long task after this call delays it instead of skipping + // into its curve. The dim, also from rest in this task, shares that + // start. + const restingDim = dimFor(restOffset); + const layers: MapLayer[] = []; + const scrim = scrimLayerAnimator(); + if (scrim != null) { + // The dim on the spring's progress: clear below the screen, the + // resting dim at rest. + const map = (y: number) => restingDim * (1 - y / distance); + scrim.set({opacity: map(distance)}); + layers.push({animator: scrim, property: 'opacity', map}); + } + const run = createInteractiveTransition({ + driver: {animator, property: 'y'}, + layers, + rule: settleOnly, + }).to(0, SHEET_PRESENT_SPRING, { + crossfadeAs: 'entrance', + startAtCommit: true, + }); + const handle = run.handle ?? settledHandle(run.finished); + travelHandleRef.current = handle; + // A close asked for meanwhile waits on this (see `dismiss`). + entranceRef.current = handle.finished; + void handle.finished.then(finished => { + endPresenting(); + if (travelHandleRef.current !== handle) { + return; + } + travelHandleRef.current = null; + if (finished) { + endScrimTracking(restingDim); + } + }); + }); + return true; + }, [ + animator, + beginScrimTracking, + dimForHeight, + endPresenting, + endScrimTracking, + measureHeight, + scrimLayerAnimator, + ]); + + const startDismiss = useCallback((): SheetGestureExit | null => { + if (gestureExitRef.current != null) { + return gestureExitRef.current; + } + if (sheetElRef.current == null || !readTouchPresentation().isTouch) { + return null; + } + if (travelHandleRef.current != null) { + // A settle still running: leave from where it is. + travelHandleRef.current = null; + animator.stop(['y']); + } else { + travelBaseRef.current = settledOffset - settledLayoutOffsetRef.current; + travelLayoutBaseRef.current = settledLayoutOffsetRef.current; + } + // Computed, not read from layout: by now the host may have rendered its + // closing style, whose transform the exit's held transform replaces in + // this same commit. + const height = measureHeight(); + const from = animator.get('y'); + const fromOffset = + travelLayoutBaseRef.current + travelBaseRef.current + from; + // Below the screen: what is still visible of the sheet. + const distance = visibleHeightForOffset( + height, + fromOffset, + offscreenBlockEndInsetRef.current, + ); + // The dim fades from where it is: an entrance (whose dim follows its + // progress, not position) or a release may still be playing it. + const startingDim = + scrimAnimatorRef.current?.get('opacity') ?? + dimForHeight(fromOffset, height); + beginScrimTracking(); + // From rest, with no start time, as the entrance: the browser starts the + // exit when it commits it, so a long task after the close delays it + // instead of skipping into its curve. The dim shares that start. + const layers: MapLayer[] = []; + const scrim = scrimLayerAnimator(); + if (scrim != null) { + // The dim on the spring's progress: from where it is to clear below the + // screen. + const map = (y: number) => + distance > 0 ? startingDim * (1 - (y - from) / distance) : 0; + scrim.set({opacity: startingDim}); + layers.push({animator: scrim, property: 'opacity', map}); + } + const run = createInteractiveTransition({ + driver: {animator, property: 'y'}, + layers, + rule: settleOnly, + }).to(from + distance, SHEET_DISMISS_SPRING, { + crossfadeAs: 'exit', + startAtCommit: true, + }); + const handle = run.handle ?? settledHandle(run.finished); + travelHandleRef.current = handle; + void handle.finished.then(finished => { + if (travelHandleRef.current === handle) { + travelHandleRef.current = null; + } + if (finished) { + endScrimTracking(0); + } + }); + const base = travelBaseRef.current; + gestureExitRef.current = { + finished: handle.finished, + transform: base !== 0 ? `translateY(${base}px)` : 'none', + }; + return gestureExitRef.current; + }, [ + animator, + beginScrimTracking, + dimForHeight, + endScrimTracking, + measureHeight, + scrimLayerAnimator, + settledOffset, + ]); + + // UIKit queues a dismissal asked for during the presentation: the sheet + // lands on its detent, then a full dismissal runs from there (F4 b59). + const dismiss = useCallback((): SheetGestureExit | null => { + if (gestureExitRef.current != null) { + return gestureExitRef.current; + } + const entrance = entranceRef.current; + if (entrance == null) { + return startDismiss(); + } + const base = travelBaseRef.current; + // The host asks for the exit once the sheet is closing. + const wasClosing = !isOpenRef.current; + const queued: SheetGestureExit = { + finished: entrance.then(async () => { + if (gestureExitRef.current === queued) { + gestureExitRef.current = null; + } + // Opened again before it landed: the newer intent owns the outcome + // (`architecture:interaction-modality` INV8), so the sheet stays. + if (wasClosing && isOpenRef.current) { + return false; + } + return startDismiss()?.finished ?? true; + }), + transform: base !== 0 ? `translateY(${base}px)` : 'none', + }; + gestureExitRef.current = queued; + return queued; + }, [startDismiss]); + const beginDrag = useCallback( (event: ReactPointerEvent, sheetHeight: number, startCoord?: number) => { + // A closing sheet is not grabbed: its exit owns the surface. + if (!isOpenRef.current) { + return; + } const target = event.currentTarget as HTMLElement; const syntheticTouch = isSyntheticTouch(event); + dragTouchRef.current = syntheticTouch || event.pointerType === 'touch'; // Never capture for a touch drag. The id came from `Touch.identifier`, // which on iOS names a live pointer: the capture succeeds, WebKit takes // it straight back for its own gesture handling, and the @@ -1107,21 +1983,55 @@ export function useSheetGestures({ // delta reflects the full pull distance. const start = startCoord ?? event.clientY; const naturalEndGap = naturalEndGapFor(bodyNodeRef.current); - const baseLayoutOffset = settledLayoutOffsetRef.current; + let baseOffset = settledOffset; + let baseLayoutOffset = settledLayoutOffsetRef.current; + if (travelHandleRef.current != null) { + // A finger landing on a settling sheet catches it where the curve has + // it on screen (never a layout read), and the drag continues from + // there. The settle is abandoned in place, so the host's layout split + // and the drag's both start from the catch point. + travelHandleRef.current = null; + animator.stop(['y']); + baseLayoutOffset = settlingLayoutOffsetRef.current ?? baseLayoutOffset; + baseOffset = + baseLayoutOffset + travelBaseRef.current + animator.get('y'); + settlingLayoutOffsetRef.current = null; + setSettlingLayoutOffset(null); + pendingScrollPreservationInsetRef.current = null; + recordSettledLayoutOffset(baseLayoutOffset); + setSettledOffset(baseOffset); + } else { + // At rest the host's transform holds the detent's translation; the + // drag travels from it. + travelBaseRef.current = settledOffset - baseLayoutOffset; + } + activeOffsetRef.current = baseOffset; + beginScrimTracking(); + // Read before this drag turns transitions off (releaseMotion.ts). + releaseMotionRef.current = readReleaseMotion( + sheetElRef.current, + syntheticTouch ? 'touch' : event.pointerType, + ); + // Selection and the callout go off with UIKit's presentation, the same + // answer as the tracking and the release: a finger where the primary + // pointer is fine keeps the browser's (spec:AST-094 FR19). + if (releaseMotionRef.current.kind === 'uikit') { + lockSelection(event.target); + } + const tracker = new VelocityTracker(); + tracker.add(0, event.clientY, event.timeStamp); dragStateRef.current = { syntheticTouch, pointerType: syntheticTouch ? 'touch' : event.pointerType, pointerId: event.pointerId, startCoord: start, - lastCoord: event.clientY, - lastTime: event.timeStamp, - samples: [{t: event.timeStamp, y: event.clientY}], - velocity: 0, + tracker, height: sheetHeight, - baseOffset: settledOffset, + baseOffset, baseLayoutOffset, - renderedOffset: settledOffset, + renderedOffset: baseOffset, layoutOffset: baseLayoutOffset, + committedLayoutOffset: baseLayoutOffset, naturalEndGap, hasTraveled: false, }; @@ -1134,10 +2044,17 @@ export function useSheetGestures({ ); // Seed dragOffset at the resting detent so flipping isDragging doesn't // jump the sheet to fully-open for one frame. - setDragOffset(settledOffset); + setDragOffset(baseOffset); setIsDragging(true); }, - [settledOffset, updateScrollPreservationInset], + [ + animator, + beginScrimTracking, + lockSelection, + recordSettledLayoutOffset, + settledOffset, + updateScrollPreservationInset, + ], ); const handlePointerDown = useCallback( @@ -1192,41 +2109,23 @@ export function useSheetGestures({ return; } const delta = event.clientY - state.startCoord; - const dt = event.timeStamp - state.lastTime; - if (dt > 0) { - state.lastCoord = event.clientY; - state.lastTime = event.timeStamp; - state.samples.push({t: event.timeStamp, y: event.clientY}); - // Drop what fell out of the window while a partner remains: a throw - // that begins after a rest reads its own speed, not the average since - // touch-down. - while ( - state.samples.length > 2 && - event.timeStamp - state.samples[0].t > VELOCITY_WINDOW_MS - ) { - state.samples.shift(); - } - const first = state.samples[0]; - // A lone partner older than the window says only where the finger - // rested; the move it precedes is read as if it took the window. - const span = Math.min(event.timeStamp - first.t, VELOCITY_WINDOW_MS); - state.velocity = span > 0 ? (event.clientY - first.y) / span : 0; - } + // One sample per event, as UIKit's velocity filter takes them, on the + // event's own timestamp: when the finger was there, however late a busy + // main thread gets to the event. + state.tracker.add(0, event.clientY, event.timeStamp); const {offsets, peekOffset} = resolveDetents(state.height); const raw = state.baseOffset + delta; - const maxDetentOffset = offsets[offsets.length - 1]; - let next: number; - if (raw < 0) { - // Up past fully-open: damped + capped rubber-band; springs back on release. - next = Math.max(-OVERSCROLL_MAX, raw * OVERSCROLL_RESISTANCE); - } else if (raw > maxDetentOffset) { - // In the dismiss zone: no magnet, so it doesn't fight a drag-to-close. - next = raw; - } else { - // Between detents: magnetically ease toward a nearby one. - next = magnetize(raw, offsets); - } + // Under UIKit's presentation (a finger or a pen where the primary + // pointer is coarse, the same answer as the release's): 1:1 between + // detents and into the dismiss zone, as UIKit's sheet tracks (the + // release, not the drag, picks the detent), and past fully open the + // record's rubber band (spec:AST-094 FR3). Otherwise today's pointer + // behavior (spec:AST-017 FR59). + const next = + releaseMotionRef.current.kind === 'uikit' + ? fingerDragOffset(raw, state.height) + : pointerDragOffset(raw, offsets); // Dragging above the base restores the full layout height below the // viewport; otherwise keep whatever layout the base detent settled with // (0 at a peek, so a peek drag stays transform-only). @@ -1235,10 +2134,11 @@ export function useSheetGestures({ state.renderedOffset = next; state.layoutOffset = layoutOffset; activeOffsetRef.current = next; - writeLiveTransform(next, layoutOffset); + writeLiveTransform(next, state.committedLayoutOffset); if (hasLayoutChanged) { // The layout split moved: the host renders the new height, reading - // the offset it has to pair with it from state. + // the offset it has to pair with it from state; the travel follows + // in that commit (see the layout effect below). setDragOffset(next); } if (!state.hasTraveled && next !== state.baseOffset) { @@ -1262,15 +2162,43 @@ export function useSheetGestures({ floorOffset, offscreenBlockEndInsetRef.current, ); - const dismissOffset = - floorOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO; onScrimOpacityRef.current?.( - scrimOpacityForOffset(next, offsets, dismissOffset, peekOffset), + scrimOpacityForOffset( + next, + offsets, + scrimClearOffset( + floorOffset, + shortestDetentHeight, + releaseMotionRef.current.kind === 'uikit', + ), + peekOffset, + ), ); }, [resolveDetents, updateScrollPreservationInset, writeLiveTransform], ); + // The host rendered a drag's new layout split (the panel derives it from + // these, as here): in the same commit, before it paints, the travel moves + // to the value that pairs with it, so the sheet stays under the finger. + useLayoutEffect(() => { + const state = dragStateRef.current; + if (!isDragging || state == null) { + return; + } + const committed = dragOffset < settledOffset ? 0 : settledLayoutOffset; + if (committed !== state.committedLayoutOffset) { + state.committedLayoutOffset = committed; + writeLiveTransform(state.renderedOffset, committed); + } + }, [ + dragOffset, + isDragging, + settledLayoutOffset, + settledOffset, + writeLiveTransform, + ]); + const endDrag = useCallback( (event: ReactPointerEvent) => { const state = dragStateRef.current; @@ -1286,13 +2214,26 @@ export function useSheetGestures({ const target = event.currentTarget as HTMLElement; const delta = event.clientY - state.startCoord; const offset = Math.max(0, state.baseOffset + delta); - const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1; - // A finger that rested before lifting released nothing in motion. - const velocity = - event.timeStamp - state.lastTime < VELOCITY_WINDOW_MS - ? state.velocity - : 0; + // The finger's velocity at the lift, in px/s, as UIKit reports it: from + // the positions on the timestamps their events carried, so a busy main + // thread handing them over late does not change it. A lift where the + // finger last was adds nothing; one after the finger held still for + // longer than the stillness interval reads 0 (spec:AST-100 FR2: a still + // finger never flicks), read at the lift's own time. + if (state.tracker.lastSample?.y !== event.clientY) { + state.tracker.add(0, event.clientY, event.timeStamp); + } + const velocity = state.tracker.velocity(event.timeStamp).y; + // Today's inputs, for a release under a mouse: the drag's direction + // and travel, and the release velocity every pointer reads + // (spec:AST-100 FR2, FR4). + const today = { + direction: delta === 0 ? 0 : delta > 0 ? 1 : -1, + travel: Math.abs(delta), + velocity, + }; dragStateRef.current = null; + unlockSelection(); if (!state.syntheticTouch) { target.releasePointerCapture?.(event.pointerId); } @@ -1301,18 +2242,17 @@ export function useSheetGestures({ settleFromDrag( offset, velocity, + today, state.height || 1, - dir, - Math.abs(delta), state.baseOffset, state.baseLayoutOffset, state.renderedOffset, - state.layoutOffset, + state.committedLayoutOffset, state.naturalEndGap, state.pointerType === 'touch', ); }, - [settleFromDrag], + [settleFromDrag, unlockSelection], ); // Pointer path for the body at-top pull-down (desktop / mouse). Touch uses @@ -1408,11 +2348,13 @@ export function useSheetGestures({ // it has to yield again if the finger comes back down. promotedAtContentEnd: boolean; } | null>(null); - const previousTouchHandlersRef = useRef<{ + // The body's touch handlers, made with its node; attached by an Effect. + const bodyTouchHandlersRef = useRef<{ start: (e: TouchEvent) => void; move: (e: TouchEvent) => void; end: (e: TouchEvent) => void; } | null>(null); + const [bodyMounts, setBodyMounts] = useState(0); // A second finger is a pinch, and pinch-zoom is the browser's. Whichever // surface it lands on and whichever finger came first, drop every armed @@ -1442,13 +2384,197 @@ export function useSheetGestures({ measureHeightRef.current = measureHeight; }); + // A finger on the drawn sheet during a release. WebKit (iOS Safari 26.2) + // hit-tests an element that a compositor animation is moving at its + // un-animated position, and reports that rect too, so a touch on the moving + // sheet lands on whatever sat there when the release began: the scrim, or + // content the sheet has left. While a release travels, a touch on the + // dialog is mapped through the sheet's drawn rect instead (its layout box, + // its resting transform and the animator's current travel, never a rect + // read): inside, it catches the sheet at touch-down and is never a scrim + // tap; outside, it is left alone. + const drawnSheetRect = useCallback(() => { + const sheet = sheetElRef.current; + if (sheet == null) { + return null; + } + // offsetTop/offsetLeft ignore transforms, in every engine. + let top = 0; + let left = 0; + for ( + let node: HTMLElement | null = sheet; + node != null; + node = node.offsetParent as HTMLElement | null + ) { + top += node.offsetTop; + left += node.offsetLeft; + } + const resting = getComputedStyle(sheet).transform; + const restingY = + resting !== '' && resting !== 'none' + ? new DOMMatrixReadOnly(resting).m42 + : 0; + const drawnTop = top + restingY + animator.get('y'); + return { + top: drawnTop, + bottom: drawnTop + sheet.offsetHeight, + left, + right: left + sheet.offsetWidth, + }; + }, [animator]); + const caughtTouchRef = useRef(null); + const suppressClickUntilRef = useRef(-Infinity); + useEffect(() => { + const sheetNode = sheetElRef.current; + const dialog = sheetNode?.closest('dialog'); + // Touch-only work: none where the primary pointer is fine (spec:AST-094 + // FR24). + if (!isOpen || !coarsePrimary || sheetNode == null || dialog == null) { + return; + } + const asPointer = (touch: Touch, timeStamp: number) => + ({ + syntheticTouch: true, + pointerId: touch.identifier, + clientY: touch.clientY, + timeStamp, + // Where the finger landed: what the selection lock reads. + target: touch.target, + currentTarget: sheetNode, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + }) as unknown as ReactPointerEvent; + const find = (list: TouchList, id: number): Touch | null => { + for (let i = 0; i < list.length; i++) { + if (list[i].identifier === id) { + return list[i]; + } + } + return null; + }; + const onDrawnSheet = (x: number, y: number) => { + const rect = drawnSheetRect(); + return ( + rect != null && + y >= rect.top && + y <= rect.bottom && + x >= rect.left && + x <= rect.right + ); + }; + const onTouchStart = (event: TouchEvent) => { + const touch = event.changedTouches[0] as Touch | undefined; + if ( + travelHandleRef.current == null || + // The entrance's touches are the hold's (see `present`). + presentingRef.current || + event.touches.length !== 1 || + touch == null || + !onDrawnSheet(touch.clientX, touch.clientY) + ) { + return; + } + // The sheet's: not the stale target's (handle, body or scrim). + event.stopPropagation(); + caughtTouchRef.current = touch.identifier; + suppressClickUntilRef.current = Infinity; + beginDragRef.current( + asPointer(touch, event.timeStamp), + measureHeightRef.current(), + ); + }; + const onTouchMove = (event: TouchEvent) => { + const id = caughtTouchRef.current; + const touch = id == null ? null : find(event.changedTouches, id); + if (touch == null) { + return; + } + event.stopPropagation(); + if (event.cancelable) { + event.preventDefault(); + } + pointerMoveRef.current(asPointer(touch, event.timeStamp)); + }; + const onTouchEnd = (event: TouchEvent) => { + const id = caughtTouchRef.current; + const touch = id == null ? null : find(event.changedTouches, id); + if (touch == null) { + return; + } + event.stopPropagation(); + caughtTouchRef.current = null; + // A tap's click follows the lift, on the stale target: not a scrim tap. + suppressClickUntilRef.current = performance.now() + 700; + if (event.type === 'touchcancel') { + cancelDragRef.current(); + } else { + endDragRef.current(asPointer(touch, event.timeStamp)); + } + }; + // The same finger's pointer events belong to the touch above, never to + // the stale target's handlers. + const onPointer = (event: PointerEvent) => { + if ( + event.pointerType === 'touch' && + (caughtTouchRef.current != null || + (event.type === 'pointerdown' && + travelHandleRef.current != null && + !presentingRef.current && + onDrawnSheet(event.clientX, event.clientY))) + ) { + event.stopPropagation(); + } + }; + const onClick = (event: MouseEvent) => { + if (performance.now() <= suppressClickUntilRef.current) { + suppressClickUntilRef.current = -Infinity; + event.stopPropagation(); + event.preventDefault(); + } + }; + const touchTypes = ['touchstart', 'touchmove', 'touchend', 'touchcancel']; + const touchHandlers = [onTouchStart, onTouchMove, onTouchEnd, onTouchEnd]; + const pointerTypes = [ + 'pointerdown', + 'pointermove', + 'pointerup', + 'pointercancel', + ]; + touchTypes.forEach((type, i) => + dialog.addEventListener(type, touchHandlers[i] as EventListener, { + capture: true, + passive: type !== 'touchmove', + }), + ); + pointerTypes.forEach(type => + dialog.addEventListener(type, onPointer as EventListener, true), + ); + dialog.addEventListener('click', onClick, true); + return () => { + touchTypes.forEach((type, i) => + dialog.removeEventListener(type, touchHandlers[i] as EventListener, { + capture: true, + }), + ); + pointerTypes.forEach(type => + dialog.removeEventListener(type, onPointer as EventListener, true), + ); + dialog.removeEventListener('click', onClick, true); + caughtTouchRef.current = null; + }; + }, [coarsePrimary, drawnSheetRect, isOpen, sheetMounts]); + const bodyRef = useCallback((node: HTMLElement | null) => { - const asPointer = (touch: Touch, target: HTMLElement) => + // `timeStamp` is the touch event's own: when the finger was there, on the + // same clock as pointer events, not when a busy main thread handled it. + const asPointer = (touch: Touch, target: HTMLElement, timeStamp: number) => ({ syntheticTouch: true, pointerId: touch.identifier, clientY: touch.clientY, - timeStamp: Date.now(), + timeStamp, + // Where the finger landed: what the selection lock reads. + target: touch.target, currentTarget: target, setPointerCapture: () => {}, releasePointerCapture: () => {}, @@ -1524,11 +2650,11 @@ export function useSheetGestures({ // Deliberately NOT preventDefault()ed: the scroller is clamped at // its end, so there is no scrolling left to cancel, and claiming the // gesture would strand the content for as long as the finger is down. - pointerMoveRef.current(asPointer(t, scroller)); + pointerMoveRef.current(asPointer(t, scroller, event.timeStamp)); return; } event.preventDefault(); - pointerMoveRef.current(asPointer(t, scroller)); + pointerMoveRef.current(asPointer(t, scroller, event.timeStamp)); return; } if (!armed) { @@ -1553,11 +2679,11 @@ export function useSheetGestures({ event.preventDefault(); touchDragRef.current = null; beginDragRef.current( - asPointer(t, scroller), + asPointer(t, scroller, event.timeStamp), measureHeightRef.current(), armed.startY, ); - pointerMoveRef.current(asPointer(t, scroller)); + pointerMoveRef.current(asPointer(t, scroller, event.timeStamp)); return; } if ((armed.top && delta < 0) || (armed.bottom && delta > 0)) { @@ -1580,11 +2706,11 @@ export function useSheetGestures({ } else if (armed.contentEndY - t.clientY >= CONTENT_END_HANDOFF_SLOP) { armed.promotedAtContentEnd = true; beginDragRef.current( - asPointer(t, scroller), + asPointer(t, scroller, event.timeStamp), measureHeightRef.current(), armed.contentEndY, ); - pointerMoveRef.current(asPointer(t, scroller)); + pointerMoveRef.current(asPointer(t, scroller, event.timeStamp)); } } else { armed.contentEndY = null; @@ -1602,7 +2728,7 @@ export function useSheetGestures({ ); const target = event.currentTarget as HTMLElement; if (t) { - endDragRef.current(asPointer(t, target)); + endDragRef.current(asPointer(t, target, event.timeStamp)); } else if (event.touches.length === 0) { // Some interrupted multi-touch sequences omit the active touch from // changedTouches. If no fingers remain, the drag cannot finish later. @@ -1610,29 +2736,36 @@ export function useSheetGestures({ } }; - const prev = bodyNodeRef.current; - if (prev && previousTouchHandlersRef.current) { - const h = previousTouchHandlersRef.current; - prev.removeEventListener('touchstart', h.start); - prev.removeEventListener('touchmove', h.move); - prev.removeEventListener('touchend', h.end); - prev.removeEventListener('touchcancel', h.end); - } bodyNodeRef.current = node; - if (node) { - node.addEventListener('touchstart', onTouchStart, {passive: true}); - node.addEventListener('touchmove', onTouchMove, {passive: false}); - node.addEventListener('touchend', onTouchEnd, {passive: true}); - node.addEventListener('touchcancel', onTouchEnd, {passive: true}); - previousTouchHandlersRef.current = { - start: onTouchStart, - move: onTouchMove, - end: onTouchEnd, - }; - } else { - previousTouchHandlersRef.current = null; - } + bodyTouchHandlersRef.current = + node == null + ? null + : {start: onTouchStart, move: onTouchMove, end: onTouchEnd}; + setBodyMounts(mounts => mounts + 1); }, []); + // The body's touch listeners, for a finger under any primary pointer: a + // touchscreen laptop's finger pulls the sheet from the content's top as a + // phone's does, where the pointer listeners would lose it to the browser's + // scroll. Under a fine pointer a component may listen on its own element + // (spec:AST-094 FR24). Attached for each mounted body, removed when it + // goes. + useEffect(() => { + const node = bodyNodeRef.current; + const h = bodyTouchHandlersRef.current; + if (node == null || h == null) { + return; + } + node.addEventListener('touchstart', h.start, {passive: true}); + node.addEventListener('touchmove', h.move, {passive: false}); + node.addEventListener('touchend', h.end, {passive: true}); + node.addEventListener('touchcancel', h.end, {passive: true}); + return () => { + node.removeEventListener('touchstart', h.start); + node.removeEventListener('touchmove', h.move); + node.removeEventListener('touchend', h.end); + node.removeEventListener('touchcancel', h.end); + }; + }, [bodyMounts]); const reconcileScrollPreservationInset = useCallback( (body: HTMLElement) => { @@ -1712,9 +2845,9 @@ export function useSheetGestures({ [cancelDrag], ); - // The resting transform; a drag in flight writes its own to the element - // (writeLiveTransform) and a host that owns the element's transform must not - // render this over it while `isDragging`. + // The resting transform, for a host that renders before it measures; a + // gesture moves the sheet on the travel channel instead (writeLiveTransform, + // springTravel). const activeOffset = isDragging ? dragOffset : settledOffset; const contentProps = useMemo( @@ -1804,5 +2937,10 @@ export function useSheetGestures({ settlingLayoutOffset, settledLayoutOffset, completeScrollAreaSettle, + gestureExitRef, + resetTravel, + present, + afterEntrance, + dismiss, }; } diff --git a/packages/core/src/Layer/__tests__/inputHold.fixture.ts b/packages/core/src/Layer/__tests__/inputHold.fixture.ts new file mode 100644 index 0000000000000..84cfc1b93f198 --- /dev/null +++ b/packages/core/src/Layer/__tests__/inputHold.fixture.ts @@ -0,0 +1,137 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file inputHold.fixture.ts + * @input Nothing: bundled by esbuild and injected into a blank page + * @output A page whose whole area is a button (`#under`), a modal dialog + * (`#dialog`: text, an action, and a gesture-host tap recognizer on the + * dialog itself, an ancestor of the hold's shield), a light-dismissing + * popover (`#pop`) and a full-viewport layer (`#lift`) whose content and + * tint take the hits while a test may make its root take none, plus + * `window.__hold`: open a layer, hold and + * release it, read what an element at a point is and what lies past a + * hold's shield there, and a log of every + * click, tap recognition and popover toggle + * @position Browser fixture of inputHold.interaction.browser.spec.ts; + * test-only + */ + +import {getGestureHost} from '../../gestures/GestureHost'; +import {TapGestureRecognizer} from '../../gestures/TapGestureRecognizer'; +import {holdInput, isInputShield, type InputHoldScope} from '../inputHold'; + +type LayerId = 'dialog' | 'pop' | 'lift'; + +interface HoldLog { + clicks: string[]; + taps: number; + toggles: string[]; +} + +declare global { + interface Window { + __hold: { + log: HoldLog; + open: (id: LayerId) => void; + hold: (id: LayerId, scope: InputHoldScope) => void; + release: () => void; + hitAt: (x: number, y: number) => string; + hitPast: (x: number, y: number) => string; + }; + } +} + +document.body.style.margin = '0'; +document.body.innerHTML = ` + + +

Delete this item?

+ +
+
+ +
+ `; + +function element(id: string): HTMLElement { + const found = document.getElementById(id); + if (found == null) { + throw new Error(`missing #${id}`); + } + return found; +} + +const log: HoldLog = {clicks: [], taps: 0, toggles: []}; + +document.addEventListener( + 'click', + event => { + const target = event.target; + if (target instanceof Element) { + const button = target.closest('button'); + if (button != null) { + log.clicks.push(button.id); + } + } + }, + true, +); +element('pop').addEventListener('toggle', event => { + log.toggles.push(`pop:${event.newState}`); +}); + +// A recognizer on the dialog itself sees every touch inside it, the hold's +// shield included: only the gesture host's touch gate keeps it away. +const tap = new TapGestureRecognizer({name: 'dialogTap'}); +tap.addTransitionListener((_current, _from, to) => { + if (to === 'ended') { + log.taps += 1; + } +}); +getGestureHost(document).attach(element('dialog'), tap); + +let release: (() => void) | null = null; + +window.__hold = { + log, + open(id) { + if (id === 'dialog') { + (element('dialog') as HTMLDialogElement).showModal(); + } else if (id === 'pop') { + element('pop').showPopover(); + } else { + element('lift').style.display = 'block'; + } + }, + hold(id, scope) { + release = holdInput(element(id), { + until: new Promise(() => {}), + scope, + }); + }, + release() { + release?.(); + release = null; + }, + hitAt(x, y) { + return describe(document.elementFromPoint(x, y)); + }, + hitPast(x, y) { + return describe( + document.elementsFromPoint(x, y).find(el => !isInputShield(el)) ?? null, + ); + }, +}; + +function describe(hit: Element | null): string { + if (hit == null) { + return 'nothing'; + } + if (isInputShield(hit)) { + return `shield:${hit.parentElement?.id ?? ''}`; + } + return hit.id !== '' ? hit.id : hit.tagName.toLowerCase(); +} diff --git a/packages/core/src/Layer/__tests__/inputHold.interaction.browser.spec.ts b/packages/core/src/Layer/__tests__/inputHold.interaction.browser.spec.ts new file mode 100644 index 0000000000000..c31bc8fe820a5 --- /dev/null +++ b/packages/core/src/Layer/__tests__/inputHold.interaction.browser.spec.ts @@ -0,0 +1,348 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file inputHold.interaction.browser.spec.ts + * @input The input-hold fixture bundled with esbuild, a phone-sized + * touch screen, and real engine touch input (touchDriver) + * @output Browser proof of what the unit tests cannot see: the hold's shield + * is the hit target over the whole viewport, above a modal dialog in the + * top layer; a tap during a hold reaches no click and no recognizer on the + * layer (the gesture host's touch gate) and leaves a light-dismissing + * popover open; a layer root that takes no hits itself (pointer-events: + * none, or visibility: hidden, its content opting back in) is held all the + * same; keys still act during a presentation's input hold; a dismissal's + * hold makes the content inert and moves focus out of it, so no key acts + * on it either (a focused button that turns inert still takes Enter and + * Space in both engines); each release gives input back; a hit test + * that skips shields (isInputShield) finds what lies beneath, and + * a layer above the held one still wins; a one-finger + * pan on the shield scrolls nothing while a pinch on it still zooms the + * page (spec:AST-094 FR10, FR18, FR35; Chromium, which pinch-zooms from + * injected touches) + * @position Browser tests of packages/core/src/Layer/inputHold.ts, run + * by the interaction project (`pnpm test:interaction inputHold`) + */ + +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {build} from 'esbuild'; +import {expect, test, type Page} from '@playwright/test'; +import { + drag, + pinch, + touchDriver, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; + +type Point = {x: number; y: number}; +type Scope = 'viewport' | 'layer'; +type LayerId = 'dialog' | 'pop' | 'lift'; + +interface HoldApi { + log: {clicks: string[]; taps: number; toggles: string[]}; + open: (id: LayerId) => void; + hold: (id: LayerId, scope: Scope) => void; + release: () => void; + hitAt: (x: number, y: number) => string; + hitPast: (x: number, y: number) => string; +} + +let bundle = ''; + +test.beforeAll(async () => { + const result = await build({ + entryPoints: [ + path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'inputHold.fixture.ts', + ), + ], + bundle: true, + write: false, + format: 'iife', + platform: 'browser', + target: 'es2022', + }); + bundle = result.outputFiles[0].text; +}); + +async function load(page: Page): 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 touchDriver(page); +} + +type HoldWindow = {__hold: HoldApi}; + +const open = async (page: Page, id: LayerId) => + page.evaluate(layer => { + (window as unknown as HoldWindow).__hold.open(layer); + }, id); +const hold = async (page: Page, id: LayerId, scope: Scope) => + page.evaluate( + ([layer, how]) => { + (window as unknown as HoldWindow).__hold.hold(layer, how); + }, + [id, scope] as const, + ); +const release = async (page: Page) => + page.evaluate(() => { + (window as unknown as HoldWindow).__hold.release(); + }); +const hitAt = async (page: Page, point: Point) => + page.evaluate( + at => (window as unknown as HoldWindow).__hold.hitAt(at.x, at.y), + point, + ); +const hitPast = async (page: Page, point: Point) => + page.evaluate( + at => (window as unknown as HoldWindow).__hold.hitPast(at.x, at.y), + point, + ); +const readLog = async (page: Page) => + page.evaluate(() => (window as unknown as HoldWindow).__hold.log); + +async function centre(page: Page, id: string): Promise { + const box = await page.locator(`#${id}`).boundingBox(); + if (box == null) { + throw new Error(`#${id} has no box`); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +// A tap's click (or its recognition) lands within a few frames. +const settle = async (page: Page) => page.waitForTimeout(250); + +test("during a presentation's input hold on a modal dialog, a tap on it or beside it reaches nothing, keys still act, and the release gives touch back", async ({ + page, +}) => { + const driver = await load(page); + await open(page, 'dialog'); + const action = await centre(page, 'action'); + const message = await centre(page, 'message'); + const beside = {x: 12, y: 12}; + await hold(page, 'dialog', 'viewport'); + // The shield is what a touch hits, on the dialog and over its backdrop. + expect(await hitAt(page, action)).toBe('shield:dialog'); + expect(await hitAt(page, beside)).toBe('shield:dialog'); + await driver.tap(action); + await driver.tap(message); + await settle(page); + // No click, and the recognizer on the dialog saw nothing: the gate. + expect(await readLog(page)).toEqual({clicks: [], taps: 0, toggles: []}); + // Keys still act during a presentation's input hold. + await page.locator('#action').focus(); + await page.keyboard.press('Enter'); + await expect + .poll(async () => (await readLog(page)).clicks) + .toEqual(['action']); + await release(page); + expect(await hitAt(page, action)).toBe('action'); + expect(await hitAt(page, beside)).toBe('dialog'); + await driver.tap(message); + await expect.poll(async () => (await readLog(page)).taps).toBe(1); +}); + +test('while a dismissal holds it, its content is inert, focus and keys leave it, and the shield still takes every tap; the release restores both', async ({ + page, +}) => { + const driver = await load(page); + await open(page, 'dialog'); + const action = await centre(page, 'action'); + const message = await centre(page, 'message'); + // The dialog moved focus to its action when it opened. + expect(await page.evaluate(() => document.activeElement?.id ?? null)).toBe( + 'action', + ); + await hold(page, 'dialog', 'layer'); + // A key reaches nothing: the focused action left the inert content. + await page.keyboard.press('Enter'); + await page.keyboard.press('Space'); + await settle(page); + expect((await readLog(page)).clicks).toEqual([]); + // An inert subtree is no hit target: the shield, in the dialog, is. + expect(await hitAt(page, action)).toBe('shield:dialog'); + const state = async () => + page.evaluate(() => { + const button = document.getElementById('action'); + button?.focus(); + return { + inert: button?.closest('[inert]') != null, + focused: document.activeElement === button, + }; + }); + expect(await state()).toEqual({inert: true, focused: false}); + await driver.tap(action); + await driver.tap(message); + await settle(page); + expect(await readLog(page)).toEqual({clicks: [], taps: 0, toggles: []}); + await release(page); + expect(await state()).toEqual({inert: false, focused: true}); + expect(await hitAt(page, action)).toBe('action'); + await driver.tap(message); + await expect.poll(async () => (await readLog(page)).taps).toBe(1); +}); + +test('a light-dismissing popover stays open when a hold takes a tap outside it; after the release the same tap dismisses it', async ({ + page, +}) => { + const driver = await load(page); + await open(page, 'pop'); + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + const outside = {x: viewport.width / 2, y: viewport.height - 40}; + expect(await hitAt(page, outside)).toBe('under'); + await hold(page, 'pop', 'viewport'); + expect(await hitAt(page, outside)).toBe('shield:pop'); + await driver.tap(outside); + await settle(page); + expect( + await page.evaluate(() => + document.getElementById('pop')?.matches(':popover-open'), + ), + ).toBe(true); + expect(await readLog(page)).toEqual({ + clicks: [], + taps: 0, + toggles: ['pop:open'], + }); + await release(page); + await driver.tap(outside); + await expect + .poll(async () => (await readLog(page)).toggles) + .toEqual(['pop:open', 'pop:closed']); +}); + +test('a hit test that skips shields sees past a hold: under a held modal dialog’s shield, its own action', async ({ + page, +}) => { + await load(page); + await open(page, 'dialog'); + const action = await centre(page, 'action'); + await hold(page, 'dialog', 'viewport'); + // The opening finger's press resolves its row past the shield. + expect(await hitAt(page, action)).toBe('shield:dialog'); + expect(await hitPast(page, action)).toBe('action'); + await release(page); + expect(await hitAt(page, action)).toBe('action'); +}); + +test('a layer opened above a held one is what that hit test finds, and past the shield the held layer’s content', async ({ + page, +}) => { + await load(page); + await open(page, 'lift'); + const liftAction = await centre(page, 'liftAction'); + await hold(page, 'lift', 'viewport'); + expect(await hitAt(page, liftAction)).toBe('shield:lift'); + expect(await hitPast(page, liftAction)).toBe('liftAction'); + await open(page, 'pop'); + const popAction = await centre(page, 'popAction'); + expect(await hitAt(page, popAction)).toBe('popAction'); + expect(await hitPast(page, popAction)).toBe('popAction'); +}); + +// A full-viewport layer whose root takes no hits itself and lets its tint +// and content take them (ContextMenu's lift layer is pointer-events: none): +// the shield, inside that root, must not inherit that. +for (const rootStyle of ['pointer-events: none', 'visibility: hidden']) { + test(`a layer root with ${rootStyle} (its content opting back in) is held all the same: a tap reaches neither the content nor the page`, async ({ + page, + }) => { + const driver = await load(page); + await page.evaluate(style => { + const lift = document.getElementById('lift'); + if (lift != null) { + lift.style.cssText += `; ${style}`; + } + }, rootStyle); + await open(page, 'lift'); + const action = await centre(page, 'liftAction'); + const tint = {x: 12, y: 12}; + // Unheld, the content and the tint take the hits. + expect(await hitAt(page, action)).toBe('liftAction'); + expect(await hitAt(page, tint)).toBe('tint'); + await hold(page, 'lift', 'layer'); + expect(await hitAt(page, action)).toBe('shield:lift'); + expect(await hitAt(page, tint)).toBe('shield:lift'); + await driver.tap(action); + await driver.tap(tint); + await settle(page); + expect((await readLog(page)).clicks).toEqual([]); + await release(page); + expect(await hitAt(page, action)).toBe('liftAction'); + }); +} + +// Page zoom stays the browser's while a layer is held (spec:AST-094 FR10, +// FR18 and FR35; WCAG 1.4.4): the shield starts no pan, but a pinch on it +// still zooms the page. +for (const [id, scope] of [ + ['pop', 'viewport'], + ['dialog', 'layer'], +] as const) { + test(`while a ${scope === 'viewport' ? 'presentation' : 'dismissal'} holds ${id === 'pop' ? 'a popover' : 'a modal dialog'}, a one-finger pan scrolls nothing and a pinch still zooms the page; neither reaches the layer or the page`, async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling and pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + const driver = await load(page); + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + const middle = {x: viewport.width / 2, y: viewport.height / 2}; + const panUp = drag( + {x: middle.x, y: middle.y + 150}, + {x: middle.x, y: middle.y - 150}, + // A rest before the lift: no fling outlives the pan. + {durationMs: 200, restMs: 150}, + ); + // A page that scrolls under the layer: unheld, the same pan scrolls it. + await page.evaluate(() => { + const spacer = document.createElement('div'); + spacer.style.height = '300vh'; + document.body.append(spacer); + }); + await driver.play(panUp); + await expect + .poll(async () => page.evaluate(() => window.scrollY)) + .toBeGreaterThan(0); + await settle(page); + await page.evaluate(() => window.scrollTo(0, 0)); + await settle(page); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await open(page, id); + await hold(page, id, scope); + expect(await hitAt(page, middle)).toBe(`shield:${id}`); + await driver.play(panUp); + await settle(page); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + await driver.play( + pinch({ + center: middle, + fromDistance: 60, + toDistance: 220, + durationMs: 200, + }), + ); + await expect + .poll(async () => page.evaluate(() => window.visualViewport?.scale ?? 1)) + .toBeGreaterThan(1.2); + expect(await readLog(page)).toEqual({ + clicks: [], + taps: 0, + toggles: id === 'pop' ? ['pop:open'] : [], + }); + }); +} diff --git a/packages/core/src/Layer/inputHold.test.tsx b/packages/core/src/Layer/inputHold.test.tsx new file mode 100644 index 0000000000000..7129b4d4e8248 --- /dev/null +++ b/packages/core/src/Layer/inputHold.test.tsx @@ -0,0 +1,233 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../gestures/GestureHost'; +import { + holdInput, + isInHeldLayer, + isInputShield, + useInputHold, +} from './inputHold'; + +function deferred() { + let resolve: () => void = () => {}; + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +/** A layer root with a child, in the document. */ +function mountLayer(): {layer: HTMLElement; child: HTMLElement} { + const layer = document.createElement('div'); + const child = document.createElement('button'); + layer.append(child); + document.body.append(layer); + return {layer, child}; +} + +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('holdInput', () => { + it('holds a presenting layer with a shield over the viewport until its end settles', async () => { + const {layer, child} = mountLayer(); + const outside = document.createElement('div'); + document.body.append(outside); + const end = deferred(); + holdInput(layer, {until: end.promise, scope: 'viewport'}); + const shield = layer.lastElementChild as HTMLElement; + expect(shield).not.toBe(child); + expect(shield.getAttribute('popover')).toBe('manual'); + expect(shield.style.position).toBe('fixed'); + // No pan, and a pinch still zooms the page (spec:AST-094 FR10). + expect(shield.style.touchAction).toBe('pinch-zoom'); + // Its own hit-testing, whatever the layer root's (the browser proof holds + // a root that is pointer-events: none). + expect(shield.style.pointerEvents).toBe('auto'); + expect(shield.style.visibility).toBe('visible'); + expect(isInHeldLayer(child)).toBe(true); + expect(isInHeldLayer(shield)).toBe(true); + expect(isInHeldLayer(outside)).toBe(false); + // Keys and assistive technology stay live while presenting. + expect(layer.hasAttribute('inert')).toBe(false); + end.resolve(); + await flush(); + expect(shield.isConnected).toBe(false); + expect(isInHeldLayer(child)).toBe(false); + }); + + it('swallows a press and a click on the shield before they reach the page, and leaves a touch its start so a pinch can still zoom', () => { + const {layer} = mountLayer(); + holdInput(layer, {until: new Promise(() => {}), scope: 'viewport'}); + const shield = layer.lastElementChild as HTMLElement; + const seen: string[] = []; + for (const type of ['pointerdown', 'click', 'touchstart']) { + document.addEventListener(type, () => seen.push(type)); + } + const press = new Event('pointerdown', {bubbles: true, cancelable: true}); + const click = new MouseEvent('click', {bubbles: true, cancelable: true}); + const touch = new Event('touchstart', {bubbles: true, cancelable: true}); + shield.dispatchEvent(press); + shield.dispatchEvent(click); + shield.dispatchEvent(touch); + expect(seen).toEqual([]); + expect(press.defaultPrevented).toBe(true); + expect(click.defaultPrevented).toBe(true); + // Cancelling a touch's start would cancel the page's pinch-zoom too. + expect(touch.defaultPrevented).toBe(false); + }); + + it("holds a dismissing layer: its content inert, the layer itself live under the shield, each element's inert given back", async () => { + const {layer, child} = mountLayer(); + const already = document.createElement('div'); + already.setAttribute('inert', ''); + layer.append(already); + child.focus(); + expect(document.activeElement).toBe(child); + const end = deferred(); + holdInput(layer, {until: end.promise, scope: 'layer'}); + // Focus leaves the content: a focused element that turns inert would + // keep its key activation. + expect(document.activeElement).not.toBe(child); + const shield = layer.lastElementChild as HTMLElement; + expect(shield.getAttribute('popover')).toBe('manual'); + // The layer stays live so the shield inside it catches every hit: an + // inert subtree is no hit target. + expect(layer.hasAttribute('inert')).toBe(false); + expect(shield.hasAttribute('inert')).toBe(false); + expect(child.hasAttribute('inert')).toBe(true); + expect(isInHeldLayer(child)).toBe(true); + end.reject(new Error('interrupted')); + await flush(); + expect(child.hasAttribute('inert')).toBe(false); + expect(already.hasAttribute('inert')).toBe(true); + expect(shield.isConnected).toBe(false); + expect(isInHeldLayer(child)).toBe(false); + }); + + it('names a live hold’s shield, and only it, so a hit test can skip it', () => { + const {layer, child} = mountLayer(); + const popover = document.createElement('div'); + popover.setAttribute('popover', 'manual'); + document.body.append(popover); + const release = holdInput(layer, { + until: new Promise(() => {}), + scope: 'viewport', + }); + const shield = layer.lastElementChild as HTMLElement; + expect(isInputShield(shield)).toBe(true); + for (const other of [layer, child, popover, null]) { + expect(isInputShield(other)).toBe(false); + } + release(); + expect(isInputShield(shield)).toBe(false); + }); + + it('sees slotted content as held: the flat tree, a slotted node to its slot', () => { + const host = document.createElement('div'); + const slotted = document.createElement('button'); + host.append(slotted); + document.body.append(host); + const root = host.attachShadow({mode: 'open'}); + const layer = document.createElement('div'); + layer.append(document.createElement('slot')); + root.append(layer); + expect(isInHeldLayer(slotted)).toBe(false); + const release = holdInput(layer, { + until: new Promise(() => {}), + scope: 'viewport', + }); + expect(isInHeldLayer(slotted)).toBe(true); + release(); + expect(isInHeldLayer(slotted)).toBe(false); + }); + + it('takes an animator handle, ending with its finished', async () => { + const {layer, child} = mountLayer(); + const end = deferred(); + holdInput(layer, {until: {finished: end.promise}, scope: 'layer'}); + expect(isInHeldLayer(child)).toBe(true); + end.resolve(); + await flush(); + expect(isInHeldLayer(child)).toBe(false); + }); + + it('stacks holds on one layer, either scope, under one shield: it comes back when the last one ends', () => { + const {layer, child} = mountLayer(); + const first = holdInput(layer, { + until: new Promise(() => {}), + scope: 'viewport', + }); + const second = holdInput(layer, { + until: new Promise(() => {}), + scope: 'layer', + }); + expect(layer.querySelectorAll('[popover]')).toHaveLength(1); + expect(child.hasAttribute('inert')).toBe(true); + first(); + first(); + expect(isInHeldLayer(child)).toBe(true); + expect(layer.querySelectorAll('[popover]')).toHaveLength(1); + second(); + expect(isInHeldLayer(child)).toBe(false); + expect(child.hasAttribute('inert')).toBe(false); + expect(layer.querySelectorAll('[popover]')).toHaveLength(0); + }); + + it('registers nothing with the gesture host: the host asks isInHeldLayer for each touch', () => { + const addTouchGate = vi.spyOn(GestureHost.prototype, 'addTouchGate'); + const {layer, child} = mountLayer(); + const releaseLayer = holdInput(layer, { + until: new Promise(() => {}), + scope: 'layer', + }); + const release = holdInput(layer, { + until: new Promise(() => {}), + scope: 'viewport', + }); + expect(addTouchGate).not.toHaveBeenCalled(); + expect(isInHeldLayer(child)).toBe(true); + expect(isInHeldLayer(document.body)).toBe(false); + release(); + releaseLayer(); + expect(isInHeldLayer(child)).toBe(false); + }); +}); + +describe('useInputHold', () => { + function Bar({isHeld}: {isHeld: boolean}) { + const ref = useRef(null); + useInputHold(ref, {isHeld, scope: 'layer'}); + return ( +
+ +
+ ); + } + + it('holds the layer while asked, and releases it when no longer asked or unmounted', () => { + const view = render(); + const bar = view.getByTestId('bar'); + const back = view.getByRole('button', {name: 'Back', hidden: true}); + expect(back.hasAttribute('inert')).toBe(true); + expect(isInHeldLayer(back)).toBe(true); + view.rerender(); + expect(back.hasAttribute('inert')).toBe(false); + view.rerender(); + expect(back.hasAttribute('inert')).toBe(true); + view.unmount(); + expect(isInHeldLayer(bar)).toBe(false); + }); +}); diff --git a/packages/core/src/Layer/inputHold.ts b/packages/core/src/Layer/inputHold.ts new file mode 100644 index 0000000000000..4b571bb16b8bc --- /dev/null +++ b/packages/core/src/Layer/inputHold.ts @@ -0,0 +1,318 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file inputHold.ts + * @input A layer's root element, what ends the hold (a promise, or an + * animator handle's `finished`), and its scope; the flat-tree walk + * (utils/flatTree) + * @output holdInput, useInputHold, isInHeldLayer (which the gesture host + * asks for each touch) and isInputShield: input held away from a layer + * while its presentation runs + * @position The Layer module's one input hold, for every component + * whose layer presents or dismisses under a finger + * + * UIKit delivers no touch while a presentation runs: until it completes, a + * tap reaches nothing, and a dismissal drops every input until it ends + * (measured on iOS 26.2 with UIAlertController: taps 400 ms into its + * presentation ignored, 450 ms in handled). A hold covers one layer root + * until what ends it settles: + * + * - `scope: 'viewport'` (a presentation): a touch, a pointer press or a + * click anywhere reaches nothing. A transparent shield inside the layer + * covers the viewport from the top layer, so a tap on it lands inside the + * layer (a light-dismissing layer stays open) and starts no pan (iOS + * drops that input too while it presents), while a pinch on it still + * zooms the page, which stays the browser's (spec:AST-094 FR10, FR18, + * FR35; WCAG 1.4.4); the gesture host holds + * the layer's touches away from every recognizer. Keys and assistive + * technology stay live, as on iOS. + * - `scope: 'layer'` (a dismissal, an inert bar): the same shield, and the + * layer's content is inert (no focus, key or assistive-technology + * activation; a focused element inside it is blurred, since one that turns + * inert keeps its key activation). The layer element itself stays live, so + * the shield inside + * it still catches every hit: an inert subtree is no hit target, and a tap + * on it would otherwise land on whatever lies beneath. A modal dialog + * still receives its own `cancel` (Escape): the caller ignores it while + * held. + * + * The shield sets its own `pointer-events` and `visibility`, so a layer root + * that takes no hits itself (its content or a backdrop child opts back in) + * is held all the same. A hit test that must see past held input (a press + * the finger that opened the layer continues, captured before the hold + * began) skips shields and nothing else: the first element of + * `elementsFromPoint` for which `isInputShield` is false, so an + * overlay really above the layer still wins. The shield is a manual + * popover, so its `toggle` events reach a document listener that logs + * every popover's: such a log keeps to its own popovers. + * + * One hold per layer: holds on a held layer stack, and the layer comes back + * when the last one ends (when what ends it settles, resolved or rejected; + * when its release runs; on unmount, for the hook). Nothing is written to + * the DOM but the shield and the content's `inert` attribute. The gesture + * host asks `isInHeldLayer` itself for each touch, walking the flat tree as + * it does (a slotted node to its slot, a shadow root to its host): this + * module imports nothing from gestures/, so a component in the root entry + * can hold its layer statically and bring no gesture code + * (`architecture:react-component-runtime` INV13). + * + * SYNC: When modified, update inputHold.test.tsx, + * __tests__/inputHold.interaction.browser.spec.ts and the components + * that hold their layer (holdInput and useInputHold callers). + */ + +import {useEffect, type RefObject} from 'react'; +import {parentAcrossShadow} from '../utils/flatTree'; + +/** What a hold covers: the whole viewport, or the layer alone. */ +export type InputHoldScope = 'viewport' | 'layer'; + +/** What ends a hold: a promise, or an animator handle (its `finished`). */ +export type InputHoldEnd = + PromiseLike | {readonly finished: PromiseLike}; + +export interface InputHoldOptions { + /** The hold ends when this settles. */ + until: InputHoldEnd; + /** `'viewport'` while presenting, `'layer'` while dismissing. */ + scope: InputHoldScope; +} + +interface HeldLayer { + viewport: number; + layer: number; + shield: HTMLElement | null; + /** The content a dismissal made inert, and whether each was already. */ + inertContent: Map | null; +} + +const held = new WeakMap(); +const liveShields = new WeakSet(); + +/** + * Whether `node` is the shield of a live hold. A hit test that must see past + * held input takes the first element of `elementsFromPoint` that is not one. + */ +export function isInputShield(node: Node | null): boolean { + return node != null && liveShields.has(node); +} + +/** + * Whether `target` is inside a held layer, walking the flat tree as the + * gesture host does (parentAcrossShadow: a slotted node to its slot, a + * shadow root to its host). + */ +export function isInHeldLayer(target: Node | null): boolean { + for ( + let node: Node | null = target; + node != null; + node = parentAcrossShadow(node) + ) { + if (node instanceof Element && held.has(node)) { + return true; + } + } + return false; +} + +const SWALLOWED = [ + 'pointerdown', + 'pointerup', + 'touchstart', + 'touchend', + 'mousedown', + 'mouseup', + 'click', + 'contextmenu', +] as const; + +const swallow = (event: Event) => { + // A touch's start stays uncancelled: cancelling it would cancel the page's + // pinch-zoom too. The shield's `touch-action: pinch-zoom` keeps a finger + // from panning, and a tap's mouse events and click land on the shield, + // which swallows them. + if (event.cancelable && event.type !== 'touchstart') { + event.preventDefault(); + } + event.stopPropagation(); +}; + +/** A transparent top-layer cover of the viewport, inside `layer`. */ +function addShield(layer: Element): HTMLElement { + const shield = layer.ownerDocument.createElement('div'); + shield.setAttribute('popover', 'manual'); + shield.setAttribute('aria-hidden', 'true'); + Object.assign(shield.style, { + position: 'fixed', + inset: '0', + width: '100vw', + height: '100vh', + maxWidth: 'none', + maxHeight: 'none', + margin: '0', + padding: '0', + border: '0', + background: 'transparent', + overflow: 'hidden', + touchAction: 'pinch-zoom', + // The two inherited properties that decide hit-testing, set here so the + // shield takes hits whatever its layer root opts out of (a full-viewport + // layer that is `pointer-events: none` and lets a backdrop child take the + // hits): inherited, they would let a held tap fall through to the page. + pointerEvents: 'auto', + visibility: 'visible', + }); + for (const type of SWALLOWED) { + shield.addEventListener(type, swallow, {capture: true, passive: false}); + } + layer.append(shield); + liveShields.add(shield); + if (typeof shield.showPopover === 'function') { + try { + shield.showPopover(); + } catch { + // Not connected, or popovers unsupported: the shield still covers + // what it can as a fixed element. + } + } + return shield; +} + +/** + * Holds input away from `layer` until `until` settles. Returns the release + * (idempotent), for a hold that ends early. + */ +export function holdInput( + layer: Element, + {until, scope}: InputHoldOptions, +): () => void { + let state = held.get(layer); + if (state == null) { + state = {viewport: 0, layer: 0, shield: null, inertContent: null}; + held.set(layer, state); + } + if (state.shield == null) { + state.shield = addShield(layer); + } + if (scope === 'viewport') { + state.viewport += 1; + } else { + state.layer += 1; + if (state.layer === 1) { + state.inertContent = makeContentInert(layer, state.shield); + } + } + let isReleased = false; + const release = () => { + if (isReleased) { + return; + } + isReleased = true; + const current = held.get(layer); + if (current == null) { + return; + } + if (scope === 'viewport') { + current.viewport -= 1; + } else { + current.layer -= 1; + if (current.layer === 0 && current.inertContent != null) { + restoreInert(current.inertContent); + current.inertContent = null; + } + } + if (current.viewport === 0 && current.layer === 0) { + if (current.shield != null) { + liveShields.delete(current.shield); + current.shield.remove(); + } + held.delete(layer); + } + }; + const end = 'finished' in until ? until.finished : until; + void Promise.resolve(end).then(release, release); + return release; +} + +/** + * Makes the layer's content inert, the shield aside, and moves focus out of + * it: an element that turns inert while focused keeps its focus, and with it + * its key activation (Enter and Space still click a focused button), until + * the engine's focus fixup runs, if it runs at all (measured in Chromium and + * WebKit). Blurring it leaves focus on the document, as that fixup does. + */ +function makeContentInert( + layer: Element, + shield: Element, +): Map { + const content = new Map(); + for (const child of layer.children) { + if (child !== shield) { + content.set(child, child.hasAttribute('inert')); + child.setAttribute('inert', ''); + } + } + const focused = deepActiveElement(layer.ownerDocument); + if ( + focused instanceof HTMLElement && + [...content.keys()].some(child => isInFlatTree(child, focused)) + ) { + focused.blur(); + } + return content; +} + +/** The focused element, inside any shadow roots on the way down. */ +function deepActiveElement(doc: Document): Element | null { + let active = doc.activeElement; + while (active?.shadowRoot?.activeElement != null) { + active = active.shadowRoot.activeElement; + } + return active; +} + +/** Whether `node` is `root` or inside it, walking the flat tree. */ +function isInFlatTree(root: Node, node: Node): boolean { + for ( + let current: Node | null = node; + current != null; + current = parentAcrossShadow(current) + ) { + if (current === root) { + return true; + } + } + return false; +} + +function restoreInert(content: Map): void { + for (const [element, wasInert] of content) { + if (!wasInert) { + element.removeAttribute('inert'); + } + } +} + +/** + * Holds input away from the layer behind `ref` while `isHeld` is true (an + * inert bar, a presentation driven by state); released when `isHeld` turns + * false or the component unmounts. + */ +export function useInputHold( + ref: RefObject, + {isHeld, scope}: {isHeld: boolean; scope: InputHoldScope}, +): void { + useEffect(() => { + const layer = ref.current; + if (!isHeld || layer == null) { + return; + } + // Released by the cleanup alone. + return holdInput(layer, { + until: new Promise(() => {}), + scope, + }); + }, [ref, isHeld, scope]); +} diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 3f27bdf9e433a..943d86d3e3b5e 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -9,7 +9,15 @@ * SYNC: When MultiSelector.tsx API changes, update these tests. */ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from 'vitest'; import { act, render, @@ -38,6 +46,13 @@ import {InputGroup, InputGroupText} from '../InputGroup'; import {colorVars, focusVars} from '../theme/tokens.stylex'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +// SelectorBottomSheet loads the sheet lazily. Load it here, untimed, so a +// bottom-sheet test's findByRole waits only for the render, never for a cold +// import. +beforeAll(async () => { + await import('../BottomSheet/BottomSheet'); +}); + /** * Enters a search query as one change. Typing it key by key can land several * keystrokes in one frame, whose result announcements are then joined into diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 7878ec14ad2aa..73e46fa7586ac 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -9,7 +9,15 @@ * SYNC: When Selector.tsx API changes, update these tests. */ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from 'vitest'; import {readFileSync} from 'node:fs'; import { act, @@ -41,6 +49,12 @@ import {selectorPresentationStyles} from './selectorPresentation.stylex'; import {groupStyles} from '../InputGroup/groupStyles'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +// SelectorBottomSheet loads the sheet lazily. Load it here, untimed, so a bottom-sheet test's +// findByRole waits only for the render, never for a cold import. +beforeAll(async () => { + await import('../BottomSheet/BottomSheet'); +}); + /** * Enters a search query as one change. Typing it key by key can land several * keystrokes in one frame, whose result announcements are then joined into diff --git a/packages/core/src/Selector/__tests__/Selector.combobox.a11y.test.tsx b/packages/core/src/Selector/__tests__/Selector.combobox.a11y.test.tsx index f6d1e5845bfdf..06c4c6debb411 100644 --- a/packages/core/src/Selector/__tests__/Selector.combobox.a11y.test.tsx +++ b/packages/core/src/Selector/__tests__/Selector.combobox.a11y.test.tsx @@ -4,7 +4,7 @@ import {cleanup, render, screen, waitFor, within} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {describe, expect, it} from 'vitest'; +import {beforeAll, describe, expect, it} from 'vitest'; import { COMBOBOX_PATTERN, expectAccessibilitySpec, @@ -18,6 +18,12 @@ import { } from './Combobox.a11y.states'; import {SELECTOR_COMBOBOX_KNOWN_FAILURES} from './Combobox.a11y.known-failures'; +// SelectorBottomSheet loads the sheet lazily. Load it here, untimed, so the sheet scenario's +// findByRole waits only for the render, never for a cold import. +beforeAll(async () => { + await import('../../BottomSheet/BottomSheet'); +}); + installListboxDialogStubs(); const hidden = {hidden: true} as const; 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..76c4ec8b7d746 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); @@ -201,11 +225,11 @@ export class GestureArena { }; 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 +265,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 +277,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 +286,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 +332,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 +363,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 +390,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 +425,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 +452,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 +489,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 +544,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 +572,7 @@ export class GestureArena { } } if (isWaiting) { - recognizer.pending = next; + internals(recognizer).pending = next; this.claim(recognizer); return; } @@ -490,14 +596,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 +615,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 +630,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 +647,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 +657,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 + * cancelled while one of its fingers is moving (in that move's dispatch) + * keeps that moving 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 +680,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 +691,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 +700,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 +713,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 +864,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 +874,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 +960,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 +1039,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 +1099,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..734760e844ad6 --- /dev/null +++ b/packages/core/src/gestures/GestureHost.ts @@ -0,0 +1,2908 @@ +// 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; the + * input hold's held layers (Layer/inputHold `isInHeldLayer`) + * @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 + * + * FOR EVERY POINTER + * + * - Recognizers and press consumers attach for a mouse, a pen and a finger + * alike (`spec:AST-094` FR43, FR19). Where the primary pointer is coarse + * the host listens while something enabled could take a touch. Where it + * is fine, each element with an enabled recognizer or consumer keeps one + * passive `pointerdown` listener and the page has none; a press on it (a + * mouse's primary button, a pen, or a finger, whose `pointerdown` comes + * before its `touchstart`) installs the host's page listeners, which go + * once that press's contacts end (FR24). Under every pointer, an element + * whose recognizer claims a moving finger also keeps its blocking + * `touchmove`, a listener FR24 counts apart from the press's one. + * - A press consumer takes a mouse press as a press (DEC-21): PressTracker's + * fine-pointer profile paints at once and brakes no scroll (FR11). + * - The host follows the query (a tablet docking or undocking a mouse): + * turning fine cancels every touch in flight. + * - `perEventPointer` (AttachOptions), from before FR43, changes nothing. + * + * 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, except that a claimed pen's stylus touches are + * cancelled as a finger's, its touchend included (FR21). + * - `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. + * + * 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. A finger or a pen + * takes it under any primary pointer; a mouse never does (`spec:AST-017` + * FR59). + * - 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 {isInHeldLayer} from '../Layer/inputHold'; +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 {isPrimaryClick} from '../utils/primaryClick'; +import { + type DragSelectionReleaseOptions, + lockDragSelection, + 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; +} + +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). Gestures + * attach for every pointer (`spec:AST-094` FR43); where it is fine, an + * element that uses a gesture keeps one passive `pointerdown` listener + * (beside a claiming recognizer's blocking `touchmove`, FR19) and the + * page's listeners last only a press (FR24). + * @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; + /** + * Accepted and ignored: every attachment reads each event's pointer + * (`spec:AST-094` FR43). Kept so callers written before FR43 compile. + * @deprecated + */ + perEventPointer?: boolean; + /** + * The element is an action group, sibling actions a held pointer slides + * across (`family:action-groups` FR5). It is served like every + * attachment, for every pointer (`spec:AST-094` FR43). @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; +const PASSIVE = {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}; + +function hasStylusTouch(event: TouchEvent): boolean { + return touchesOf(event.changedTouches).some( + touch => (touch as StylusTouch).touchType === 'stylus', + ); +} + +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< + number, + (options?: DragSelectionReleaseOptions) => 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(); + /** + * A pen whose lift a recognizer claimed, until its stylus touchend: an + * iPad sends the pen's pointer events first (FR21, FR43). + */ + private isPenLiftClaimed = false; + /** 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; + /** The input stream subscriptions while installed. */ + private unsubscribers: (() => void)[] = []; + /** + * Where the primary pointer is fine, each element that uses a gesture + * listens for a press itself; the page's listeners last that press (FR24). + */ + private readonly armedElements = new Map void>(); + /** + * Where the primary pointer is fine, the elements whose enabled hover + * recognizer listens on the element itself (syncHoverListeners). + */ + private readonly hoverElements = 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, + onClaim: ids => this.lockClaimedDrags(ids), + }); + 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 (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; + 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; + this.refresh(element, registration); + this.syncListeners(); + return () => { + 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, + }; + this.registry.set(element, registration); + } + return registration; + } + + /** Whether anything attached could take input. */ + private needsInput(): boolean { + return this.registry.size > 0 || this.retainers > 0; + } + + private refresh(element: Element, registration: Registration): void { + if (registration.manageStyle && element instanceof HTMLElement) { + this.applyStyle(element, registration); + } + this.syncTouchMoveListener(element, registration); + } + + /** + * The element's blocking `touchmove`. For every pointer a claiming + * recognizer keeps it: a finger's claim cancels its moves here (FR19), a + * listener FR24 counts outside the one-listener budget a fine pointer's + * element keeps for its press (syncArmed). A kept press needs it only + * while a press is held: a phone's before every touch (Safari honours a + * blocking listener only when registered before the touch began), a fine + * pointer's only during an armed press, whose `pointerdown` comes before + * the touch it brings (FR24). + */ + private syncTouchMoveListener( + element: Element, + registration: Registration, + ): void { + const needsListener = + registration.recognizers.some( + recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer), + ) || + (registration.consumer?.keepsTouch != null && + registration.consumer.isEnabled?.() !== false && + (this.isCoarse || this.isArmedPress)); + // 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; + } + } + + private applyStyle(element: HTMLElement, registration: Registration): void { + // A disabled recognizer leaves the browser everything (FR24). + // Every pointer gets it: a touchscreen laptop's finger as a phone's (FR43). + const active = registration.recognizers.filter( + recognizer => recognizer.isEnabled, + ); + let style: GestureHostStyle = + active.length > 0 ? gestureHostStyle(active) : {}; + // A control that keeps its touch (a stepper's hold-repeat) takes taps in + // quick succession: the browser's double-tap zoom goes, as for a tap + // recognizer (`spec:AST-094` FR18), for every pointer (FR43). + if ( + style.touchAction == null && + 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); + } + this.registry.delete(element); + this.uninstallIfIdle(); + } + + /** + * Listen only while something could take a touch now (`spec:AST-094` + * FR24, FR43): where the primary pointer is coarse, an enabled recognizer + * or consumer, or a finger-scroll watch; where it is fine, only during a + * press an element armed (syncArmed). Otherwise no document listener is + * registered, so an idle 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, each element with an enabled + * recognizer or press consumer listens for a press itself: one passive + * `pointerdown` listener on the element. The host's page listeners come at + * that press and go once its contacts end, so an idle page has none + * (`spec:AST-094` FR24, FR43). A finger on a touchscreen whose primary + * pointer is fine sends its `pointerdown` before its `touchstart`, so the + * host hears both. + */ + private syncArmed(): void { + const wanted = new Set(); + if (!this.isCoarse) { + for (const [element, registration] of this.registry) { + const consumer = registration.consumer; + if ( + registration.recognizers.some(recognizer => recognizer.isEnabled) || + (consumer != null && 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( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + this.armedElements.set(element, () => { + element.removeEventListener( + 'pointerdown', + this.onArmedPress, + PASSIVE_CAPTURE, + ); + }); + } + if (wanted.size === 0 && this.isArmedPress) { + this.isArmedPress = false; + this.syncKeptPresses(); + } + this.syncHoverListeners(); + } + + /** + * Where the primary pointer is fine, a mouse or a pen hovering an element + * with an enabled hover recognizer reaches it through two passive + * listeners on that element, `pointermove` and `pointerout`, and no page + * listener (`spec:AST-094` FR24, FR43). Where it is coarse, the page's + * listeners carry hover. + */ + private syncHoverListeners(): void { + const wanted = new Set(); + if (!this.isCoarse) { + for (const [element, registration] of this.registry) { + if (registration.hovers.some(hover => hover.isEnabled)) { + wanted.add(element); + } + } + } + for (const [element, release] of this.hoverElements) { + if (!wanted.has(element)) { + release(); + this.hoverElements.delete(element); + } + } + for (const element of wanted) { + if (this.hoverElements.has(element)) { + continue; + } + element.addEventListener('pointermove', this.onElementHover, PASSIVE); + element.addEventListener('pointerout', this.onElementHoverOut, PASSIVE); + this.hoverElements.set(element, () => { + element.removeEventListener('pointermove', this.onElementHover); + element.removeEventListener('pointerout', this.onElementHoverOut); + }); + } + } + + /** A hovering mouse or pen moved over a hover element (fine pointer). */ + private readonly onElementHover = (event: Event): void => { + const move = event as PointerEvent; + // While the page listeners are on, they carry hover. + if ( + !this.isInstalled && + move.pointerType !== 'touch' && + move.buttons === 0 + ) { + this.updateHover(move); + } + }; + + /** A hovering mouse or pen left a hover element, or one inside it (fine pointer). */ + private readonly onElementHoverOut = (event: Event): void => { + const out = event as PointerEvent; + if (this.isInstalled || out.pointerType === 'touch') { + return; + } + if (out.relatedTarget == null) { + this.exitHover(out.pointerId); + } else { + // What it moved onto decides which hovers it is still over. + this.updateHover(out, out.relatedTarget); + } + }; + + /** A press on an armed element, a mouse's primary button, a pen or a finger: listen for this press. */ + private readonly onArmedPress = (event: Event): void => { + const press = event as PointerEvent; + if ( + this.isInstalled || + !isPointerKind(press.pointerType) || + (press.pointerType !== 'touch' && !isPrimaryClick(press)) + ) { + return; + } + this.isArmedPress = true; + this.syncKeptPresses(); + this.install(); + // A finger's or a pen's press brings the cancelable touchend and the + // contextmenu canceller, as wherever the host installs, so a claimed + // lift clicks nothing and a claimed hold opens no browser menu (FR21). A + // mouse's press needs neither. + if (press.pointerType !== 'mouse') { + this.syncClaimingListeners(); + } + // The window's capture phase is over for this event: hand it on. + this.onPointerDown(press); + }; + + /** A kept press's blocking `touchmove` follows the armed press (syncTouchMoveListener). */ + private syncKeptPresses(): void { + for (const [element, registration] of this.registry) { + if (registration.consumer?.keepsTouch != null) { + this.syncTouchMoveListener(element, registration); + } + } + } + + /** 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.syncKeptPresses(); + 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(); + } + + /** + * 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) || + registration.recognizers.some( + recognizer => recognizer.isEnabled && recognizer.claimsTouches, + ) + ) { + 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: it follows a finger's scroll, which a fine pointer's page + // leaves to the browser (FR24). + if (this.isCoarse && this.retainers > 0) { + return true; + } + let isUsed = false; + for (const registration of this.registry.values()) { + const consumer = registration.consumer; + if ( + (consumer != null && consumer.isEnabled?.() !== false) || + registration.recognizers.some(recognizer => recognizer.isEnabled) || + (this.isCoarse && registration.hovers.some(r => r.isEnabled)) + ) { + isUsed = true; + break; + } + } + // Where the primary pointer is fine, only while a press an element armed + // is held (syncArmed, FR24, FR43). + return isUsed && (this.isCoarse || 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; + // A layer the input hold holds takes no touch while its presentation + // runs: the host asks the hold itself, so the hold imports nothing from + // gestures/ and a component in the root entry can use it statically. + if ( + target instanceof Node && + (isInHeldLayer(target) || [...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) { + // Every pointer (`spec:AST-094` FR43), hover included. + recognizers.push(...registration.recognizers); + hovers.push(...registration.hovers); + } + if (hasConsumerOwner || pointerType == null) { + continue; + } + // A press consumer takes every pointer's press, a mouse's too + // (`spec:AST-094` FR43, DEC-21). + if (registration?.consumer != null) { + 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 for a finger or a pen under any primary pointer, never for a mouse + * (locksSelectionFor), 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(); + } + }); + } + } + + /** + * A mouse or a pen that a recognizer claims for a continuous gesture (a + * pan's drag) takes no text selection from then on (`spec:AST-094` FR43, + * FR41): a drag that moved before the claim started selecting, so that + * selection goes, and the claiming recognizers' elements refuse one until + * the pointer lifts, as a held finger's do. A press that claims at its own + * pointerdown, before any movement (an action group's press), has + * selected nothing: the page's selection stays, for the group's Copy or + * Cut, while a new one is still refused. A tap claims only at its lift, + * so a click keeps the selection it made. A finger keeps the touch-down + * lock alone. + */ + private lockClaimedDrags(ids: ReadonlyArray): void { + for (const id of ids) { + if (this.lockedBy.has(id)) { + continue; + } + const touch = this.arena.touches.find(each => each.id === id); + if (touch == null || touch.pointerType === 'touch') { + continue; + } + const elements = new Set(); + for (const recognizer of this.arena.participantsOf(id)) { + if ( + (recognizer.state === 'began' || recognizer.state === 'changed') && + recognizer.view instanceof Element + ) { + elements.add(recognizer.view); + } + } + if (elements.size > 0) { + this.lockedBy.set( + id, + lockDragSelection(elements, this.doc.defaultView, { + keepsSelection: + touch.x === touch.startX && touch.y === touch.startY, + }), + ); + } + } + } + + /** + * Undo `lockSelection` for a contact that ended, at once for a cancel; + * idempotent. + */ + private unlockSelection(id: number, isCancel = false): void { + this.lockedBy.get(id)?.({isCancel}); + this.lockedBy.delete(id); + } + + // --- Touch events ---------------------------------------------------------- + + /** + * Hold touches away from every participant while `gate(target)` answers + * true. Returns the release. The input hold's held layers need none: the + * host asks `isInHeldLayer` for every touch. + */ + 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); + // A second finger leaves the two to the page's pinch, whichever events + // the host reads fingers from (FR10). + if (this.fingerTargets.length > 1) { + this.failUnclaimedLoneFingerPans(); + } + // 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 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')) { + toLock.push({id, target, consumerElement}); + } + // The finger's events keep going to what the engine hit. + this.watchTarget( + touch.identifier, + native, + recognizers.some(claimsWhileMoving) || 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, + }); + } + 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); + } + // Again once the new fingers are delivered: a pinch's second finger may + // have landed on another one-finger pan, the next row (FR10). + if (this.fingerTargets.length > 1) { + this.failUnclaimedLoneFingerPans(); + } + // 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) || + // A kept press keeps its finger under any pointer (FR11, DEC-21). + 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 (hasStylusTouch(event)) { + 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); + if (hasStylusTouch(event)) { + // A pen's lift is its pointer's: the stylus touchend only refuses the + // compatibility mouse events and click when a gesture took the pen. + const isClaimed = + this.isPenLiftClaimed || + [...this.pointerIds.values()].some(id => this.arena.isClaimed(id)); + this.isPenLiftClaimed = false; + if (isClaimed && event.cancelable) { + event.preventDefault(); + } + } + // 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 === 'pen') { + // A new pen press: no earlier lift's stylus touchend is still owed. + this.isPenLiftClaimed = false; + } + if (event.pointerType === 'touch' && this.usesTouchEvents) { + this.pairPointer(event.pointerId, { + x: event.clientX, + y: event.clientY, + target: landedOn(event), + }); + return; + } + if ( + event.pointerType === 'touch' && + this.arena.touches.some( + touch => + touch.pointerType === 'touch' && + touch.phase !== 'ended' && + touch.phase !== 'cancelled', + ) + ) { + // Fingers from pointer events: a second one, wherever it lands, + // leaves the two to the page's pinch, as onTouchStart does (FR10). + this.failUnclaimedLoneFingerPans(); + } + // A right click, a pen's barrel button, or a ctrl-click on macOS (its + // context click) starts no press (FR43). + if (event.pointerType !== 'touch' && !isPrimaryClick(event)) { + 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, + }, + ]); + if ( + event.pointerType === 'touch' && + this.arena.touches.filter( + touch => + touch.pointerType === 'touch' && + touch.phase !== 'ended' && + touch.phase !== 'cancelled', + ).length > 1 + ) { + // Again once this finger is delivered: it may have landed on another + // one-finger pan (FR10). + this.failUnclaimedLoneFingerPans(); + } + if (locksSelectionFor(event.pointerType)) { + // 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, + ); + } + }; + + 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); + } + if (event.pointerType === 'pen' && this.usesTouchEvents) { + this.isPenLiftClaimed = clicked || needsSuppression(dispositions); + } + }; + + 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, true); + 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, + target: unknown = landedOn(event), + ): void { + if (!isPointerKind(event.pointerType)) { + return; + } + const {hovers} = this.participantsAt(target, 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()); + // As at a blur: the press sends no further pointer event, so an armed + // press's page listeners go now (FR24). + this.releaseArmedPress(); + } + }; + + private readonly onBlur = (): void => { + this.cancelEverything(this.arena.now()); + // The press a blur ends sends no further pointer event: an armed press's + // page listeners go now, as at its lift (FR24). + this.releaseArmedPress(); + }; + + /** 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, true); + } + 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..d6c23bc5968e4 --- /dev/null +++ b/packages/core/src/gestures/PressTracker.ts @@ -0,0 +1,397 @@ +// 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 || + // The scroll brake is a finger's (`spec:AST-094` FR11, DEC-21). + (touch.pointerType === 'touch' && + 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 { + 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..e356025eaa0ac --- /dev/null +++ b/packages/core/src/gestures/README.md @@ -0,0 +1,688 @@ +# 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 (`family:action-groups` FR5), served + like every attachment, for every pointer. 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 + +- **Pens.** A pen drives every recognizer and press tracker as a finger + does: it pans, taps and long-presses; its hold, where the hold does + something, keeps text unselected as a finger's does (FR41); a hovering + pen reaches a hover recognizer as a mouse does; its barrel button is a + context click and starts no press; a pinch never mixes a pen with a + finger; and the screen-edge pan, a finger's only, ignores it (FR43). +- **For every pointer.** Recognizers and press trackers attach for a mouse, + a pen and a finger alike (`spec:AST-094` FR43, FR19). Where the primary + pointer is coarse (`TOUCH_QUERY`, `../utils/coarsePointer.ts`), the host + listens while something enabled could take a touch. Where it is fine, each + element that uses a gesture keeps one passive `pointerdown` listener (and + a recognizer that claims a moving finger its blocking `touchmove`, which + FR24 counts outside that budget, FR19) and the page has none: a press on the element (a mouse's primary button, a + pen, or a finger, whose `pointerdown` comes before its `touchstart`) + adds the host's document and window listeners, and they go once that + press's contacts end, or when the window blurs during it (FR24). A press + tracker created with `keepsTouch` gets its blocking `touchmove` only + during such a press; where the pointer is coarse it keeps it, since + Safari honours a blocking listener only when it was registered before + the touch. An element with a hover recognizer listens for a hovering + mouse or pen with two passive listeners of its own, `pointermove` and + `pointerout`. A component that uses no gesture, and a + disabled recognizer or consumer, registers nothing; after a consumer's + `isEnabled()` changes, `host.update(element)` re-syncs. The screen-edge + pan stays off under a mouse, a trackpad and a pen (its recognizer allows + touches only; it takes no `allowedPointerTypes`), and a pinch + never mixes a pen with a finger (a recognizer takes one kind of pointer + at a time) (FR43). A press tracker takes a mouse press as a press, with + the fine-pointer profile: it paints at once, inside a scroller too, and + the scroll brake is a finger's only (FR11, DEC-21). A right click, a pen's + barrel button and a ctrl-click on macOS (its context click) start no press + (`isPrimaryClick`, `../utils/primaryClick.ts`). A mouse or a pen drag + that a recognizer claims takes no text selection from then on: the + selection the drag started goes, the claiming elements refuse one, and + the document refuses one too until the press's mouseup and click have + passed, since WebKit extends a drag's selection past them (FR41); a click + keeps its selection. The host follows the + query (a tablet docking or undocking a mouse): turning fine cancels every + touch in flight. `perEventPointer`, an option of `attach`, + `attachConsumer`, `useGestureRecognizer` and `usePressTracker` from + before FR43, is accepted and does nothing. +- **`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. A + finger or a pen takes the lock under any primary pointer, a touchscreen + laptop and a desktop pen tablet included; a mouse never does, so mouse + selection is unchanged (`spec:AST-017` FR59); 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). Its element + also gets `touch-action: manipulation`, for every pointer, as a tap + recognizer's does (`spec:AST-094` FR18), so taps in quick succession + never zoom. Where the primary pointer is fine the blocking listener comes + only with a press: the element keeps its one `pointerdown` listener + (FR24), the press adds the `touchmove` before its `touchstart`, and the + kept finger's moves are cancelled from then on, as on a phone (FR11, + DEC-21). 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`); gestures + themselves attach for every pointer (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 the stylus touchend of a pen a recognizer claimed, as a finger's, so no compatibility mouse event or click follows (iPad's order; spec:AST-094 FR21, FR43)", () => { + 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 stylus = (type: string, time: number, isLift: boolean) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + const pen = { + identifier: 77, + clientX: 10, + clientY: 10, + target: box, + touchType: 'stylus', + }; + Object.defineProperty(event, 'changedTouches', {value: [pen]}); + Object.defineProperty(event, 'touches', {value: isLift ? [] : [pen]}); + Object.defineProperty(event, 'timeStamp', {value: time}); + box.dispatchEvent(event); + return event; + }; + // iPad Safari: the pen's pointer events, then its stylus touches. + pointer('pointerdown', box, { + id: 9, + pointerType: 'pen', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + stylus('touchstart', 0, false); + pointer('pointerup', box, { + id: 9, + pointerType: 'pen', + x: 10, + y: 10, + time: 60, + }); + const end = stylus('touchend', 60, true); + expect(states).toEqual(['ended']); + expect(end.defaultPrevented).toBe(true); + }); + + it('leaves the stylus touchend of a pen no recognizer claimed to the browser', () => { + const {box, other} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'}))); + pointer('pointerdown', other, { + id: 9, + pointerType: 'pen', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + pointer('pointerup', other, { + id: 9, + pointerType: 'pen', + x: 10, + y: 10, + time: 60, + }); + const end = new Event('touchend', {bubbles: true, cancelable: true}); + const pen = { + identifier: 77, + clientX: 10, + clientY: 10, + target: other, + touchType: 'stylus', + }; + Object.defineProperty(end, 'changedTouches', {value: [pen]}); + Object.defineProperty(end, 'touches', {value: []}); + Object.defineProperty(end, 'timeStamp', {value: 60}); + other.dispatchEvent(end); + expect(end.defaultPrevented).toBe(false); + }); + + 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("hands a press's selection lock what it landed on inside an open shadow root, not the shadow host the document sees", () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = '
'; + const row = shadow.getElementById('row') as HTMLElement; + const field = shadow.getElementById('field') as HTMLElement; + const seen: (EventTarget | null)[] = []; + const hold = new LongPressGestureRecognizer({ + name: 'hold', + selectionLock: (_elements, target) => { + seen.push(target); + return () => {}; + }, + }); + detachers.push(host.attach(row, hold)); + pointer('pointerdown', field, { + pointerType: 'pen', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + expect(seen).toEqual([field]); + }); + + 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, so rapid taps never zoom, for every pointer (spec:AST-094 FR18, FR43)', () => { + 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, a touchscreen laptop's finger keeps it. + pointer.set(false); + expect(stepper.getAttribute('data-astryx-touch-action')).toBe( + 'manipulation', + ); + }); + + 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("locks for a finger and a pen where the primary pointer is fine, a touchscreen laptop's finger and a desktop pen, and never for a mouse (spec:AST-094 FR41, FR43; spec:AST-017 FR59)", () => { + detachers.push(asIosWebKit()); + detachers.push(pointerQuery(false).restore); + const {slider} = mount('
'); + const laptop = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + detachers.push( + laptop.attach( + slider, + new PanGestureRecognizer({ + direction: 'horizontal', + selectionLock: lockSelection, + }), + ), + ); + const at = {x: 5, y: 5}; + // A mouse keeps its released behavior: no lock. + pointer('pointerdown', slider, { + pointerType: 'mouse', + ...at, + buttons: 1, + time: 0, + }); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + pointer('pointerup', slider, {pointerType: 'mouse', ...at, time: 50}); + // A pen's hold acts as a finger's. + pointer('pointerdown', slider, { + pointerType: 'pen', + ...at, + buttons: 1, + time: 100, + }); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + pointer('pointerup', slider, {pointerType: 'pen', ...at, time: 150}); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + // A touchscreen laptop's finger: its pointerdown arms the press, then + // its touchstart takes the lock. + pointer('pointerdown', slider, { + id: 7, + pointerType: 'touch', + ...at, + buttons: 1, + time: 200, + }); + const finger = {id: 1, ...at}; + touch('touchstart', slider, [finger], [finger], 200); + expect(slider.style.getPropertyValue('user-select')).toBe('none'); + pointer('pointerup', slider, { + id: 7, + pointerType: 'touch', + ...at, + time: 250, + }); + touch('touchend', slider, [finger], [], 250); + expect(slider.style.getPropertyValue('user-select')).toBe(''); + }); + + 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("holds a held layer's targets away from every participant with no gate: the host asks the input hold", () => { + const {layer, button} = mount( + '
', + ); + const tap = new TapGestureRecognizer({name: 'tap'}); + detachers.push(host.attach(button, tap)); + const release = holdInput(layer, { + until: new Promise(() => {}), + scope: 'viewport', + }); + 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('where the primary pointer is fine (spec:AST-094 FR43, 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('follows the pointer: turning coarse installs it, turning fine cancels the touch in flight and keeps only what a press needs', () => { + 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'})), + ); + // Its touch-action is there for every pointer (FR43). + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + // 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('an idle press tracker leaves no document or window listener: they last only a press (spec:AST-094 FR24, FR43)', async () => { + const primary = pointerQuery(false); + detachers.push(primary.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), + }), + ), + ); + 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}; + // A finger's pointerdown comes before its touchstart. + pointer('pointerdown', stepper, { + id: 7, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + 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 everything working when the pointer turns fine (spec:AST-094 FR43)', () => { + const primary = pointerQuery(true); + detachers.push(primary.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), + host.attach(row, new LongPressGestureRecognizer({name: 'press'})), + ); + expect(row.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + primary.set(false); + // Every touch-action stays. + expect(row.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + 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}; + // A finger's pointerdown comes before its touchstart. + pointer('pointerdown', box, { + id: 9, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + // Its moves come from its pointer once paired, as in a browser. + pointer('pointermove', box, { + id: 9, + pointerType: 'touch', + x: 60, + y: 5, + buttons: 1, + time: 16, + }); + touch('touchmove', box, [moved], [moved], 16); + pointer('pointerup', box, { + id: 9, + pointerType: 'touch', + x: 60, + y: 5, + time: 32, + }); + 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); + }); +}); + +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, as on any element that uses a gesture (spec:AST-094 FR43)', () => { + 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), {capture: true, passive: true}], + ]); + expect(otherAdded.mock.calls).toEqual([ + ['pointerdown', expect.any(Function), {capture: true, passive: true}], + ]); + }); + + it('adds page listeners only during a press, removes them at the release, and serves the press to the group', async () => { + 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'); + // The press's contacts end; its listeners go after this task's events. + await Promise.resolve(); + 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', async () => { + 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})); + // A finger: its pointer events and, after them, its touch events. + pointer('pointerdown', one, { + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', one, [finger], [finger], 0); + const moved = {id: 1, x: 11, y: 10}; + pointer('pointermove', one, { + pointerType: 'touch', + x: 11, + y: 10, + buttons: 1, + time: 16, + }); + touch('touchmove', one, [moved], [moved], 16); + pointer('pointerup', one, {pointerType: 'touch', x: 11, y: 10, time: 60}); + touch('touchend', one, [moved], [], 60); + expect(states).toEqual(['ended']); + await Promise.resolve(); + 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), + {capture: true, passive: true}, + ); + 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([]); + }); + + it("keeps an element's passive pointerdown while any of its gestures stays attached, whichever goes first, and drops it with the last (family:action-groups; spec:AST-094 FR24)", () => { + const fine = new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + for (const order of ['group first', 'plain first'] as const) { + document.body.innerHTML = '
'; + const element = document.getElementById('group') as HTMLElement; + let pointerDowns = 0; + const add = element.addEventListener.bind(element); + const remove = element.removeEventListener.bind(element); + element.addEventListener = ((type: string, ...rest: [never, never?]) => { + if (type === 'pointerdown') { + pointerDowns += 1; + } + add(type, ...rest); + }) as typeof element.addEventListener; + element.removeEventListener = (( + type: string, + ...rest: [never, never?] + ) => { + if (type === 'pointerdown') { + pointerDowns -= 1; + } + remove(type, ...rest); + }) as typeof element.removeEventListener; + const detachGroup = fine.attach( + element, + new TapGestureRecognizer({name: 'group'}), + {actionGroup: true}, + ); + const detachPlain = fine.attach( + element, + new TapGestureRecognizer({name: 'plain'}), + {actionGroup: false}, + ); + expect(pointerDowns).toBe(1); + const [first, last] = + order === 'group first' + ? [detachGroup, detachPlain] + : [detachPlain, detachGroup]; + // One gesture left: the element is still a group. + first(); + expect(pointerDowns).toBe(1); + // The last one gone: nothing stays on it. + last(); + expect(pointerDowns).toBe(0); + } + }); +}); + +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('gestures attach for every pointer (spec:AST-094 FR43, FR19, FR24)', () => { + const gestureListeners = (spy: ReturnType) => + (spy.mock.calls as unknown[][]) + .map(call => call[0] as string) + .filter(t => /^(touch|pointer|click|blur|scroll|contextmenu)/.test(t)); + + function fineHost(): GestureHost { + return new GestureHost(document, { + clock, + isPrimaryPointerCoarse: () => false, + }); + } + + function mouse(type: string, target: EventTarget, x: number, time: number) { + return pointer(type, target, { + pointerType: 'mouse', + x, + y: 10, + buttons: type === 'pointerup' ? 0 : 1, + time, + }); + } + + it('keeps one passive pointerdown listener on each element that uses a gesture while idle, and no page listener', () => { + const {box, row} = mount('
'); + const added = vi.spyOn(document, 'addEventListener'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const rowAdded = vi.spyOn(row, 'addEventListener'); + // A spy an earlier case left keeps its calls. + for (const spy of [added, viewAdded, boxAdded, rowAdded]) { + spy.mockClear(); + } + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + fine.attachConsumer(row, new RecordingConsumer()), + ); + expect(gestureListeners(added)).toEqual([]); + expect(gestureListeners(viewAdded)).toEqual([]); + // The pan's blocking touchmove for a finger's claim counts outside the + // one-listener budget (FR24, FR19). + expect(gestureListeners(boxAdded).sort()).toEqual([ + 'pointerdown', + 'touchmove', + ]); + expect(gestureListeners(rowAdded)).toEqual(['pointerdown']); + }); + + it('pans with a mouse, its page listeners lasting only the press', async () => { + const {box} = mount('
'); + const added = vi.spyOn(window, 'addEventListener'); + const removed = vi.spyOn(window, 'removeEventListener'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + added.mockClear(); + mouse('pointerdown', box, 10, 0); + expect(gestureListeners(added)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup']), + ); + mouse('pointermove', box, 25, 16); + mouse('pointermove', box, 40, 32); + mouse('pointerup', box, 40, 48); + expect(states[0]).toBe('began'); + expect(states.at(-1)).toBe('ended'); + removed.mockClear(); + await Promise.resolve(); + expect(gestureListeners(removed)).toEqual( + expect.arrayContaining(['pointermove', 'pointerup']), + ); + }); + + it('pans with a pen', () => { + const {box} = mount('
'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + for (const [type, x, time] of [ + ['pointerdown', 10, 0], + ['pointermove', 25, 16], + ['pointermove', 40, 32], + ['pointerup', 40, 48], + ] as const) { + pointer(type, box, { + pointerType: 'pen', + x, + y: 10, + buttons: type === 'pointerup' ? 0 : 1, + time, + }); + } + expect(states[0]).toBe('began'); + expect(states.at(-1)).toBe('ended'); + }); + + it('gives a press consumer a mouse press, with no feedback-only mode (DEC-21)', () => { + const {row} = mount('
'); + const fine = fineHost(); + const consumer = new RecordingConsumer(); + detachers.push(fine.attachConsumer(row, consumer)); + mouse('pointerdown', row, 10, 0); + mouse('pointerup', row, 10, 80); + expect(consumer.events.map(event => event.split(':')[0])).toEqual([ + 'began', + 'ended', + ]); + }); + + it('starts nothing for a secondary button', () => { + const {box} = mount('
'); + const viewAdded = vi.spyOn(window, 'addEventListener'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(fine.attach(box, pan)); + viewAdded.mockClear(); + const down = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 1, + pointerType: 'mouse', + clientX: 10, + clientY: 10, + button: 2, + buttons: 2, + }); + box.dispatchEvent(down); + mouse('pointermove', box, 40, 32); + expect(gestureListeners(viewAdded)).toEqual([]); + expect(states).toEqual([]); + }); + + it("writes a pan's touch-action under a fine pointer too, for a touchscreen laptop's finger (FR18)", () => { + const {box} = mount('
'); + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + }); + + it("keeps a claiming recognizer's blocking touchmove listener on its element under a fine pointer, outside FR24's one-listener budget, so a touchscreen laptop's finger claims as on a phone (FR19)", () => { + const {box, tapper} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const tapperAdded = vi.spyOn(tapper, 'addEventListener'); + for (const spy of [boxAdded, tapperAdded]) { + spy.mockClear(); + } + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + fine.attach(tapper, new TapGestureRecognizer()), + ); + expect( + (boxAdded.mock.calls as unknown[][]).filter( + call => call[0] === 'touchmove', + ), + ).toEqual([['touchmove', expect.any(Function), {passive: false}]]); + // A tap claims only at its lift: still only its one listener. + expect(gestureListeners(tapperAdded)).toEqual(['pointerdown']); + }); + + it('claims a finger for a one-way pan under a fine pointer through its blocking touchmove, as the pan leaves the browser every direction (FR24, FR19)', () => { + const {box} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + boxAdded.mockClear(); + const fine = fineHost(); + // A one-way pan (a toast's swipe) leaves all four directions native, so + // no touch-action stops the browser's pan: only cancelling moves does, + // through a listener the element carries before the finger lands (the + // browser decides at touchstart whether a move can be cancelled). + const swipe = new PanGestureRecognizer({name: 'swipe', direction: 'right'}); + detachers.push(fine.attach(box, swipe)); + expect(box.getAttribute('data-astryx-touch-action')).toBeNull(); + expect( + (boxAdded.mock.calls as unknown[][]).filter( + call => call[0] === 'touchmove', + ), + ).toEqual([['touchmove', expect.any(Function), {passive: false}]]); + const finger = {id: 3, x: 5, y: 5}; + pointer('pointerdown', box, { + id: 9, + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', box, [finger], [finger], 0); + let last: Event | null = null; + for (const [x, time] of [ + [20, 16], + [40, 32], + [60, 48], + ] as const) { + pointer('pointermove', box, { + id: 9, + pointerType: 'touch', + x, + y: 5, + buttons: 1, + time, + }); + const moved = {id: 3, x, y: 5}; + last = touch('touchmove', box, [moved], [moved], time); + } + expect(['began', 'changed']).toContain(swipe.state); + expect(last?.defaultPrevented).toBe(true); + }); + + it('takes no text selection once a mouse or a pen pan claims: the selection its drag started goes, and its element refuses one until the lift (FR43, FR41)', async () => { + const {box} = mount('
Some text a drag would select
'); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(fine.attach(box, pan)); + const selection = window.getSelection() as Selection; + let time = 0; + for (const pointerType of ['mouse', 'pen'] as const) { + // The drag selects text before the pan claims at 10 px. + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(box); + selection.addRange(range); + time += 1000; + for (const [type, x, buttons] of [ + ['pointerdown', 10, 1], + ['pointermove', 30, 1], + ['pointermove', 50, 1], + ] as const) { + time += 16; + pointer(type, box, {pointerType, x, y: 10, buttons, time}); + } + expect(['began', 'changed']).toContain(pan.state); + expect(selection.rangeCount).toBe(0); + expect(box.style.getPropertyValue('user-select')).toBe('none'); + const start = new Event('selectstart', {bubbles: true, cancelable: true}); + box.dispatchEvent(start); + expect(start.defaultPrevented).toBe(true); + pointer('pointerup', box, {pointerType, x: 50, y: 10, time: time + 16}); + // The document's hold ends a task after the lift: wait for it, so no + // hold outlives the press. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(box.style.getPropertyValue('user-select')).toBe(''); + } + }); + + it("keeps the page's selection when a press claims at its own pointerdown, before any movement: a plain click on an action group's button leaves it for the group's Copy, while the hold still refuses a new one (FR41)", async () => { + const {text, group, copy} = mount( + '

Text the page selected

', + ); + const fine = fineHost(); + // The action-group adapter's recognizer: a long press with no minimum + // duration and no movement limit, which claims at the press's pointerdown. + const press = new LongPressGestureRecognizer({ + minimumPressDuration: 0, + allowableMovement: Number.POSITIVE_INFINITY, + }); + detachers.push(fine.attach(group, press, {actionGroup: true})); + const selection = window.getSelection() as Selection; + let time = 0; + for (const pointerType of ['mouse', 'pen'] as const) { + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(text); + selection.addRange(range); + time += 1000; + pointer('pointerdown', copy, { + pointerType, + x: 10, + y: 10, + buttons: 1, + time, + }); + expect(press.state).toBe('began'); + expect(selection.toString()).toBe('Text the page selected'); + const start = new Event('selectstart', {bubbles: true, cancelable: true}); + copy.dispatchEvent(start); + expect(start.defaultPrevented).toBe(true); + pointer('pointerup', copy, {pointerType, x: 10, y: 10, time: time + 80}); + await Promise.resolve(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(selection.toString()).toBe('Text the page selected'); + } + }); + + it('holds selection off document-wide while a mouse pan claims, as WebKit extends a drag selection past the pan: a selection begun elsewhere goes, until the press is over', async () => { + const {box, outside} = mount( + '

Text outside the pan

Pan
', + ); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + detachers.push(fine.attach(box, pan)); + const selection = window.getSelection() as Selection; + let time = 0; + for (const [type, x] of [ + ['pointerdown', 10], + ['pointermove', 30], + ['pointermove', 50], + ] as const) { + time += 16; + pointer(type, box, {pointerType: 'mouse', x, y: 10, buttons: 1, time}); + } + expect(['began', 'changed']).toContain(pan.state); + const elsewhere = new Event('selectstart', { + bubbles: true, + cancelable: true, + }); + outside.dispatchEvent(elsewhere); + expect(elsewhere.defaultPrevented).toBe(true); + // WebKit extends the drag's selection over the page without a selectstart. + const range = document.createRange(); + range.selectNodeContents(outside); + selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + expect(selection.rangeCount).toBe(0); + pointer('pointerup', box, { + pointerType: 'mouse', + x: 50, + y: 10, + time: time + 16, + }); + // The press's own mouseup and click come in the same task: the hold + // lasts past them. + await new Promise(resolve => setTimeout(resolve, 0)); + const later = new Event('selectstart', {bubbles: true, cancelable: true}); + outside.dispatchEvent(later); + expect(later.defaultPrevented).toBe(false); + }); + + for (const ending of ['pointercancel', 'blur', 'detach'] as const) { + it(`lifts the document-wide selection hold at once when a claimed mouse press ends in a ${ending}: no mouseup or click follows a cancel (FR41: "lifted at the lift or a cancel")`, () => { + const {box, outside} = mount( + '

Text outside the pan

Pan
', + ); + const fine = fineHost(); + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const detach = fine.attach(box, pan); + detachers.push(detach); + let time = 0; + for (const [type, x] of [ + ['pointerdown', 10], + ['pointermove', 30], + ['pointermove', 50], + ] as const) { + time += 16; + pointer(type, box, {pointerType: 'mouse', x, y: 10, buttons: 1, time}); + } + expect(['began', 'changed']).toContain(pan.state); + if (ending === 'pointercancel') { + pointer('pointercancel', document.body, { + pointerType: 'mouse', + x: 50, + y: 10, + time: time + 16, + }); + } else if (ending === 'blur') { + window.dispatchEvent(new Event('blur')); + } else { + detach(); + } + const later = new Event('selectstart', {bubbles: true, cancelable: true}); + outside.dispatchEvent(later); + expect(later.defaultPrevented).toBe(false); + }); + } + + it("leaves a mouse click's selection alone: a tap claims only at its lift", () => { + const {box} = mount('
Some selected text
'); + const fine = fineHost(); + detachers.push(fine.attach(box, new TapGestureRecognizer())); + const selection = window.getSelection() as Selection; + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(box); + selection.addRange(range); + mouse('pointerdown', box, 10, 0); + mouse('pointerup', box, 10, 80); + expect(selection.rangeCount).toBe(1); + expect(box.style.getPropertyValue('user-select')).toBe(''); + }); + + it("starts no press for a right click or a ctrl-click on macOS, that platform's context click, and starts one for a ctrl-click elsewhere (FR43)", () => { + const {row} = mount('
'); + const fine = fineHost(); + const consumer = new RecordingConsumer(); + detachers.push(fine.attachConsumer(row, consumer)); + const platform = vi.spyOn(navigator, 'platform', 'get'); + platform.mockReturnValue('MacIntel'); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + buttons: 2, + time: 0, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + button: 2, + time: 80, + }); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + ctrlKey: true, + time: 500, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + ctrlKey: true, + time: 580, + }); + expect(consumer.events).toEqual([]); + platform.mockReturnValue('Win32'); + pointer('pointerdown', row, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + ctrlKey: true, + time: 1000, + }); + pointer('pointerup', row, { + pointerType: 'mouse', + x: 5, + y: 5, + ctrlKey: true, + time: 1080, + }); + expect(consumer.events.length).toBeGreaterThan(0); + }); + + it("starts no press for a pen's barrel button, as the README says, under a fine or a coarse pointer (FR43)", () => { + for (const target of [fineHost(), host]) { + document.body.innerHTML = ''; + const {row} = mount('
'); + const consumer = new RecordingConsumer(); + detachers.push(target.attachConsumer(row, consumer)); + pointer('pointerdown', row, { + pointerType: 'pen', + x: 5, + y: 5, + button: 2, + buttons: 2, + time: 0, + }); + pointer('pointerup', row, { + pointerType: 'pen', + x: 5, + y: 5, + button: 2, + time: 80, + }); + expect(consumer.events).toEqual([]); + } + }); + + it("ends a fine-pointer finger's press on an action group at its lift, and the group's next press begins and ends as well (FR43, FR19)", () => { + vi.useFakeTimers(); + try { + const {group, a} = mount( + '
', + ); + const laptop = new GestureHost(document, { + isPrimaryPointerCoarse: () => false, + }); + // The action-group adapter's recognizer: a long press with no minimum + // duration and no movement limit, attached as an action group. + const press = new LongPressGestureRecognizer({ + minimumPressDuration: 0, + allowableMovement: Number.POSITIVE_INFINITY, + }); + const states: string[] = []; + press.addTransitionListener((_recognizer, _from, to) => states.push(to)); + detachers.push(laptop.attach(group, press, {actionGroup: true})); + // Chromium on a touchscreen laptop: pointerdown, then touchstart; at + // the lift, pointerup, then touchend. + const fingerPress = ( + pointerId: number, + touchId: number, + time: number, + ) => { + const finger = {id: touchId, x: 10, y: 10}; + pointer('pointerdown', a, { + id: pointerId, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time, + }); + touch('touchstart', a, [finger], [finger], time); + vi.advanceTimersByTime(20); + pointer('pointerup', a, { + id: pointerId, + pointerType: 'touch', + x: 10, + y: 10, + time: time + 50, + }); + touch('touchend', a, [finger], [], time + 50); + vi.advanceTimersByTime(20); + }; + fingerPress(31, 0, 0); + const first = states.splice(0); + fingerPress(32, 1, 1000); + expect(first).toEqual(expect.arrayContaining(['began', 'ended'])); + expect(states).toEqual(expect.arrayContaining(['began', 'ended'])); + expect(press.state).toBe('possible'); + } finally { + vi.useRealTimers(); + } + }); + + it('takes perEventPointer as it takes any attach, and warns for nothing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + detachers.push(() => warn.mockRestore()); + const {box, row} = mount('
'); + const boxAdded = vi.spyOn(box, 'addEventListener'); + const rowAdded = vi.spyOn(row, 'addEventListener'); + const fine = fineHost(); + detachers.push( + fine.attach(box, new PanGestureRecognizer({direction: 'horizontal'}), { + perEventPointer: true, + }), + fine.attach(row, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(gestureListeners(boxAdded)).toEqual(gestureListeners(rowAdded)); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +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('fails an unclaimed one-finger pan when a second finger lands there too, so the two fingers stay the page’s pinch (spec:AST-094 FR10)', () => { + 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, page} = mount('
'); + const pan = new PanGestureRecognizer({ + direction: 'horizontal', + maximumNumberOfTouches: 1, + }); + const states: string[] = []; + pan.addTransitionListener((_recognizer, _from, to) => states.push(to)); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + id: 7, + pointerType: 'touch', + x: 10, + y: 10, + buttons: 1, + time: 0, + }); + // A second finger lands elsewhere before the first has moved: a pinch. + pointer('pointerdown', page, { + id: 8, + pointerType: 'touch', + x: 200, + y: 10, + buttons: 1, + time: 16, + }); + for (const [x, time] of [ + [30, 32], + [60, 48], + ] as const) { + pointer('pointermove', box, { + id: 7, + pointerType: 'touch', + x, + y: 10, + buttons: 1, + time, + }); + } + expect(states).not.toContain('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..d9448db9a31b4 --- /dev/null +++ b/packages/core/src/gestures/__tests__/PressTracker.test.ts @@ -0,0 +1,309 @@ +// 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'); + }); +}); + +describe('PressTracker under a fine pointer (spec:AST-094 FR11, DEC-21)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('paints a mouse or a pen press at once, inside a scroller too: the fine profile has no delay', () => { + for (const pointerType of ['mouse', 'pen'] as const) { + const {events, highlights, tracker, h} = setup(); + h.finger(1).down(150, 120, 0, { + recognizers: [], + consumer: tracker, + pointerType, + scroll: scrollProbe(() => true), + }); + expect(highlights).toEqual([true]); + h.finger(1).up(80); + expect(events).toEqual(['touchDown', 'touchUpInside']); + } + }); + + it('brakes only a finger: a mouse press on a scroller a flick set moving still presses', () => { + const events: ControlEvent[] = []; + const highlights: boolean[] = []; + const tracker = new PressTracker({ + getBounds: () => BOUNDS, + onControlEvent: event => events.push(event), + onHighlightChange: value => highlights.push(value), + isBraking: () => true, + }); + const h = createHarness(); + h.finger(1).down(150, 120, 0, {recognizers: [], consumer: tracker}); + h.finger(1).up(80); + expect(events).toEqual([]); + h.finger(2).down(150, 120, 400, { + recognizers: [], + consumer: tracker, + pointerType: 'mouse', + }); + expect(highlights).toEqual([true]); + h.finger(2).up(480); + expect(events).toEqual(['touchDown', 'touchUpInside']); + }); +}); 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..ce1eedcb59e39 --- /dev/null +++ b/packages/core/src/gestures/__tests__/ScreenEdgePanGestureRecognizer.test.ts @@ -0,0 +1,245 @@ +// 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("stays off under a mouse and a pen: a drag from the edge band is a finger's only (spec:AST-094 FR43)", () => { + for (const pointerType of ['mouse', 'pen'] as const) { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({name: 'edge'}); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge], pointerType}) + .drag(40, 4, 50); + expect(h.log.some(line => line.startsWith('edge:began'))).toBe(false); + } + }); + + it('is locked to a finger: a caller cannot widen its pointers, so a pen drag from the edge band still does not begin (spec:AST-094 FR43)', () => { + const h = createHarness(); + const edge = new ScreenEdgePanGestureRecognizer({ + name: 'edge', + // @ts-expect-error the screen-edge pan takes no allowedPointerTypes + allowedPointerTypes: ['touch', 'pen'], + }); + expect([...(edge.allowedPointerTypes ?? [])]).toEqual(['touch']); + h.finger(1) + .down(10, 300, 0, {recognizers: [edge], pointerType: 'pen'}) + .drag(40, 4, 50); + expect(h.log.some(line => line.startsWith('edge:began'))).toBe(false); + }); + + 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..52eca5532a119 --- /dev/null +++ b/packages/core/src/gestures/__tests__/browser/gestures.browser.spec.ts @@ -0,0 +1,1619 @@ +// 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, gestures + * for every pointer with nothing listening while idle where the primary + * pointer is fine, nothing for a disabled recognizer, + * 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 still writes touch-action, leaves the page no listener while idle, and pans with a mouse (spec:AST-094 FR43, 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
'; + const pan = new PanGestureRecognizer({direction: 'horizontal'}); + const states: string[] = []; + (window as unknown as {__panStates: string[]}).__panStates = states; + pan.addTransitionListener((_current, _from, to) => states.push(to)); + host.attach(document.getElementById('drag') as HTMLElement, pan); + host.attach( + document.getElementById('hold') as HTMLElement, + new LongPressGestureRecognizer(), + ); + }); + // A touchscreen laptop's finger needs the same touch-action as a phone's. + expect(await footprint(page, 'drag')).toEqual({ + touchAction: 'pan-y pinch-zoom', + pageTouchListeners: 0, + }); + expect((await footprint(page, 'hold')).touchAction).toBe('manipulation'); + // A real mouse drag pans; the page's listeners last only the press. + const box = await page.locator('#drag').boundingBox(); + if (box == null) { + throw new Error('no box for #drag'); + } + const y = box.y + box.height / 2; + await page.mouse.move(box.x + 20, y); + await page.mouse.down(); + await page.mouse.move(box.x + 100, y, {steps: 8}); + await page.mouse.up(); + const states = await page.evaluate( + () => (window as unknown as {__panStates: string[]}).__panStates, + ); + expect(states[0]).toBe('began'); + expect(states).toContain('ended'); + expect((await footprint(page, 'drag')).pageTouchListeners).toBe(0); + } 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("a mouse drag a pan claimed selects no page text, though it carries past the pan's element over text (spec:AST-094 FR43, FR41)", async ({ + page, + }) => { + await page.goto('about:blank'); + await page.setContent( + '', + ); + await page.addScriptTag({content: bareBundle}); + await page.evaluate(() => { + const {host, PanGestureRecognizer} = window.__api; + document.body.style.font = '16px sans-serif'; + document.body.innerHTML = + '

A story heading with words

A description with several words in it.

Card text a drag would select

Text below the card, with words.

'; + host.attach( + document.getElementById('card') as HTMLElement, + new PanGestureRecognizer(), + ); + }); + const card = await page.locator('#card').boundingBox(); + if (card == null) { + throw new Error('no box for #card'); + } + const x = card.x + 40; + const y = card.y + card.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + // Past the pan's start, then up over the text above the card. + await page.mouse.move(x + 30, y - 10, {steps: 4}); + await page.mouse.move(x + 60, card.y - 80, {steps: 12}); + // selectionchange is dispatched as its own task: read the page once it + // has run, as the next frame would paint it. + const during = await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => + resolve(String(window.getSelection() ?? '').trim()), + ); + }), + ); + await page.mouse.up(); + const after = await page.evaluate(() => + String(window.getSelection() ?? '').trim(), + ); + expect([during, after]).toEqual(['', '']); + }); + + test("the host registers no page touch listener while idle and warns for nothing, though the page attaches recognizers: an element keeps only a claiming recognizer's blocking touchmove; a mouse drag pans (spec:AST-094 FR43, FR24, FR19)", async ({ + page, + }) => { + 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}:${entry.type}:${String(entry.passive)}`, + ), + // 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([]); + // Only the elements whose recognizer claims a moving finger carry a + // touch listener: the blocking touchmove, which FR24 counts outside the + // one-listener budget, so a touchscreen laptop's finger claims as on a + // phone (FR19). A kept press gets its own only during a press. + expect(seen.elements).toEqual( + [ + 'hpan', + 'presser', + 'pinchable', + 'sheetBody', + 'card', + 'slider', + 'switchRow', + 'onePan', + ].map(id => `${id}:touchmove:false`), + ); + expect(seen.styles).toEqual(['||', '||', '||', '||']); + // Gestures attach for every pointer: nothing to warn about. + expect( + warnings.filter(text => text.includes('primary pointer is fine')), + ).toEqual([]); + // A real mouse drag pans the horizontal pan. + const box = await page.locator('#hpan').boundingBox(); + if (box == null) { + throw new Error('no box for #hpan'); + } + const y = box.y + box.height / 2; + await page.mouse.move(box.x + 20, y); + await page.mouse.down(); + await page.mouse.move(box.x + 120, y, {steps: 8}); + await page.mouse.up(); + const hpan = await page.evaluate(() => + window.__gestures + .filter(entry => entry.name === 'hpan') + .map(entry => entry.state), + ); + expect(hpan[0]).toBe('began'); + expect(hpan).toContain('ended'); + }); +}); + +/** + * 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: []}]); + }); +} + +/** + * A screen-edge pan on an element that reaches beyond its band (200 px wide, + * from the left edge), over the same horizontal scroller. + */ +async function openWideEdgePan(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: 9, begins: false}, + {travel: 10, begins: true}, +]) { + test(`a screen-edge pan on an element wider than its band ${run.begins ? 'claims at' : 'has not claimed at'} ${run.travel} px of travel from a touch in the band (spec:AST-094 FR18–FR22, DEC-22)`, async ({ + page, + browserName, + }) => { + const driver = await openWideEdgePan(page, browserName); + await logTouchMoves(page); + await driver.play( + drag({x: 20, y: 150}, {x: 20 + 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__/finePointerListeners.test.ts b/packages/core/src/gestures/__tests__/finePointerListeners.test.ts new file mode 100644 index 0000000000000..64afa75e9a5d4 --- /dev/null +++ b/packages/core/src/gestures/__tests__/finePointerListeners.test.ts @@ -0,0 +1,316 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file finePointerListeners.test.ts + * @input GestureHost under a fine primary pointer, with the shared input + * stream counted and synthetic pointer and touch events + * @output What the host listens with where the primary pointer is fine + * (`spec:AST-094` FR24, FR43): its page listeners go when the window blurs + * during a press; a kept press has its blocking touchmove only during a + * press; a mouse or a pen drives a hover recognizer through listeners on + * its element alone + * @position Unit tests of packages/core/src/gestures/GestureHost.ts + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {GestureHost} from '../GestureHost'; +import {HoverGestureRecognizer} from '../HoverGestureRecognizer'; +import {LongPressGestureRecognizer} from '../LongPressGestureRecognizer'; +import {PanGestureRecognizer} from '../PanGestureRecognizer'; +import {PressTracker} from '../PressTracker'; +import {TapGestureRecognizer} from '../TapGestureRecognizer'; +import type * as InputStreamModule from '../../utils/inputStream'; +import {ManualClock} from './harness'; + +/** Live subscriptions to the shared input stream, by event type. */ +const subscriptions = new Map(); + +vi.mock('../../utils/inputStream', async importOriginal => { + const real = await importOriginal(); + return { + ...real, + subscribeInput: ((...args: Parameters) => { + const type = args[1]; + subscriptions.set(type, (subscriptions.get(type) ?? 0) + 1); + const unsubscribe = real.subscribeInput(...args); + let isDone = false; + return () => { + if (!isDone) { + isDone = true; + subscriptions.set(type, (subscriptions.get(type) ?? 0) - 1); + } + unsubscribe(); + }; + }) as typeof real.subscribeInput, + }; +}); + +function pageListeners(): string[] { + return [...subscriptions] + .filter(([, count]) => count > 0) + .map(([type]) => type) + .sort(); +} + +function pointer( + type: string, + target: EventTarget, + init: { + pointerType: string; + x: number; + y: number; + buttons?: number; + time: number; + relatedTarget?: EventTarget | null; + }, +): void { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: init.pointerType === 'mouse' ? 1 : 2, + pointerType: init.pointerType, + clientX: init.x, + clientY: init.y, + buttons: init.buttons ?? 0, + relatedTarget: init.relatedTarget ?? null, + }); + Object.defineProperty(event, 'timeStamp', {value: init.time}); + target.dispatchEvent(event); +} + +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 detachers: (() => void)[]; +let host: GestureHost; + +function mount(html: string): Record { + document.body.innerHTML = html; + const elements: Record = {}; + for (const element of document.querySelectorAll('[id]')) { + elements[element.id] = element; + } + return elements; +} + +/** The element's live `touchmove` listeners, counted as they come and go. */ +function countTouchMoves(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 === 'touchmove') { + count += 1; + } + add(type, ...rest); + }) as typeof element.addEventListener; + element.removeEventListener = ((type: string, ...rest: [never, never?]) => { + if (type === 'touchmove') { + count -= 1; + } + remove(type, ...rest); + }) as typeof element.removeEventListener; + return () => count; +} + +beforeEach(() => { + subscriptions.clear(); + detachers = []; + host = new GestureHost(document, { + clock: new ManualClock(), + isPrimaryPointerCoarse: () => false, + }); +}); + +afterEach(() => { + for (const detach of detachers.splice(0)) { + detach(); + } + document.body.innerHTML = ''; +}); + +describe('a fine primary pointer', () => { + it('takes down the page listeners when the window blurs during a press', async () => { + const {box} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer())); + expect(pageListeners()).toEqual([]); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + expect(pageListeners()).toContain('pointermove'); + window.dispatchEvent(new Event('blur')); + await Promise.resolve(); + expect(pageListeners()).toEqual([]); + }); + + it("gives a kept press its blocking touchmove only during a press, as a phone's needs it only for a finger", async () => { + const {stepper} = mount('
'); + const touchMoves = countTouchMoves(stepper); + detachers.push( + host.attachConsumer( + stepper, + new PressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + expect(touchMoves()).toBe(0); + // A touchscreen laptop's finger: its pointerdown comes before its + // touchstart, so the listener is in place before the first touchmove. + pointer('pointerdown', stepper, { + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + expect(touchMoves()).toBeGreaterThan(0); + touch('touchstart', stepper, {id: 1, x: 5, y: 5}, 0); + pointer('pointerup', stepper, {pointerType: 'touch', x: 5, y: 5, time: 50}); + touch('touchend', stepper, {id: 1, x: 5, y: 5}, 50, true); + await Promise.resolve(); + expect(touchMoves()).toBe(0); + }); + + it('drives a hover recognizer for a mouse or a pen, with listeners on its element alone (FR43)', () => { + const {card, outside} = mount( + '
', + ); + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(() => states.push(hover.state)); + detachers.push(host.attach(card, hover)); + for (const pointerType of ['mouse', 'pen']) { + pointer('pointermove', card, {pointerType, x: 5, y: 5, time: 0}); + pointer('pointermove', card, {pointerType, x: 8, y: 5, time: 16}); + pointer('pointerout', card, { + pointerType, + x: 120, + y: 5, + time: 32, + relatedTarget: outside, + }); + } + expect(states).toEqual([ + 'began', + 'changed', + 'ended', + 'began', + 'changed', + 'ended', + ]); + expect(pageListeners()).toEqual([]); + }); + + it("cancels a claimed finger's lift and the browser's menu during an armed press, as where the primary pointer is coarse (spec:AST-094 FR21)", () => { + const {sheet} = mount('
'); + detachers.push( + host.attach(sheet, new PanGestureRecognizer({direction: 'horizontal'})), + host.attach(sheet, new LongPressGestureRecognizer({name: 'press'})), + ); + // A touchscreen laptop's finger: its pointerdown arms the press. + pointer('pointerdown', sheet, { + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', sheet, {id: 1, x: 5, y: 5}, 0); + // The finger's pointer events sample it; its touchmove follows each. + pointer('pointermove', sheet, { + pointerType: 'touch', + x: 40, + y: 5, + buttons: 1, + time: 32, + }); + touch('touchmove', sheet, {id: 1, x: 40, y: 5}, 32); + // The browser's own menu for that finger stays off. + const menu = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + sheet.dispatchEvent(menu); + expect(menu.defaultPrevented).toBe(true); + // The claimed finger's lift clicks nothing. + pointer('pointerup', sheet, {pointerType: 'touch', x: 40, y: 5, time: 48}); + const lift = new Event('touchend', {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: 40, clientY: 5, target: sheet}; + Object.defineProperty(lift, 'changedTouches', {value: [entry]}); + Object.defineProperty(lift, 'touches', {value: []}); + Object.defineProperty(lift, 'timeStamp', {value: 48}); + sheet.dispatchEvent(lift); + expect(lift.defaultPrevented).toBe(true); + }); + + it("keeps a kept press's finger during an armed press: its moves are cancelled, as on a phone (spec:AST-094 FR11, DEC-21)", () => { + const {stepper} = mount('
'); + detachers.push( + host.attachConsumer( + stepper, + new PressTracker({getBounds: () => null, keepsTouch: true}), + ), + ); + pointer('pointerdown', stepper, { + pointerType: 'touch', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + touch('touchstart', stepper, {id: 1, x: 5, y: 5}, 0); + const move = new Event('touchmove', {bubbles: true, cancelable: true}); + const entry = {identifier: 1, clientX: 9, clientY: 5, target: stepper}; + Object.defineProperty(move, 'changedTouches', {value: [entry]}); + Object.defineProperty(move, 'touches', {value: [entry]}); + Object.defineProperty(move, 'timeStamp', {value: 16}); + stepper.dispatchEvent(move); + expect(move.defaultPrevented).toBe(true); + touch('touchend', stepper, {id: 1, x: 9, y: 5}, 50, true); + }); + + it('takes down the page listeners when the page is hidden during a press (spec:AST-094 FR24)', async () => { + const {box} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer())); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + expect(pageListeners()).toContain('pointermove'); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + try { + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + expect(pageListeners()).toEqual([]); + } finally { + Reflect.deleteProperty(document, 'visibilityState'); + } + }); +}); 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..97143c3e07681 --- /dev/null +++ b/packages/core/src/gestures/__tests__/useGestureRecognizer.test.tsx @@ -0,0 +1,1242 @@ +// 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 for every pointer: a mouse drag pans where the primary pointer is fine (spec:AST-094 FR43)', () => { + 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, + ); + return
; + } + const {getByTestId, unmount} = render(); + const surface = getByTestId('surface'); + const mouse = (type: string, x: number, time: number) => { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 9, + pointerType: 'mouse', + clientX: x, + clientY: 0, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + }); + Object.defineProperty(event, 'timeStamp', {value: time}); + surface.dispatchEvent(event); + }; + mouse('pointerdown', 0, 0); + mouse('pointermove', 30, 16); + expect(onPan).toHaveBeenCalled(); + mouse('pointerup', 30, 32); + 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('serves a mouse press under a fine pointer, on an action group or not (family:action-groups FR5; spec:AST-094 FR43)', () => { + const primary = pointerQuery(false); + 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 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( + , + ); + const action = getByTestId('action'); + // Not a group: served too, as every attachment (FR43). + press(action, 1, 0); + expect(states).toEqual(['ended']); + act(() => { + rerender(); + }); + press(action, 2, 1000); + expect(states).toEqual(['ended', 'ended']); + unmount(); + } finally { + primary.restore(); + } + }); + + 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('gives a press tracker a mouse press where the primary pointer is fine, with or without perEventPointer (spec:AST-094 FR43, DEC-21)', () => { + 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); + // A mouse press is a press (DEC-21). + expect(held).toEqual(['touchDown', 'touchUpInside']); + const other = getByTestId('other'); + boundsOf(other, 44, 44); + mouse('pointerdown', other, 2); + mouse('pointerup', other, 2); + expect(inactive).toEqual(['touchDown', 'touchUpInside']); + } 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 `