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/list-row-press-compositor.md b/.changeset/list-row-press-compositor.md new file mode 100644 index 0000000000000..d858eba055251 --- /dev/null +++ b/.changeset/list-row-press-compositor.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A list row's touch press release keeps fading while the page runs script. `ListItem`, `Item` and `TreeList` rows used to stay fully pressed after the finger lifted until the main thread was free, and every frame of the fade repainted the row; the release now runs on the compositor, with the same color, clock and curve as before. Under a finger, while a row is pressed and through its release, the press paints on the row's `::before` and the row is its own stacking context; at rest nothing of it applies, so a row's own `::before` decoration and its children's stacking are as they were. During a press the layer is the row's `::before`, and any `::before` property a caller sets, from `xstyle` (which `Item` composes after the layer) or from a stylesheet, wins over the layer's: a decoration with its own size and background jumps to the row's corner, and the row shows no pressed fill until the release ends. Decoration that should stay through a press belongs on `::after` or a child element. With a mouse or trackpad the row is unchanged: no layer and no new stacking context. + +@vjeux diff --git a/.changeset/list-touch-hit-area.md b/.changeset/list-touch-hit-area.md new file mode 100644 index 0000000000000..eee89371d2ed5 --- /dev/null +++ b/.changeset/list-touch-hit-area.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a finger (a coarse primary pointer), an interactive `List` row's hit target grows toward 44px where its neighbors leave room: the shared touch hit area measures each row and stops at half the gap to the nearest neighboring row or control. Rows look and lay out exactly as before, and a mouse is unchanged. Under a finger a list row's `::after` is that invisible layer, so decorate a row with a child instead. + +@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/number-input-stepper-every-pointer.md b/.changeset/number-input-stepper-every-pointer.md new file mode 100644 index 0000000000000..5cfc73369b75f --- /dev/null +++ b/.changeset/number-input-stepper-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] NumberInput: a mouse, a pen and a finger press the number steppers through one press model, under any primary pointer (AST-094 FR43). On a touchscreen laptop a finger's quick taps on a stepper no longer zoom the page. A held mouse keeps its press within 70 px of the steppers, as a finger does, and a right click presses nothing. + +@vjeux diff --git a/.changeset/number-input-stepper-hold.md b/.changeset/number-input-stepper-hold.md new file mode 100644 index 0000000000000..40786ac2dd0c3 --- /dev/null +++ b/.changeset/number-input-stepper-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] NumberInput: a number stepper held down keeps stepping, under a finger, a pen or a mouse, on iOS and Android alike: once after half a second, as a native iOS stepper does, then on one steady ramp that starts at a quarter of a second and quickens by a tenth each step, down to a twentieth of a second. A tap steps exactly once, on the click the browser sends when it lifts. While a press steps, the field shows each value, and `onChange` is called once, with the final value, when the press ends; a controlled field then shows whatever value its parent passes back. Sliding onto the other stepper carries the repeat to it; sliding away pauses it, and coming back resumes it, never with a step at once. The pressed stepper lights at once and clears at once. A held finger selects no text and opens no callout: in iOS Safari, resting a finger on a stepper used to select the field's label and open the edit menu over the field. A scroll that starts on a stepper still scrolls and steps nothing, and keyboard and assistive-technology activation are unchanged. + +@vjeux diff --git a/.changeset/number-input-stepper-touch-control.md b/.changeset/number-input-stepper-touch-control.md new file mode 100644 index 0000000000000..ff12bae07d486 --- /dev/null +++ b/.changeset/number-input-stepper-touch-control.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] NumberInput: under a finger, on iOS and Android alike, the number steppers sit beside the field as one control shaped like the iOS stepper: a 94 px wide capsule at the field's size (28, 32 or 36 px tall) with − at the start and + at the end, mirrored in right-to-left layouts, a divider between them, and the theme's neutral fill and icon colours. The pressed half darkens at once, and a finger can slide across the divider. Each half is its own touch target, 47 px wide and as tall as the control. The field gives up the control's width and a gap, so an attached status still lines up with the input; a field narrower than 170 px keeps the steppers inside it. With a mouse, and inside an InputGroup, the steppers stay in the field as before. + +@vjeux diff --git a/.changeset/number-input-stepper-touch-focus.md b/.changeset/number-input-stepper-touch-focus.md new file mode 100644 index 0000000000000..24b6c3b0d034e --- /dev/null +++ b/.changeset/number-input-stepper-touch-focus.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] NumberInput: a finger on a number stepper steps the value without focusing the field, and so do a pen and an activation with no pointer (a screen reader, a programmatic `click()`) where the primary pointer is coarse, so on a phone the on-screen keyboard no longer slides up over the page with every tap. A field that is already being edited stays focused. A mouse click, and a pen or an activation with no pointer where the primary pointer is fine, still focus the field, so the arrow keys and the wheel keep stepping. + +@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/press-release-ends-with-its-animation.md b/.changeset/press-release-ends-with-its-animation.md new file mode 100644 index 0000000000000..76617417e9d02 --- /dev/null +++ b/.changeset/press-release-ends-with-its-animation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under a finger, a press's release now lasts until its fade has played. The touch press controller ended the release on a clock started at the lift, so when the page's script delayed the fade's first frame, the release was cut short mid-fade. It now ends when the surface's release animation ends, or on its clock where no release animation runs (an instant release, a surface without one, or an engine without Web Animations). This applies to every surface the touch press controller drives: buttons, switches, checkboxes, cards, menu rows and list rows. + +@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/selection-touch-targets.md b/.changeset/selection-touch-targets.md new file mode 100644 index 0000000000000..f63fac6bffb9e --- /dev/null +++ b/.changeset/selection-touch-targets.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] On a touch screen, a checkbox and a radio option are now as easy to hit as an iOS control, and nothing changes size: a standalone, labelled CheckboxInput and each RadioList option get an invisible hit area that reaches the new `--size-touch-target` size token (44 px, Apple's control size; a smaller value never lowers it) and stops at half the gap to each neighbour, so neighbouring targets never overlap. Between close neighbours a target stays smaller: a middle RadioList option reaches 32 px at the default 8 px gap. Under a finger a RadioListItem row's `::after` becomes its hit layer, so decorate a row with a child rather than its `::after`. A label-hidden checkbox inside another component's row (a MultiSelector option, a table selection cell, a task item) keeps that row's target. With a mouse or trackpad nothing changes. The CheckboxInput `size` docs now give the real box sizes. + +@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/use-finger-scroll.md b/.changeset/use-finger-scroll.md new file mode 100644 index 0000000000000..e48f0b171df03 --- /dev/null +++ b/.changeset/use-finger-scroll.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useFingerScroll(scroller)` tells a finger's scroll from the program's, and the gestures README gains a guide to migrating a raw-touch drag onto the recognizers. Internal to the touch components: not exported from the package entry point or the gestures barrel. + +@vjeux diff --git a/apps/storybook/stories/List.stories.tsx b/apps/storybook/stories/List.stories.tsx index 881f53db5430d..5714e8326fbf7 100644 --- a/apps/storybook/stories/List.stories.tsx +++ b/apps/storybook/stories/List.stories.tsx @@ -13,6 +13,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {List, ListItem} from '@astryxdesign/core/List'; import {Avatar} from '@astryxdesign/core/Avatar'; import {Badge} from '@astryxdesign/core/Badge'; +import {Button} from '@astryxdesign/core/Button'; import {Card} from '@astryxdesign/core/Card'; import {Icon} from '@astryxdesign/core/Icon'; import {Switch} from '@astryxdesign/core/Switch'; @@ -257,6 +258,73 @@ export const Links: Story = { ), }; +function EndControlsDemo() { + const [isWifiOn, setIsWifiOn] = useState(true); + return ( + // The button after the list sits 4 px below its last row. +
+ + + Connections + +
+ }> + {}} + endContent={} + swipeActions={{trailing: [{label: 'Forget', onActivate: () => {}}]}} + /> + setIsWifiOn(checked)} + /> + } + /> + {}} + endContent={} + /> + +
`; + const FIXTURE = `
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); +}); + +test.describe('wheel and pen input', () => { + /** A non-passive wheel listener: Chromium then waits for the page itself. */ + const listenNonPassively = (page: Page) => + page.evaluate(() => + window.addEventListener('wheel', () => {}, {passive: false}), + ); + /** The wheel steps the page received. */ + const wheelsOf = async (page: Page) => + (await readInputLog(page)).filter(r => r.type === 'wheel'); + + test('a wheel step reaches the page as exactly its deltas, trusted, with Control when held, by the time wheel() resolves on a page whose wheel listeners are all passive', async ({ + page, + }) => { + await fixture(page); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + await driver.wheel({x: 60, y: 520, dy: 120}); + // The input log listens passively, so Chromium answers the dispatch + // before the page has the step: read right after the call. + expect(await wheelsOf(page)).toHaveLength(1); + await driver.wheel({x: 60, y: 520, dx: 30, dy: -40, ctrl: true}); + const wheels = await wheelsOf(page); + expect(wheels).toHaveLength(2); + // Chromium's phone context has a device scale factor of 3: the driver + // multiplies back what Chromium divides by, so the page sees CSS px. + expect(wheels[0]).toMatchObject({ + isTrusted: true, + deltaX: 0, + deltaY: 120, + deltaMode: 0, + ctrlKey: false, + x: 60, + y: 520, + }); + expect(wheels[1]).toMatchObject({ + isTrusted: true, + deltaX: 30, + deltaY: -40, + deltaMode: 0, + ctrlKey: true, + }); + }); + + test('a wheel step reaches the page by the time wheel() resolves with a non-passive wheel listener too', async ({ + page, + }) => { + await fixture(page); + const driver = await webInputDriver(page); + test.skip(!driver.wheels, WHEEL_SKIP); + await listenNonPassively(page); + await driver.wheel({x: 60, y: 520, dy: 80}); + expect(await wheelsOf(page)).toHaveLength(1); + await driver.wheel({x: 60, y: 520, dy: -20, ctrl: true}); + const wheels = await wheelsOf(page); + expect(wheels).toHaveLength(2); + expect(wheels[0]).toMatchObject({ + isTrusted: true, + deltaY: 80, + ctrlKey: false, + }); + expect(wheels[1]).toMatchObject({ + isTrusted: true, + deltaY: -20, + ctrlKey: true, + }); + }); + + test('a pen stroke reaches the page as trusted pen pointers, with its pressure, at its scripted times', async ({ + page, + }) => { + await fixture(page); + const driver = await webInputDriver(page); + test.skip(!driver.pens, PEN_SKIP); + await driver.pen([ + {x: 100, y: 100, t: 0, pressure: 0.3}, + {x: 100, y: 140, t: 50, pressure: 0.6}, + {x: 100, y: 180, t: 100, pressure: 0.6}, + ]); + const log = await readInputLog(page); + const down = log.find(r => r.type === 'pointerdown'); + const moves = log.filter(r => r.type === 'pointermove'); + const up = log.find(r => r.type === 'pointerup'); + expect(down).toMatchObject({ + isTrusted: true, + pointerType: 'pen', + x: 100, + y: 100, + }); + expect(down?.pressure).toBeCloseTo(0.3, 2); + expect(moves.at(-1)).toMatchObject({pointerType: 'pen', y: 180}); + expect(moves.at(-1)?.pressure).toBeCloseTo(0.6, 2); + expect(up).toMatchObject({isTrusted: true, pointerType: 'pen'}); + // The scripted 100 ms between the first and last points reach the page. + expect( + (moves.at(-1)?.timeStamp ?? NaN) - (down?.timeStamp ?? NaN), + ).toBeCloseTo(100, 0); + }); + + test('a stroke sent right after a navigation presses where it is scripted', async ({ + page, + }) => { + // The driver exists before the page navigates, and the stroke goes out + // the moment the page has loaded: the press must not take the previous + // document's page scale (about:blank's, in the phone project). + const driver = await webInputDriver(page); + test.skip(!driver.pens, PEN_SKIP); + await fixture(page); + await driver.pen([ + {x: 100, y: 100, t: 0, pressure: 0.5}, + {x: 100, y: 120, t: 30, pressure: 0.5}, + ]); + const down = (await readInputLog(page)).find(r => r.type === 'pointerdown'); + expect(down).toMatchObject({pointerType: 'pen', x: 100, y: 100}); + }); + + test('says which inputs it has: WebKit in a phone context has no wheel and no pen', async ({ + page, + browserName, + }) => { + await fixture(page); + const driver = await webInputDriver(page); + if (browserName === 'chromium') { + expect({wheels: driver.wheels, pens: driver.pens}).toEqual({ + wheels: true, + pens: true, + }); + return; + } + expect({wheels: driver.wheels, pens: driver.pens}).toEqual({ + wheels: false, + pens: false, + }); + await expect(driver.wheel({x: 60, y: 520, dy: 120})).rejects.toThrow( + WHEEL_SKIP, + ); + await expect(driver.pen([{x: 100, y: 100, t: 0}])).rejects.toThrow( + PEN_SKIP, + ); + }); + + test.describe('in a desktop context', () => { + test.use({isMobile: false, hasTouch: false, deviceScaleFactor: 1}); + + test('a wheel step reaches the page as exactly its deltas in both engines', async ({ + page, + }) => { + await fixture(page); + const driver = await webInputDriver(page); + expect(driver.wheels).toBe(true); + await driver.wheel({x: 60, y: 520, dy: 120}); + expect(await wheelsOf(page)).toHaveLength(1); + await driver.wheel({x: 60, y: 520, dx: -25, dy: 0, ctrl: true}); + const wheels = await wheelsOf(page); + expect(wheels).toHaveLength(2); + expect(wheels[0]).toMatchObject({ + isTrusted: true, + deltaX: 0, + deltaY: 120, + deltaMode: 0, + ctrlKey: false, + }); + expect(wheels[1]).toMatchObject({ + isTrusted: true, + deltaX: -25, + deltaY: 0, + deltaMode: 0, + ctrlKey: true, + }); + }); + }); }); test.describe('curve sampling', () => { @@ -1143,6 +2083,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 +2232,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 +2305,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'); @@ -1326,45 +2410,64 @@ test.describe('animation state', () => { // A 300 ms delay plays last when going backward: the curve starts at once. let watch = await watchAnimations(box); - let readyAt = await box.evaluate(async (el, keyframes) => { + let begunAt = await box.evaluate(async (el, keyframes) => { const animation = el.animate(keyframes, {duration: 400, delay: 300}); animation.reverse(); await animation.ready; - return performance.timeOrigin + performance.now(); + // When it began, by its own clock read at one instant: a run going + // backward starts at its end, so its current time has come down from + // there by as much as the timeline has moved since (through the rate). + // performance.now() read after `ready` drifts later under load. + const end = Number(animation.effect?.getComputedTiming().endTime ?? 0); + return ( + performance.timeOrigin + + Number(document.timeline.currentTime) - + (end - Number(animation.currentTime)) / Math.abs(animation.playbackRate) + ); }, frames); const [delayed] = await watch.started(); - expect(Math.abs(delayed.from - readyAt)).toBeLessThan(50); + expect(Math.abs(delayed.from - begunAt)).toBeLessThan(50); expect(delayed.to - delayed.from).toBeCloseTo(400, 0); await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); // A short one that ended before the look keeps its curve inside its life. watch = await watchAnimations(box); - readyAt = await box.evaluate(async (el, keyframes) => { + begunAt = await box.evaluate(async (el, keyframes) => { const animation = el.animate(keyframes, {duration: 60}); animation.reverse(); await animation.ready; - return performance.timeOrigin + performance.now(); + const end = Number(animation.effect?.getComputedTiming().endTime ?? 0); + return ( + performance.timeOrigin + + Number(document.timeline.currentTime) - + (end - Number(animation.currentTime)) / Math.abs(animation.playbackRate) + ); }, frames); await page.waitForTimeout(200); const [ended] = await watch.started(); expect(ended.finishedAt).not.toBeNull(); - expect(Math.abs(ended.from - readyAt)).toBeLessThan(50); + expect(Math.abs(ended.from - begunAt)).toBeLessThan(50); expect(ended.to).toBeLessThanOrEqual((ended.finishedAt ?? 0) + 1); // One the page cancels when it ends keeps its start. watch = await watchAnimations(box); - readyAt = await box.evaluate(async (el, keyframes) => { + begunAt = await box.evaluate(async (el, keyframes) => { const animation = el.animate(keyframes, {duration: 60}); animation.addEventListener('finish', () => animation.cancel(), { once: true, }); animation.reverse(); await animation.ready; - return performance.timeOrigin + performance.now(); + const end = Number(animation.effect?.getComputedTiming().endTime ?? 0); + return ( + performance.timeOrigin + + Number(document.timeline.currentTime) - + (end - Number(animation.currentTime)) / Math.abs(animation.playbackRate) + ); }, frames); await page.waitForTimeout(200); const [cancelled] = await watch.started(); - expect(Math.abs(cancelled.from - readyAt)).toBeLessThan(50); + expect(Math.abs(cancelled.from - begunAt)).toBeLessThan(50); expect(cancelled.to - cancelled.from).toBeCloseTo(60, 0); // Read right after finish(), before the finish event is delivered: the @@ -1611,7 +2714,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 +2738,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 +2927,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..73ba38af0d61f 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -21,3 +21,5 @@ export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; +export * from './webInput'; 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.ts b/internal/interaction-harness/src/touch.ts index 1c16c4b1f82e0..3a5803d1e6cef 100644 --- a/internal/interaction-harness/src/touch.ts +++ b/internal/interaction-harness/src/touch.ts @@ -265,6 +265,13 @@ export interface InputRecord { readonly x?: number; readonly y?: number; readonly touches?: number; + /** A pointer event's pressure (a pen's, 0–1). */ + readonly pressure?: number; + /** A wheel step's deltas, mode and Control key (webInput.ts). */ + readonly deltaX?: number; + readonly deltaY?: number; + readonly deltaMode?: number; + readonly ctrlKey?: boolean; readonly target: string; } @@ -276,20 +283,21 @@ const INPUT_LOG_SCRIPT = `(() => { ? node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') + (node.getAttribute('aria-label') ? '[' + node.getAttribute('aria-label') + ']' : '') : String(node && node.nodeName); const types = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'touchstart', 'touchmove', - 'touchend', 'touchcancel', 'click', 'contextmenu', 'gotpointercapture', 'lostpointercapture']; + 'touchend', 'touchcancel', 'click', 'contextmenu', 'gotpointercapture', 'lostpointercapture', 'wheel']; for (const type of types) { // Bubble phase on window, so defaultPrevented reflects the page's own handlers. window.addEventListener(type, event => { const record = {type, timeStamp: event.timeStamp, isTrusted: event.isTrusted, cancelable: event.cancelable, defaultPrevented: event.defaultPrevented, target: describe(event.target)}; - if ('pointerId' in event) Object.assign(record, {pointerId: event.pointerId, pointerType: event.pointerType, x: event.clientX, y: event.clientY}); + if ('pointerId' in event) Object.assign(record, {pointerId: event.pointerId, pointerType: event.pointerType, x: event.clientX, y: event.clientY, pressure: event.pressure}); + if (type === 'wheel') Object.assign(record, {deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, ctrlKey: event.ctrlKey, x: event.clientX, y: event.clientY}); if (event.touches) Object.assign(record, {touches: event.touches.length, x: event.changedTouches[0] && event.changedTouches[0].clientX, y: event.changedTouches[0] && event.changedTouches[0].clientY}); log.push(record); }, {passive: true}); } })()`; -/** Records every pointer/touch/click the page receives (install before navigation). */ +/** Records every pointer, touch, click and wheel step the page receives (install before navigation). */ export async function installInputLog(page: Page): Promise { await page.addInitScript(INPUT_LOG_SCRIPT); } 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/interaction-harness/src/webInput.ts b/internal/interaction-harness/src/webInput.ts new file mode 100644 index 0000000000000..267b4d7a02503 --- /dev/null +++ b/internal/interaction-harness/src/webInput.ts @@ -0,0 +1,250 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file webInput.ts + * @input Uses a Playwright `Page` (Chromium or WebKit) + * @output `webInputDriver(page)`: a wheel step (`wheel({x, y, dx, dy, ctrl})`) + * and a pen stroke (`pen(points)`) as REAL engine input, for proofs that + * the same motion works for every pointer, not only a finger + * (`spec:AST-100` FR4). + * @position The non-touch half of the input drivers; touch.ts is the touch + * half and owns the input log both are read through. Untrusted DOM events + * are not input (a component can tell them apart with `isTrusted`), so both + * go through the engine's own input pipeline. + * + * Chromium: CDP `Input.dispatchMouseEvent`. + * - A wheel step is a `mouseWheel` event at (x, y). Chromium divides the + * deltas it delivers by the device scale factor times the visual viewport's + * scale, so the driver multiplies them back: the page receives exactly `dx` + * and `dy` CSS px, in pixel mode (`deltaMode` 0). `ctrl` holds Control, as a + * trackpad pinch arrives. On a page whose wheel listeners are all passive + * (every page with the input log), Chromium answers the dispatch at once + * and delivers the step to the page afterwards, so `wheel` waits, through a + * one-shot passive listener, until the page has the step. + * - A pen stroke is pressed at its first point, moved through every point and + * released at the last, with `pointerType: 'pen'`, a pressure and an + * optional tilt, each event stamped with its scripted time: the page + * receives trusted pen pointer events (and the compatibility mouse events) + * with those timestamps. The stroke starts once the page has presented two + * frames: the press goes out with no page round trip, so right after a + * navigation it could carry the previous document's page scale (about:blank + * scales a press at 100 px to 41 px in the phone project). + * + * WebKit (Playwright's Linux build): `page.mouse.wheel` in a desktop context + * (`isMobile: false`, `hasTouch: false`), waiting the same way. The step + * moves the mouse to (x, y) first (a `pointermove`) and holds Control as a key, + * which Chromium's step does neither. WebKit's mobile emulation has no + * wheel input, and Playwright has no pen for WebKit: both throw, naming the + * reason, so a proof skips on the driver's flags instead. + * + * SYNC: Keep the engine limits above equal to the README's table and to the + * harness self-test (../src/harness.interaction.browser.spec.ts). + */ + +import type {CDPSession, JSHandle, Page} from '@playwright/test'; +import type {Engine} from './touch'; + +/** One wheel step: the page receives exactly `dx`, `dy` CSS px. */ +export interface WheelInput { + readonly x: number; + readonly y: number; + readonly dx?: number; + readonly dy?: number; + /** Hold Control during the step (a trackpad pinch arrives as a ctrl wheel). */ + readonly ctrl?: boolean; +} + +/** A point of a pen stroke. */ +export interface PenPoint { + readonly x: number; + readonly y: number; + /** Ms after the stroke's first point. */ + readonly t: number; + /** 0–1 (default 0.5) while the pen touches. */ + readonly pressure?: number; + /** Degrees, -90–90 (default 0). */ + readonly tiltX?: number; + readonly tiltY?: number; +} + +export interface WebInputDriver { + readonly engine: Engine; + /** Whether `wheel` works in this page (WebKit's mobile emulation has none). */ + readonly wheels: boolean; + /** Whether `pen` works in this page (Chromium only). */ + readonly pens: boolean; + wheel(input: WheelInput): Promise; + /** Presses at the first point, moves through all of them, releases at the last; paced on the wall clock. */ + pen(points: readonly PenPoint[]): Promise; +} + +/** Why a proof skips when the driver has no wheel in this page. */ +export const WHEEL_SKIP = + "WebKit's mobile emulation has no wheel input: run wheel proofs in a desktop context"; +/** Why a proof skips when the driver has no pen. */ +export const PEN_SKIP = + 'Playwright has no pen input for WebKit: pen proofs run in Chromium'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +/** How long `wheel` waits for the page to receive its step. */ +const WHEEL_DELIVERY_MS = 2000; + +/** + * Arms a one-shot passive capture listener for the page's next wheel step, + * before the step is dispatched. Passive, so the page's own listeners keep + * deciding whether the engine waits for it. (The promise sits in an object: + * a handle to a bare promise would wait for it to settle.) + */ +function armWheel(page: Page): Promise}>> { + return page.evaluateHandle(() => ({ + received: new Promise(resolve => { + window.addEventListener('wheel', () => resolve(), { + capture: true, + once: true, + passive: true, + }); + }), + })); +} + +/** Waits until the armed listener has seen the step; throws past the limit. */ +async function untilReceived( + page: Page, + armed: JSHandle<{received: Promise}>, +): Promise { + const received = await page.evaluate( + ([box, ms]) => + Promise.race([ + box.received.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), ms)), + ]), + [armed, WHEEL_DELIVERY_MS] as const, + ); + await armed.dispose(); + if (!received) { + throw new Error( + `webInputDriver: the page did not receive the wheel step within ${WHEEL_DELIVERY_MS} ms (is the point inside the viewport, over the main document?)`, + ); + } +} + +async function chromiumWebInput(page: Page): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + return { + engine: 'chromium', + wheels: true, + pens: true, + async wheel({x, y, dx = 0, dy = 0, ctrl = false}) { + const scale = await page.evaluate( + () => window.devicePixelRatio * (window.visualViewport?.scale ?? 1), + ); + const armed = await armWheel(page); + await cdp.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x, + y, + deltaX: dx * scale, + deltaY: dy * scale, + modifiers: ctrl ? 2 : 0, + }); + await untilReceived(page, armed); + }, + async pen(points) { + if (points.length === 0) { + return; + } + // Two presented frames first: the press reaches the compositor with no + // page round trip, and right after a navigation it could still carry + // the previous document's page scale. The harness's own frames, not a + // frozen timer gate's (story.ts). + await page.evaluate( + () => + new Promise(resolve => { + const gate = ( + window as unknown as { + __harnessTimerGate?: {frame?: typeof requestAnimationFrame}; + } + ).__harnessTimerGate; + const frame = + gate?.frame ?? window.requestAnimationFrame.bind(window); + frame(() => frame(() => resolve())); + }), + ); + const startWall = Date.now(); + const startStamp = Date.now() / 1000; + const events = [ + {type: 'mousePressed' as const, point: points[0]}, + ...points.slice(1).map(point => ({type: 'mouseMoved' as const, point})), + {type: 'mouseReleased' as const, point: points[points.length - 1]}, + ]; + for (const {type, point} of events) { + const wait = startWall + point.t - Date.now(); + if (wait > 0) { + await sleep(wait); + } + await cdp.send('Input.dispatchMouseEvent', { + type, + x: point.x, + y: point.y, + button: 'left', + buttons: type === 'mouseReleased' ? 0 : 1, + clickCount: type === 'mouseMoved' ? 0 : 1, + pointerType: 'pen', + force: type === 'mouseReleased' ? 0 : (point.pressure ?? 0.5), + tiltX: point.tiltX ?? 0, + tiltY: point.tiltY ?? 0, + timestamp: startStamp + point.t / 1000, + }); + } + }, + }; +} + +async function webkitWebInput(page: Page): Promise { + // Playwright refuses a wheel in a WebKit context that emulates a phone: read + // the option it reads, the context's `isMobile`. + const mobile = + (page.context() as unknown as {_options?: {isMobile?: boolean}})._options + ?.isMobile === true; + return { + engine: 'webkit', + wheels: !mobile, + pens: false, + async wheel({x, y, dx = 0, dy = 0, ctrl = false}) { + if (mobile) { + throw new Error(`webInputDriver: ${WHEEL_SKIP}`); + } + await page.mouse.move(x, y); + if (ctrl) { + await page.keyboard.down('Control'); + } + try { + const armed = await armWheel(page); + await page.mouse.wheel(dx, dy); + await untilReceived(page, armed); + } finally { + if (ctrl) { + await page.keyboard.up('Control'); + } + } + }, + async pen() { + throw new Error(`webInputDriver: ${PEN_SKIP}`); + }, + }; +} + +/** Wheel and pen input for the page's engine. Firefox is not supported (no project). */ +export async function webInputDriver(page: Page): Promise { + const name = page.context().browser()?.browserType().name(); + if (name === 'chromium') { + return chromiumWebInput(page); + } + if (name === 'webkit') { + return webkitWebInput(page); + } + throw new Error( + `webInputDriver: no wheel or pen input path for ${name ?? 'this browser'}`, + ); +} 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/cli/assets/docs/tree/tokens-size.doc.mjs b/packages/cli/assets/docs/tree/tokens-size.doc.mjs index d7d42f7d8566c..479958eafc932 100644 --- a/packages/cli/assets/docs/tree/tokens-size.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens-size.doc.mjs @@ -17,14 +17,14 @@ export const docs = { "order": 50 }, "category": "foundations", - "description": "Control heights for consistent sizing across buttons, inputs, and selectors.", + "description": "Control heights for consistent sizing across buttons, inputs, and selectors, and the touch target: how far a hit area grows under a coarse primary pointer before it stops at half the gap to a neighbour.", "sections": [ { "title": "Size Tokens", "content": [ { "type": "prose", - "text": "Control heights for consistent sizing across buttons, inputs, and selectors." + "text": "Control heights for consistent sizing across buttons, inputs, and selectors, and the touch target: how far a hit area grows under a coarse primary pointer before it stops at half the gap to a neighbour." }, { "type": "table", @@ -44,6 +44,10 @@ export const docs = { [ "--size-element-lg", "36px" + ], + [ + "--size-touch-target", + "44px" ] ] } diff --git a/packages/cli/assets/docs/tree/tokens.doc.mjs b/packages/cli/assets/docs/tree/tokens.doc.mjs index 0349b6eb7fc67..0cdb1efba7c10 100644 --- a/packages/cli/assets/docs/tree/tokens.doc.mjs +++ b/packages/cli/assets/docs/tree/tokens.doc.mjs @@ -2,7 +2,7 @@ // AUTO-GENERATED — do not edit manually. // Run: node scripts/generate-token-docs.mjs -// Total: 282 tokens across 15 categories. +// Total: 283 tokens across 15 categories. /** @type {import('@astryxdesign/cli/authoring').NamespaceDoc} */ export const docs = { diff --git a/packages/cli/assets/theme.template.ts b/packages/cli/assets/theme.template.ts index dfa1aeaa7628b..4fbd5c349a94e 100644 --- a/packages/cli/assets/theme.template.ts +++ b/packages/cli/assets/theme.template.ts @@ -212,6 +212,7 @@ export const myTheme = defineTheme({ // {background,border,icon,text} // --spacing-* 0 · 0-5 · 1 … 12 // --size-element-* sm · md · lg (control heights) + // --size-touch-target how far a hit area grows under a coarse pointer (44px) // --focus-outline-* width · style · color · offset // --border-width hairline thickness shared by bordered surfaces // --radius-* none · inner · element · container · page · chat · full 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/CheckboxInput/CheckboxInput.doc.mjs b/packages/core/src/CheckboxInput/CheckboxInput.doc.mjs index 67108fc97b31a..04f42a2ea816a 100644 --- a/packages/core/src/CheckboxInput/CheckboxInput.doc.mjs +++ b/packages/core/src/CheckboxInput/CheckboxInput.doc.mjs @@ -109,7 +109,7 @@ export const docs = { name: 'size', type: "'sm' | 'md'", description: - 'The size of the checkbox. sm for compact layouts, md for default.', + 'The size of the checkbox: a 20 px box for sm (compact layouts), 24 px for md (default). The row is as tall as its label. Under a coarse pointer a labelled checkbox’s target reaches the touch target (`--size-touch-target`, 44 px) through an invisible hit area that stops at half the gap to each neighbour; nothing changes size.', default: "'md'", }, { @@ -352,7 +352,7 @@ export const docsZh = { { name: 'size', type: "'sm' | 'md'", - description: '复选框尺寸。sm 用于紧凑布局,md 为默认。', + description: '复选框尺寸:sm 为 20 px 的方框(紧凑布局),md 为 24 px(默认)。行高与标签一致。在粗指针(触摸)下,带可见标签的复选框通过不可见的点击区域将目标扩展到触摸目标尺寸(`--size-touch-target`,44 px),该区域最多延伸到与相邻控件间距的一半;尺寸不变。', default: "'md'", }, { diff --git a/packages/core/src/CheckboxInput/CheckboxInput.tsx b/packages/core/src/CheckboxInput/CheckboxInput.tsx index a3e9996370ccc..859f1a20c2bd5 100644 --- a/packages/core/src/CheckboxInput/CheckboxInput.tsx +++ b/packages/core/src/CheckboxInput/CheckboxInput.tsx @@ -4,7 +4,7 @@ /** * @file CheckboxInput.tsx - * @input Uses React, useId, ChangeEvent, FieldLabel, FieldStatus, IconType, InputStatus, useTooltip + * @input Uses React, useId, ChangeEvent, FieldLabel, FieldStatus, IconType, InputStatus, useTooltip, useTouchHitArea * @output Exports CheckboxInput component, CheckboxInputProps * @position Core implementation; consumed by index.ts, tested by CheckboxInput.test.tsx * @@ -24,6 +24,7 @@ import { useTransition, type ChangeEvent, type FocusEvent, + type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react'; import * as stylex from '@stylexjs/stylex'; @@ -55,6 +56,8 @@ import {useIndicator} from '../Indicator'; import {themeProps} from '../utils/themeProps'; import {useMergedRefs} from '../hooks/useMergedRefs'; +import {useTouchHitArea} from '../utils/touchHitArea'; +import {touchHitAreaStyles} from '../utils/touchHitArea.stylex'; // The touch press's paint, declared by the shared overlay styles on the // element the controller writes to (`pressedAlpha`) at the press's strength, @@ -71,6 +74,10 @@ const styles = stylex.create({ containerLabelHidden: { gap: 0, }, + // The touch hit layer's containing block, only where the layer exists. + hitAreaHost: { + position: {default: null, '@media (pointer: coarse)': 'relative'}, + }, checkboxWrapper: { position: 'relative', display: 'flex', @@ -262,9 +269,11 @@ export interface CheckboxInputProps extends Omit { */ width?: SizeValue; /** - * The size of the checkbox. - * - 'sm': Compact size (28px row height) - * - 'md': Default size (36px row height) + * The size of the checkbox box: 20 px for 'sm', 24 px for 'md'. The row is + * as tall as its label. Under a coarse pointer a labelled checkbox's target + * reaches the theme's touch target (`--size-touch-target`, 44 px by + * default) through an invisible hit area that stops at half the gap to + * each neighbour; nothing changes size. * @default 'md' */ size?: CheckboxInputSize; @@ -341,6 +350,32 @@ export function CheckboxInput({ // The row is the pressable: the overlay's pressed arm reads the row's // scope marker, the way the indicator's hover tint does. const pressable = usePressFeedback(); + // Under a finger a labelled checkbox's target reaches the touch target + // through an invisible hit area that stops at half the gap to each + // neighbour, and nothing changes size (spec:AST-094 FR42). A label-hidden + // checkbox is a marker in another component's row, which keeps its pitch. + const hasHitArea = !isLabelHidden && !isDisabled && !isReadOnly; + const containerRef = useRef(null); + const inputRef = useRef(null); + useTouchHitArea(containerRef, hasHitArea); + // The hit layer is the container's own ::after, so a tap on it lands on + // the container outside its box, where nothing else answers: it toggles + // the checkbox, as a tap on the box does. A click inside the box (the gap + // between box and label) stays as it was. + const handleHitAreaClick = (event: ReactMouseEvent) => { + if (event.target !== event.currentTarget) { + return; + } + const box = event.currentTarget.getBoundingClientRect(); + const isInside = + event.clientX >= box.left && + event.clientX <= box.right && + event.clientY >= box.top && + event.clientY <= box.bottom; + if (!isInside) { + inputRef.current?.click(); + } + }; const generatedId = useId(); const id = idProp || generatedId; const descriptionID = useId(); @@ -435,11 +470,15 @@ export function CheckboxInput({ // Handlers are gated internally by isEnabled, so attaching // unconditionally is safe. disabledMessageTooltip.interactionRef(el); + containerRef.current = el; }} {...(isDisabled ? undefined : pressable)} + onClick={hasHitArea ? handleHitAreaClick : undefined} {...stylex.props( styles.container, isLabelHidden && styles.containerLabelHidden, + hasHitArea && styles.hitAreaHost, + hasHitArea && touchHitAreaStyles.layer, // Hover and focus reach the checkbox visual through this ancestor // marker rather than props, so the whole row drives it. !isDisabled && indicatorScope, @@ -459,6 +498,7 @@ export function CheckboxInput({ {...rest} ref={useMergedRefs( ref, + inputRef, indeterminateRef, disabledMessageTooltip.positionRef, )} diff --git a/packages/core/src/Indicator/__tests__/TouchTargets.interaction.browser.spec.ts b/packages/core/src/Indicator/__tests__/TouchTargets.interaction.browser.spec.ts new file mode 100644 index 0000000000000..fbb335aaecec5 --- /dev/null +++ b/packages/core/src/Indicator/__tests__/TouchTargets.interaction.browser.spec.ts @@ -0,0 +1,451 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TouchTargets.interaction.browser.spec.ts + * @input The built CheckboxInput, RadioList, CheckboxList, Markdown, + * TableSelection and MultiSelector stories, real touch input (Chromium CDP / + * WebKit's tap) and the interaction harness + * @output Proofs that on a touch screen a checkbox, a radio option and a + * CheckboxList row are as easy to hit as an iOS control without growing + * (spec:AST-094 FR42, List FR10): their boxes match a mouse page's, each + * offers an invisible hit area (the theme's `--size-touch-target`, 44 px by + * default) that stops at half the gap to a neighbour, the point just + * outside a control's box belongs to it and a tap there picks it, a + * theme's larger value reaches further, a composer's rows (a Markdown task + * list, a table's selection column, a MultiSelector option) keep their + * pitch, and a mouse gets no hit area + * @position Real-engine layer of the touch hit areas on CheckboxInput, + * RadioListItem and ListItem (utils/touchHitArea.ts); the stylesheet layer + * is Indicator/touchTarget.test.tsx + */ + +import { + expect, + test, + type Browser, + type Page, + type TestInfo, +} from '@playwright/test'; +import { + openStory, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +/** The theme's default touch target (`--size-touch-target`), CSS px. */ +const SIZE = 44; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +async function checkboxGeometry(page: Page) { + return page + .getByRole('checkbox') + .first() + .evaluate(input => { + const rect = (el: Element) => { + const r = el.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }; + const box = input + .closest('[data-astryx-pressable]') + ?.querySelector('.astryx-checkbox-indicator'); + const row = input.closest('[data-astryx-pressable]'); + if (box == null || row == null) { + throw new Error('no checkbox box or row'); + } + return {input: rect(input), box: rect(box), row: rect(row)}; + }); +} + +async function radioRows(page: Page): Promise { + return page.getByRole('radio').evaluateAll(inputs => + inputs.map(input => { + const row = input.closest('.astryx-item'); + if (row == null) { + throw new Error('no radio row'); + } + const r = row.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }), + ); +} + +const centre = (r: Rect) => ({x: r.x + r.width / 2, y: r.y + r.height / 2}); + +/** A hit area's insets on an element, px, as `watchHitArea` writes them. */ +async function hitInsets(page: Page, selector: string, index = 0) { + return page + .locator(selector) + .nth(index) + .evaluate(el => { + const style = getComputedStyle(el); + const read = (side: string) => + parseFloat(style.getPropertyValue(`--astryx-hit-inset-${side}`)) || 0; + return { + top: read('top'), + right: read('right'), + bottom: read('bottom'), + left: read('left'), + }; + }); +} + +/** The insets once the hit area's first measurement has landed. */ +async function measuredInsets(page: Page, selector: string, index = 0) { + await expect + .poll(async () => { + const insets = await hitInsets(page, selector, index); + return insets.top + insets.right + insets.bottom + insets.left; + }) + .toBeGreaterThan(0); + return hitInsets(page, selector, index); +} + +/** Whether the element under a point is inside `selector`'s nth match. */ +async function ownsPoint( + page: Page, + selector: string, + index: number, + point: {x: number; y: number}, +) { + return page.evaluate( + ({css, n, x, y}) => { + const host = document.querySelectorAll(css)[n]; + const hit = document.elementFromPoint(x, y); + return host != null && hit != null && host.contains(hit); + }, + {css: selector, n: index, ...point}, + ); +} + +/** The same story with a mouse: the phone's viewport and scale, a fine pointer. */ +async function withMouse( + browser: Browser, + testInfo: TestInfo, + run: (mousePage: Page) => Promise, +): Promise { + const {viewport, deviceScaleFactor} = testInfo.project.use; + const context = await browser.newContext({ + viewport, + deviceScaleFactor, + hasTouch: false, + isMobile: false, + }); + try { + return await run(await context.newPage()); + } finally { + await context.close(); + } +} + +/** + * Measures with the hit layer on every `selector` match taken away, then + * gives it back: the same page, so the same text and scale. + */ +async function withoutLayer( + page: Page, + selector: string, + measure: () => Promise, +): Promise { + const style = await page.addStyleTag({ + content: `${selector}::after { display: none !important; }`, + }); + try { + return await measure(); + } finally { + await style.evaluate(async el => { + (el as Element).remove(); + }); + } +} + +/** A theme that sets the touch target size, in place before the story renders. */ +async function themeTouchTarget(page: Page, size: string): Promise { + // Storybook rewrites the root element's style attribute, so a stylesheet. + await page.addInitScript(value => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = `:root { --size-touch-target: ${value}; }`; + document.head.append(style); + }); + }, size); +} + +/** The rows of the story's CheckboxList, as boxes. */ +async function checkboxListRows(page: Page): Promise { + return page.locator(LIST_ROW).evaluateAll(rows => + rows.map(row => { + const r = row.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; + }), + ); +} + +const CHECKBOX_ROW = '#storybook-root [data-astryx-pressable]'; +const RADIO_ROW = '#storybook-root .astryx-item'; +const LIST_ROW = '#storybook-root li:has(input[type="checkbox"])'; + +test.describe('selection controls under a finger', () => { + test('a labelled checkbox keeps its size under a finger, and a tap just outside its box toggles it (FR42)', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-checkboxinput--default'); + expect( + await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--size-touch-target') + .trim(), + ), + ).toBe(`${SIZE}px`); + const insets = await measuredInsets(page, CHECKBOX_ROW); + const finger = await checkboxGeometry(page); + // The layer adds no layout: the same boxes without it. + expect(finger).toEqual( + await withoutLayer(page, CHECKBOX_ROW, async () => + checkboxGeometry(page), + ), + ); + // The input keeps its drawn size, centred on the box, as the RTL audit's + // coarse-hit check (D7) holds in both directions. + expect(finger.input.width).toBeLessThanOrEqual(24.5); + expect( + Math.abs(centre(finger.input).x - centre(finger.box).x), + ).toBeLessThanOrEqual(1); + // The target reaches the touch target through the invisible hit area. + expect( + finger.row.height + insets.top + insets.bottom, + ).toBeGreaterThanOrEqual(SIZE - 0.5); + const point = { + x: Math.round(centre(finger.box).x), + y: Math.floor(finger.row.y - insets.top / 2), + }; + expect(point.y).toBeLessThan(finger.row.y); + expect(await ownsPoint(page, CHECKBOX_ROW, 0, point)).toBe(true); + const checkbox = page.getByRole('checkbox').first(); + const before = await checkbox.isChecked(); + const driver = await touchDriver(page); + await driver.tap(point); + await expect(checkbox).toBeChecked({checked: !before}); + }); + + test('RadioList rows keep their geometry under a finger, and a tap in a row’s half of the gap selects it (FR42)', async ({ + page, + }) => { + for (const orientation of ['vertical', 'horizontal']) { + await openStory(page, storybook(), 'core-radiolist--default', { + orientation, + }); + await measuredInsets(page, RADIO_ROW); + const finger = await radioRows(page); + expect(finger.length).toBeGreaterThan(1); + expect(finger).toEqual( + await withoutLayer(page, RADIO_ROW, async () => radioRows(page)), + ); + } + await openStory(page, storybook(), 'core-radiolist--default'); + const [first, second] = await radioRows(page); + const gap = second.y - (first.y + first.height); + expect(gap).toBeGreaterThan(0); + const insets = await measuredInsets(page, RADIO_ROW, 1); + // The two areas meet in the middle of the gap and never overlap. + expect(insets.top).toBeGreaterThan(0); + expect(insets.top).toBeLessThanOrEqual(gap / 2 + 0.5); + const point = { + x: Math.round(second.x + second.width / 2), + y: Math.floor(second.y - insets.top / 2), + }; + expect(await ownsPoint(page, RADIO_ROW, 1, point)).toBe(true); + // The other half of the gap belongs to the first row. + expect( + await ownsPoint(page, RADIO_ROW, 0, { + x: point.x, + y: Math.ceil(first.y + first.height + 0.5), + }), + ).toBe(true); + const driver = await touchDriver(page); + await driver.tap(point); + await expect(page.getByRole('radio').nth(1)).toBeChecked(); + }); + + test('a CheckboxList’s rows keep their geometry under a finger at every density (List FR10)', async ({ + page, + }) => { + for (const density of ['compact', 'balanced', 'spacious']) { + await openStory(page, storybook(), 'core-checkboxlist--default', { + density, + }); + const finger = await checkboxListRows(page); + expect(finger.length).toBeGreaterThan(1); + expect(finger).toEqual( + await withoutLayer(page, LIST_ROW, async () => checkboxListRows(page)), + ); + } + }); + + test('a tap just outside a compact CheckboxList row toggles that row (List FR10’s hit area)', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-checkboxlist--default', { + density: 'compact', + }); + const [first] = await checkboxListRows(page); + const insets = await measuredInsets(page, LIST_ROW, 0); + expect(first.height + insets.top + insets.bottom).toBeGreaterThan( + first.height, + ); + const point = { + x: Math.round(first.x + first.width / 2), + y: Math.floor(first.y - Math.max(insets.top, 1) / 2), + }; + expect(await ownsPoint(page, LIST_ROW, 0, point)).toBe(true); + const checkbox = page.getByRole('checkbox').first(); + const before = await checkbox.isChecked(); + const driver = await touchDriver(page); + await driver.tap(point); + await expect(checkbox).toBeChecked({checked: !before}); + }); + + // A label-hidden checkbox is a marker in a composer's row: the composer + // keeps its row pitch and the marker its 24 px target, so a taller target + // never reaches into the next row. Each case measures the composer's own + // rows (a Markdown task list's items, a table's rows), and a theme's touch + // target size must not reach them either. + for (const [what, story, scope, rowSelector] of [ + ['a Markdown task list', 'core-markdown--default', '', 'li'], + [ + 'a compact table with a selection column', + 'core-tableselection--compact', + // The body rows, not the header's select-all row. + 'tbody ', + 'tr', + ], + ] as const) { + test(`${what} keeps its row pitch under a finger`, async ({page}) => { + await openStory(page, storybook(), story); + const read = async () => + page + .locator(`#storybook-root ${scope}input[type="checkbox"]`) + .first() + .evaluate((input, selector) => { + const first = input.closest(selector); + const rows = first?.parentElement + ? [...first.parentElement.children].filter(row => + row.matches(selector), + ) + : []; + const r = input.getBoundingClientRect(); + return { + input: {width: r.width, height: r.height}, + rows: rows.map(row => { + const style = getComputedStyle(row); + const box = row.getBoundingClientRect(); + return { + y: box.y, + height: box.height, + padding: + parseFloat(style.paddingTop) + + parseFloat(style.paddingBottom), + line: parseFloat(style.lineHeight), + }; + }), + }; + }, rowSelector); + const today = await read(); + expect(today.rows.length).toBeGreaterThan(1); + if (rowSelector === 'li') { + // Each task item is its text's lines plus its padding: no touch + // floor, so the pitch is the one `main` has under a finger (a + // table's rows keep the height the table gives them). + for (const row of today.rows) { + expect(row.height).toBeLessThan(SIZE); + const lines = (row.height - row.padding) / row.line; + expect(lines).toBeGreaterThanOrEqual(0.95); + expect(Math.abs(lines - Math.round(lines))).toBeLessThan(0.05); + } + } + expect(today.input.width).toBeLessThanOrEqual(24.5); + expect(today.input.height).toBeLessThanOrEqual(24.5); + // A theme's touch target size leaves the composer's rows alone. + await page.evaluate(() => + document.documentElement.style.setProperty( + '--size-touch-target', + '60px', + ), + ); + const tuned = await read(); + expect(tuned.rows.map(row => row.y)).toEqual( + today.rows.map(row => row.y), + ); + }); + } + + test('a MultiSelector option keeps its height under a finger', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-multiselector--default'); + await page.getByRole('combobox').first().tap(); + const option = page.getByRole('option').first(); + await option.waitFor(); + const marker = await option.evaluate(el => { + const input = el.querySelector('input[type="checkbox"]'); + if (input == null) { + throw new Error('no checkbox in the option'); + } + const r = input.getBoundingClientRect(); + return { + option: el.getBoundingClientRect().height, + input: {width: r.width, height: r.height}, + }; + }); + expect(marker.option).toBeLessThan(SIZE); + expect(marker.input.width).toBeLessThanOrEqual(24.5); + expect(marker.input.height).toBeLessThanOrEqual(24.5); + }); + + test('a theme’s touch target size changes how far the hit area reaches', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-checkboxinput--default'); + const today = await measuredInsets(page, CHECKBOX_ROW); + // A theme's value, in place before the control mounts and measures. + await themeTouchTarget(page, '60px'); + await openStory(page, storybook(), 'core-checkboxinput--default'); + const tuned = await measuredInsets(page, CHECKBOX_ROW); + expect(tuned.top).toBeGreaterThan(today.top); + expect(tuned.bottom).toBeGreaterThan(today.bottom); + }); + + test('a mouse gets no hit area and keeps the drawn sizes (negative control)', async ({ + browser, + }, testInfo) => { + await withMouse(browser, testInfo, async mousePage => { + await openStory(mousePage, storybook(), 'core-checkboxinput--default'); + expect( + await mousePage.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const {input, box, row} = await checkboxGeometry(mousePage); + expect(input.width).toBeCloseTo(box.width, 0); + expect(row.height).toBeLessThan(SIZE); + await mousePage.waitForTimeout(100); + expect(await hitInsets(mousePage, CHECKBOX_ROW)).toEqual({ + top: 0, + right: 0, + bottom: 0, + left: 0, + }); + expect( + await mousePage + .locator(CHECKBOX_ROW) + .first() + .evaluate(el => getComputedStyle(el, '::after').content), + ).toMatch(/^(none|normal)$/); + }); + }); +}); diff --git a/packages/core/src/Indicator/touchTarget.test.tsx b/packages/core/src/Indicator/touchTarget.test.tsx new file mode 100644 index 0000000000000..5e5224b9a76d5 --- /dev/null +++ b/packages/core/src/Indicator/touchTarget.test.tsx @@ -0,0 +1,286 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchTarget.test.tsx + * @input CheckboxInput, RadioList and CheckboxList rendered in jsdom, the + * stylesheet StyleX injects for them, the shared touch hit layer and the + * size token defaults + * @output Holds the touch target token's default to the platform's control + * size, and that under a coarse pointer nothing grows (spec:AST-094 FR42, + * List FR10): a labelled CheckboxInput's row, each RadioList row and its + * list's gap, and every CheckboxList row at every density keep their + * geometry, while each carries the coarse-only invisible hit layer + * (utils/touchHitArea.stylex.ts) that gives the target; a tap on a + * checkbox's layer toggles it + * @position Stylesheet layer of the touch hit areas; the real-engine layer is + * Indicator/__tests__/TouchTargets.interaction.browser.spec.ts + */ + +import * as stylex from '@stylexjs/stylex'; +import {fireEvent, render, screen} from '@testing-library/react'; +import {describe, expect, it, vi} from 'vitest'; +import {CheckboxInput} from '../CheckboxInput'; +import {CheckboxList, CheckboxListItem} from '../CheckboxList'; +import {RadioList, RadioListItem} from '../RadioList'; +import {sizeDefaults} from '../theme/tokens.stylex'; +import {touchHitAreaStyles} from '../utils/touchHitArea.stylex'; + +interface Declared { + media: string | null; + text: string; +} + +/** Every injected rule naming one of the element's classes, with its media. */ +function declaredFor(el: Element): Declared[] { + const classes = el.className + .split(/\s+/) + .filter(Boolean) + .map(name => `.${name}`); + const out: Declared[] = []; + const walk = (rules: CSSRuleList, media: string | null) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSMediaRule) { + walk(rule.cssRules, rule.media.mediaText); + } else if ( + rule instanceof CSSStyleRule && + classes.some(cls => rule.selectorText.startsWith(cls)) + ) { + out.push({media, text: rule.style.cssText}); + } + } + }; + for (const sheet of Array.from(document.styleSheets)) { + walk(sheet.cssRules, null); + } + return out; +} + +/** The value `property` takes on `el` under `media` (null: unconditionally). */ +function valueOf( + el: Element, + property: string, + media: string | null, +): string | undefined { + for (const rule of declaredFor(el)) { + if (rule.media !== media) { + continue; + } + const match = new RegExp(`(?:^|;\\s*)${property}:\\s*([^;]+)`).exec( + rule.text, + ); + if (match) { + return match[1].trim(); + } + } + return undefined; +} + +/** Whether `el` carries every class of the shared touch hit layer. */ +function hasHitLayer(el: Element): boolean { + const layer = (stylex.props(touchHitAreaStyles.layer).className ?? '') + .split(/\s+/) + .filter(Boolean); + if (layer.length === 0) { + throw new Error('the hit layer has no classes'); + } + return layer.every(name => el.classList.contains(name)); +} + +function closestOrThrow(el: Element, selector: string): Element { + const found = el.closest(selector); + if (found == null) { + throw new Error(`no ${selector} around the control`); + } + return found; +} + +const TOUCH = '(pointer: coarse)'; + +describe('touch target size', () => { + it('is a theme metric whose default is the platform’s control size', () => { + // Apple's Human Interface Guidelines: iOS controls take a hit target of + // at least 44 × 44 pt. + expect(sizeDefaults['--size-touch-target']).toBe('44px'); + }); +}); + +describe('CheckboxInput under a finger', () => { + it('keeps a labelled checkbox’s row at its size and gives it the hit layer (FR42)', () => { + render( + {}} />, + ); + const row = closestOrThrow( + screen.getByRole('checkbox', {name: 'Accept terms'}), + '[data-astryx-pressable]', + ); + expect(valueOf(row, 'min-height', TOUCH)).toBeUndefined(); + expect(valueOf(row, 'min-height', null)).toBeUndefined(); + expect(hasHitLayer(row)).toBe(true); + // The row holds the layer only under a finger; a mouse sees no change. + expect(valueOf(row, 'position', TOUCH)).toBe('relative'); + expect(valueOf(row, 'position', null)).toBeUndefined(); + }); + + it('gives a label-hidden, disabled or read-only checkbox no hit layer', () => { + render( + <> + {}} + /> + {}} + /> + {}} + /> + , + ); + for (const name of ['Marker', 'Disabled', 'Read-only']) { + const input = screen.getByRole('checkbox', {name}); + const row = + input.closest('[data-astryx-pressable]') ?? input.parentElement; + if (row == null) { + throw new Error(`no row for ${name}`); + } + expect(hasHitLayer(row), name).toBe(false); + } + }); + + it('toggles on a tap that lands on the hit layer, outside the row’s box, and not on one inside it', () => { + const onChange = vi.fn(); + render( + , + ); + const row = closestOrThrow( + screen.getByRole('checkbox', {name: 'Accept terms'}), + '[data-astryx-pressable]', + ); + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({x: 10, y: 10, width: 200, height: 24}), + ); + // Inside the row's own box (the gap between box and label): unchanged. + fireEvent.click(row, {clientX: 30, clientY: 20}); + expect(onChange).not.toHaveBeenCalled(); + // Just above it, on the layer's band. + fireEvent.click(row, {clientX: 30, clientY: 5}); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toBe(true); + }); +}); + +describe('CheckboxList under a finger', () => { + function rowOf(name: string): Element { + return closestOrThrow(screen.getByRole('checkbox', {name}), 'li'); + } + + it('sets no touch row height at any density: List keeps row geometry (FR10)', () => { + for (const density of ['compact', 'balanced', 'spacious'] as const) { + const {unmount} = render( + {}} + density={density}> + + + , + ); + for (const name of [`Email ${density}`, `SMS ${density}`]) { + expect(valueOf(rowOf(name), 'min-height', TOUCH), name).toBeUndefined(); + expect(paddingBlockOf(rowOf(name), TOUCH), name).toEqual([]); + // A row that toggles takes List's invisible hit area instead. + expect(hasHitLayer(rowOf(name)), name).toBe(true); + } + unmount(); + } + }); + + it('keeps a read-only compact list at its pitch, as Markdown’s task list', () => { + render( + {}} + isReadOnly + density="compact"> + + + , + ); + expect(valueOf(rowOf('Open'), 'min-height', TOUCH)).toBeUndefined(); + expect(paddingBlockOf(rowOf('Open'), TOUCH)).toEqual([]); + }); +}); + +describe('RadioList under a finger', () => { + it('keeps each row’s size and the list’s gap: no touch height, padding or gap (FR42)', () => { + for (const orientation of ['vertical', 'horizontal'] as const) { + const {unmount} = render( + {}}> + + + , + ); + const row = closestOrThrow( + screen.getByRole('radio', {name: `Option B ${orientation}`}), + '.astryx-item', + ); + expect(valueOf(row, 'min-height', TOUCH)).toBeUndefined(); + expect(paddingBlockOf(row, TOUCH)).toEqual([]); + const touchGaps = declaredFor(screen.getByRole('radiogroup')).filter( + rule => + rule.media?.startsWith(TOUCH) === true && + /(?:^|;\s*)(?:row-)?gap:/.test(rule.text), + ); + expect(touchGaps).toEqual([]); + unmount(); + } + }); + + it('gives each enabled option the hit layer and a disabled one none (FR42)', () => { + render( + {}}> + + + , + ); + const rowOf = (name: string) => + closestOrThrow(screen.getByRole('radio', {name}), '.astryx-item'); + expect(hasHitLayer(rowOf('Option A'))).toBe(true); + expect(hasHitLayer(rowOf('Option B'))).toBe(false); + }); +}); + +/** The block padding `el` declares under `media`, as [start, end]. */ +function paddingBlockOf(el: Element, media: string | null): string[] { + const both = valueOf(el, 'padding-block', media); + if (both != null) { + const [start, end = start] = both.split(/\s+(?![^(]*\))/); + return [start, end]; + } + const start = + valueOf(el, 'padding-top', media) ?? + valueOf(el, 'padding-block-start', media); + const end = + valueOf(el, 'padding-bottom', media) ?? + valueOf(el, 'padding-block-end', media); + return start == null && end == null ? [] : [start ?? '', end ?? '']; +} diff --git a/packages/core/src/Item/Item.test.tsx b/packages/core/src/Item/Item.test.tsx index 8358b096fb9ef..510d2a056f9c2 100644 --- a/packages/core/src/Item/Item.test.tsx +++ b/packages/core/src/Item/Item.test.tsx @@ -345,6 +345,85 @@ describe('Item', () => { expect(anchor?.textContent).toContain('Link'); }); + /** A link row whose box spans x 0–100 and y 10–50, its anchor followed. */ + function linkRow() { + const {container} = render(); + const anchor = container.querySelector('a') as HTMLAnchorElement; + const followed = vi.fn((event: MouseEvent) => event.preventDefault()); + anchor.addEventListener('click', followed); + const row = container.firstElementChild as HTMLElement; + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({x: 0, y: 10, width: 100, height: 40}), + ); + return {anchor, followed, row}; + } + + it('follows the link on a tap in a link row’s touch band, past its box (spec:AST-094 FR42)', () => { + const {anchor, followed, row} = linkRow(); + fireEvent.click(row, {clientX: 50, clientY: 5, detail: 1}); + expect(followed).toHaveBeenCalledTimes(1); + // A click on the anchor itself follows it once, not twice. + fireEvent.click(anchor); + expect(followed).toHaveBeenCalledTimes(2); + // Modifier keys carry over, so a ctrl or cmd tap opens as on the label. + fireEvent.click(row, {clientX: 50, clientY: 55, detail: 1, ctrlKey: true}); + expect(followed).toHaveBeenCalledTimes(3); + expect(followed.mock.calls[2][0].ctrlKey).toBe(true); + }); + + it('does nothing for a mouse click inside a link row’s box but outside its anchor, as before (spec:AST-017 FR59)', () => { + const {followed, row} = linkRow(); + fireEvent.click(row, {clientX: 4, clientY: 30, detail: 1}); + expect(followed).not.toHaveBeenCalled(); + }); + + it('follows the link on a tap in its own padding when the press was a finger’s, though the click says mouse (WebKit)', () => { + const {followed, row} = linkRow(); + fireEvent.pointerDown(row, {pointerType: 'touch'}); + fireEvent.click(row, {clientX: 4, clientY: 30, detail: 1}); + expect(followed).toHaveBeenCalledTimes(1); + // A mouse press after it: its click in the padding does nothing again. + fireEvent.pointerDown(row, {pointerType: 'mouse'}); + fireEvent.click(row, {clientX: 4, clientY: 30, detail: 1}); + expect(followed).toHaveBeenCalledTimes(1); + }); + + it('follows the link on a tap in a link row’s own padding, outside its anchor, under a finger', () => { + const {followed, row} = linkRow(); + const original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + addEventListener() {}, + removeEventListener() {}, + })) as unknown as typeof window.matchMedia; + try { + fireEvent.click(row, {clientX: 4, clientY: 30, detail: 1}); + expect(followed).toHaveBeenCalledTimes(1); + } finally { + window.matchMedia = original; + } + }); + + it('does nothing for a click with no pointer behind it', () => { + const {followed, row} = linkRow(); + fireEvent.click(row, {clientX: 50, clientY: 5, detail: 0}); + expect(followed).not.toHaveBeenCalled(); + }); + + it('keeps a row with both onClick and href on onClick for a click in its own box', () => { + const onClick = vi.fn(); + const {container} = render( + , + ); + const anchor = container.querySelector('a') as HTMLAnchorElement; + const followed = vi.fn((event: MouseEvent) => event.preventDefault()); + anchor.addEventListener('click', followed); + fireEvent.click(container.firstElementChild as HTMLElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(followed).not.toHaveBeenCalled(); + }); + it('sets target on anchor when provided', () => { const {container} = render( , diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 34b264d00124d..49a21de731d84 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -4,7 +4,8 @@ /** * @file Item.tsx - * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer + * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer, + * the shared press overlays and the compositor press layer * @output Exports Item component, ItemProps type; publishes the shared inline inset * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx * @@ -53,6 +54,8 @@ import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {rowPressLayerStyles} from '../utils/rowPressLayer'; +import {TOUCH_QUERY} from '../utils/touchQueries'; import {usePressFeedback} from '../hooks/usePressFeedback'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; @@ -326,6 +329,40 @@ const ARIA_SELECTED_ROLES = new Set([ */ const SWIPE_ROLES = new Set(['listitem']); +/** + * Whether a row serves swipe actions, and so is a stacking context at rest + * (its root isolates and translates, `styles.swipeRoot`). Swipe actions exist + * where the row's role permits interactive descendants (a swipe panel's + * buttons are such descendants): a `listitem`, or no role at all. A root that + * is itself a link, a row whose role forbids them, a disabled row, a row with + * no entry on either side, and a row that is the enlarged target of a nested + * control are not served. Internal: List/ListItem.tsx raises such a row under + * a finger, so its touch band is above the rows after it. + */ +export function servesSwipeActions({ + swipeActions, + isDisabled, + isDelegate, + isLinkRoot, + role, +}: { + readonly swipeActions: ItemSwipeActions | undefined; + readonly isDisabled: boolean; + readonly isDelegate: boolean; + readonly isLinkRoot: boolean; + readonly role: string | null | undefined; +}): boolean { + return ( + swipeActions != null && + ((swipeActions.leading?.length ?? 0) > 0 || + (swipeActions.trailing?.length ?? 0) > 0) && + !isDisabled && + !isDelegate && + !isLinkRoot && + (role == null || SWIPE_ROLES.has(role)) + ); +} + /** The travel a drag writes on the row root, in physical px (see useSwipeAction). */ const SWIPE_TRAVEL = 'var(--_item-swipe-travel, 0px)'; /** The settle clock, `0s` while the finger drives the row. */ @@ -540,6 +577,23 @@ const densityStyles = stylex.create({ // Component // ============================================================================= +/** + * Whether a click came from a finger: the click's own pointer type where the + * engine reports one, else the primary pointer (TOUCH_QUERY, one touch + * model). + */ +function isTouchClick(event: MouseEvent): boolean { + const {pointerType} = event as Partial; + if (typeof pointerType === 'string' && pointerType !== '') { + return pointerType === 'touch'; + } + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(TOUCH_QUERY).matches + ); +} + /** * A universal item primitive that unifies the "start content + label + * description + end content" layout pattern. Use as a building block for list items, @@ -620,19 +674,16 @@ export function Item({ // and the invisible anchor below is not rendered. const isLinkRoot = typeof Component !== 'string' && href != null; - // Swipe actions exist where the row's role permits interactive descendants - // (a swipe panel's buttons are such descendants): a `listitem`, or no role - // at all. A root that is itself a link, a row whose role forbids them, and - // a row that is the enlarged target of a nested control are not served. + // Which rows serve swipe actions: servesSwipeActions, above. const leadingSwipe = swipeActions?.leading ?? []; const trailingSwipe = swipeActions?.trailing ?? []; - const hasSwipe = - swipeActions != null && - (leadingSwipe.length > 0 || trailingSwipe.length > 0) && - !isDisabled && - !isDelegate && - !isLinkRoot && - (role == null || SWIPE_ROLES.has(role)); + const hasSwipe = servesSwipeActions({ + swipeActions, + isDisabled, + isDelegate, + isLinkRoot, + role, + }); useDevWarning( 'Item', '`swipeBehavior="commit"` fires only the outermost entry of a side; the ' + @@ -727,6 +778,12 @@ export function Item({ ); + // The pointer of the last press on a link row, which its click inherits: + // WebKit reports a click a finger made with the pointer type `mouse`, while + // the press itself says `touch`. + const pressPointerRef = useRef(null); + const isLinkRow = onClick == null && href != null && !isLinkRoot; + const handleContainerClick = (e: React.MouseEvent) => { if (isDisabled) { return; @@ -735,6 +792,40 @@ export function Item({ if (target.closest('button, a, input, select, textarea')) { return; } + if (isLinkRow) { + // Under a finger, a link row's whole touch target follows its link: a + // tap in its own padding, outside the anchor, and a tap in its touch + // hit area, the band past its box (`spec:AST-094` FR42). It goes to + // the row's anchor, with its modifier keys, so the link component and + // the browser decide the destination as for a tap on the label. A + // fine-pointer click in the padding does nothing, as before + // (`spec:AST-017` FR59); so does a click with no pointer behind it. + const row = e.currentTarget as HTMLElement; + const box = row.getBoundingClientRect(); + const isInBand = + e.clientX < box.left || + e.clientX > box.right || + e.clientY < box.top || + e.clientY > box.bottom; + const isTouch = + pressPointerRef.current != null + ? pressPointerRef.current === 'touch' + : isTouchClick(e.nativeEvent); + if (e.detail > 0 && (isInBand || isTouch)) { + row.querySelector('[data-astryx-row-link]')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + button: e.button, + }), + ); + } + return; + } onClick?.(e); }; @@ -768,6 +859,7 @@ export function Item({ href={href} target={target} rel={rel} + data-astryx-row-link="" aria-disabled={isDisabled || undefined} tabIndex={isDisabled ? -1 : undefined} {...stylex.props( @@ -866,6 +958,14 @@ export function Item({ {...restProps} {...swipeHandlers} {...linkRootProps} + {...(isInteractive && isLinkRow + ? { + onPointerDownCapture: (event: React.PointerEvent) => { + pressPointerRef.current = event.pointerType; + restProps.onPointerDownCapture?.(event); + }, + } + : null)} aria-selected={(allowsAriaSelected && isSelected) || undefined} // aria-selected is invalid on roles that don't permit it (listitem, a // bare div, etc.). For those, convey selection via aria-current — valid @@ -886,6 +986,10 @@ export function Item({ align === 'first-line' && styles.alignFirstLine, isInteractive && styles.interactive, isInteractive && interactionOverlayStyles.backgroundColor, + // The touch press on a compositor layer (after the overlay, whose + // own touch paint it turns off): the release keeps fading while + // script runs. + isInteractive && rowPressLayerStyles.cell, isLinkRoot && styles.linkRoot, isHighlighted && styles.highlighted, isSelected && styles.selected, diff --git a/packages/core/src/List/List.doc.mjs b/packages/core/src/List/List.doc.mjs index 81b2acaba52ec..7fba204c494a1 100644 --- a/packages/core/src/List/List.doc.mjs +++ b/packages/core/src/List/List.doc.mjs @@ -13,6 +13,9 @@ export const docs = { {className: 'astryx-list', visualProps: ['density', 'listStyle']}, {className: 'astryx-list-item'}, ], + vars: [ + {name: '--_hit-border-bottom', description: "Width of the divider below a divided row (0 on the last row), which the row's touch hit layer adds to its offset so its band starts past the divider. Set by ListItem under hasDividers from --border-width; a theme changes the divider through that token.", default: '0px', private: true}, + ], }, description: 'List container with density, dividers, and header support.', props: [ @@ -32,7 +35,7 @@ export const docs = { { name: 'density', type: "'compact' | 'balanced' | 'spacious'", - description: 'Spacing density for items.', + description: "Spacing density for items. Under a finger, an interactive row's hit target reaches 44px where its neighbors leave room, with no change to how rows look or lay out.", default: "'balanced'", }, { diff --git a/packages/core/src/List/List.test.tsx b/packages/core/src/List/List.test.tsx index 8ed1f09f40b38..d70bec12e56cc 100644 --- a/packages/core/src/List/List.test.tsx +++ b/packages/core/src/List/List.test.tsx @@ -9,13 +9,50 @@ * SYNC: When modified, update this header */ +import {useRef} from 'react'; import {describe, it, expect, vi} from 'vitest'; import {cleanup, render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {rulesDeclaredFor} from '../__tests__/pressState'; + +/** + * The element's injected rules split by the touch gate: inside + * `@media (pointer: coarse)` (TOUCH_QUERY, utils/touchPresentation.ts), or + * not. + */ +function touchGatedRules(el: Element): {touch: string[]; other: string[]} { + const classes = el.className + .split(/\s+/) + .filter(Boolean) + .map(name => `.${name}`); + const out = {touch: [] as string[], other: [] as string[]}; + const visit = (rules: CSSRuleList, touch: boolean) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + if (classes.some(cls => rule.selectorText.startsWith(cls))) { + (touch ? out.touch : out.other).push( + `${rule.selectorText} {${rule.style.cssText}}`, + ); + } + continue; + } + const nested = (rule as CSSGroupingRule).cssRules; + if (nested == null) { + continue; + } + const condition = (rule as CSSConditionRule).conditionText ?? ''; + visit(nested, touch || condition.includes('pointer: coarse')); + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, false); + } + return out; +} import {List} from './List'; import {ListItem} from './ListItem'; import {ListMarkerScope} from './ListContext'; +import {HIT_INSET_PROPERTIES} from '../utils/hitArea'; describe('List', () => { it('forwards ListItem align to its Item row unchanged', () => { @@ -488,6 +525,68 @@ describe('List', () => { expect(hrs).toHaveLength(0); }); + // =========================================================================== + // Touch hit area (`spec:AST-094` FR42; `component:List` FR10) + // =========================================================================== + + /** The rules among `rules` whose selector reaches the row's `::after`. */ + const afterRules = (rules: string[]): string[] => + rules.filter(rule => rule.slice(0, rule.indexOf('{')).includes('::after')); + + it('gives an interactive, enabled row an invisible hit layer under a finger only', () => { + render( + + {}} /> + + + {}} isDisabled /> + , + ); + const row = (text: string) => screen.getByText(text).closest('li')!; + for (const text of ['Pressable', 'Link']) { + const {touch, other} = touchGatedRules(row(text)); + const layer = afterRules(touch).join('\n'); + // Positioned past the row by the measured insets, clipped to the band + // outside the row's own box, and painting nothing. + expect(layer).toContain('position: absolute'); + expect(layer).toMatch(/clip-path: polygon\(evenodd/); + for (const side of ['top', 'right', 'bottom', 'left'] as const) { + expect(layer).toMatch( + new RegExp( + `${side}: calc\\(\\(var\\(${HIT_INSET_PROPERTIES[side]}, ?0px\\) \\+ var\\(--_hit-border-${side}, ?0px\\)\\) \\* -1\\)`, + ), + ); + } + // Above the rows after it, so a later row does not hit-test over the + // band; painting nothing. + expect(layer).toMatch(/z-index: 1/); + expect(layer).not.toMatch( + /[{;]\s*(?:background|border|box-shadow|outline)[\w-]*\s*:/, + ); + // The row itself takes no z-index: it stays no stacking context. + expect( + touch.filter( + rule => + !rule.slice(0, rule.indexOf('{')).includes('::') && + rule.includes('z-index'), + ), + ).toEqual([]); + // At rest the row's stacking stays as it was: only a press isolates it + // (the press layer, utils/rowPressLayer.ts). + for (const rule of touch.filter(r => r.includes('isolation'))) { + expect(rule.slice(0, rule.indexOf('{'))).toContain( + '[data-astryx-press', + ); + } + // With a mouse: no pseudo-element. + expect(afterRules(other)).toEqual([]); + } + // A row with nothing to press offers no target. + for (const text of ['Read-only', 'Disabled']) { + expect(afterRules(touchGatedRules(row(text)).touch)).toEqual([]); + } + }); + // =========================================================================== // Interactive items — onClick (invisible button pattern) // =========================================================================== @@ -1012,6 +1111,23 @@ describe('List', () => { }); }); +/** A row whose press goes to a control inside it (`interactiveRef`), with swipe actions. */ +function DelegateSwipeRow() { + const ref = useRef(null); + return ( + + Open + + } + swipeActions={{trailing: [{label: 'Archive', onActivate: () => {}}]}} + /> + ); +} + describe('List and swipe actions', () => { it('clips the rows in the inline axis with clip, never hidden, so a row dragged aside paints no further than the list', () => { render( @@ -1026,6 +1142,67 @@ describe('List and swipe actions', () => { expect(rules.some(r => /overflow[-a-z]*: hidden/.test(r))).toBe(false); }); + it('isolates its rows, so what rises inside the list (a row’s touch band, a swipeable row) stays below a sticky header', () => { + render( + + {}} /> + , + ); + // The list is the isolation boundary for its rows' z-index + // (`spec:AST-027` DEC-1, DEC-2): a page-level sticky header at z-index 1 + // stays above every part of the list. + expect(getComputedStyle(screen.getByRole('list')).isolation).toBe( + 'isolate', + ); + }); + + it('under a finger, raises a swipeable interactive row above the rows after it: it is a stacking context, so its touch band’s own z-index orders only inside it', () => { + render( + + {}} + swipeActions={{trailing: [{label: 'Archive', onActivate: () => {}}]}} + /> + {}} /> + {}}]}} + /> + {}} + swipeActions={{leading: [], trailing: []}} + /> + + , + ); + const row = (text: string) => screen.getByText(text).closest('li')!; + const rowZ = (el: Element) => { + const {touch, other} = touchGatedRules(el); + const own = (rules: string[]) => + rules.filter( + rule => + !rule.slice(0, rule.indexOf('{')).includes('::') && + rule.includes('z-index:'), + ); + return {touch: own(touch), other: own(other)}; + }; + const swipeable = rowZ(row('Swipeable')); + expect(swipeable.touch.join('\n')).toMatch(/z-index: 1/); + // With a mouse there is no band, and nothing changes. + expect(swipeable.other).toEqual([]); + // A row that is no stacking context needs no raise (its band's own + // z-index orders it), and a swipeable row with no band gets none. + expect(rowZ(row('Pressable')).touch).toEqual([]); + expect(rowZ(row('Static')).touch).toEqual([]); + // Item serves no swipe actions on a row with no entry on either side, or + // on a row whose press goes to a control inside it: neither is a stacking + // context, so neither is raised. + expect(rowZ(row('Empty sides')).touch).toEqual([]); + expect(rowZ(row('Delegate')).touch).toEqual([]); + }); + it('passes swipeActions and swipeBehavior through to the row unchanged', async () => { render( diff --git a/packages/core/src/List/List.tsx b/packages/core/src/List/List.tsx index b2379d9104181..fecf8373cffaf 100644 --- a/packages/core/src/List/List.tsx +++ b/packages/core/src/List/List.tsx @@ -147,6 +147,11 @@ const styles = stylex.create({ // `hidden`: hidden makes a scroll container, which fights the list's // vertical scroller. (StyleX emits the physical longhand of this axis.) overflowInline: 'clip', + // The isolation boundary for what rises inside the list: a row's touch + // band and a swipeable row (utils/touchHitArea.stylex.ts, ListItem) order + // above the rows after them and stay below a page-level sticky header at + // the same z-index (`spec:AST-027` DEC-1, DEC-2). + isolation: 'isolate', }, // 'outset' and 'bleed' rows reach into the container's padding. The list // reaches the container's padding edge on each inline edge first, so its diff --git a/packages/core/src/List/ListItem.hitArea.test.tsx b/packages/core/src/List/ListItem.hitArea.test.tsx new file mode 100644 index 0000000000000..00a4cebc77eba --- /dev/null +++ b/packages/core/src/List/ListItem.hitArea.test.tsx @@ -0,0 +1,187 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListItem.hitArea.test.tsx + * @input Vitest, Testing Library, and utils/hitArea.ts with `watchHitArea` + * spied (the rest real) + * @output Checks that ListItem keeps an interactive, enabled row's touch hit + * insets live through the shared utility (`spec:AST-094` FR42; `component:List` FR10): which + * rows it watches, that the insets land on the row, the theme's touch + * target size under a finger, that a measurement taken mid-motion waits for + * the motion to end, and that it stops when the row goes + * @position Test for ListItem's hit area wiring (ListItem.tsx, through + * utils/touchHitArea.ts); the layer's own CSS is checked in List.test.tsx, its + * hit testing in __tests__/ListTouchHitArea.interaction.browser.spec.ts + */ + +import {createRef} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import type * as HitAreaModule from '../utils/hitArea'; +import type {HitInsets} from '../utils/hitArea'; + +const watches = vi.hoisted( + () => + [] as { + element: Element; + onInsets: (insets: HitInsets) => void; + minSize: number | undefined; + stop: ReturnType; + }[], +); + +vi.mock('../utils/hitArea', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + watchHitArea: ( + element: Element, + onInsets: (insets: HitInsets) => void, + options?: {readonly minSize?: number}, + ) => { + const stop = vi.fn(); + watches.push({element, onInsets, minSize: options?.minSize, stop}); + return stop; + }, + }; +}); + +import {List} from './List'; +import {ListItem} from './ListItem'; + +describe('ListItem touch hit area (spec:AST-094 FR42; component:List FR10)', () => { + afterEach(() => { + watches.length = 0; + vi.unstubAllGlobals(); + }); + + const row = (text: string) => screen.getByText(text).closest('li')!; + + it('watches each interactive, enabled row, and no other', () => { + const control = createRef(); + render( + + {}} /> + + + Toggle + + } + /> + + {}} isDisabled /> + , + ); + expect(watches.map(watch => watch.element)).toEqual([ + row('Pressable'), + row('Link'), + row('Delegated'), + ]); + }); + + it('puts the measured insets on the row, where its layer reads them', () => { + render( + + {}} /> + , + ); + expect(watches).toHaveLength(1); + watches[0].onInsets({top: 4, right: 0, bottom: 1, left: 0}); + const style = row('Pressable').style; + expect(style.getPropertyValue('--astryx-hit-inset-top')).toBe('4px'); + expect(style.getPropertyValue('--astryx-hit-inset-bottom')).toBe('1px'); + expect(style.getPropertyValue('--astryx-hit-inset-left')).toBe('0px'); + }); + + it('grows toward the theme’s touch target under a finger, and 44px otherwise', () => { + const {unmount} = render( + + {}} + style={{['--size-touch-target' as string]: '48px'}} + /> + , + ); + // No coarse pointer: the 44px minimum, whatever the theme says. + expect(watches.map(watch => watch.minSize)).toEqual([44]); + unmount(); + watches.length = 0; + vi.stubGlobal('matchMedia', (media: string) => ({ + media, + matches: media === '(pointer: coarse)', + addEventListener: () => {}, + removeEventListener: () => {}, + })); + render( + + {}} + style={{['--size-touch-target' as string]: '48px'}} + /> + {}} /> + , + ); + expect(watches.map(watch => watch.minSize)).toEqual([48, 44]); + }); + + it('drops a measurement taken while the list moves, and watches the row afresh once the motion ends', async () => { + render( + + {}} /> + , + ); + const row = screen.getByText('Pressable').closest('li')!; + const list = row.parentElement!; + let finish = (): void => {}; + const finished = new Promise(resolve => { + finish = resolve; + }); + const motion = { + playState: 'running', + effect: {getComputedTiming: () => ({endTime: 300})}, + finished, + } as unknown as Animation; + list.getAnimations = () => [motion]; + expect(watches).toHaveLength(1); + watches[0].onInsets({top: 3, right: 0, bottom: 1, left: 0}); + // Mid-motion: nothing written. + expect(row.style.getPropertyValue('--astryx-hit-inset-top')).toBe(''); + list.getAnimations = () => []; + finish(); + await vi.waitFor(() => expect(watches).toHaveLength(2)); + // The old watch stopped, and the fresh one measures the row at rest. + expect(watches[0].stop).toHaveBeenCalledTimes(1); + watches[1].onInsets({top: 4, right: 0, bottom: 1, left: 0}); + expect(row.style.getPropertyValue('--astryx-hit-inset-top')).toBe('4px'); + }); + + it('stops watching when the row goes or turns disabled', () => { + const {rerender, unmount} = render( + + {}} /> + , + ); + expect(watches).toHaveLength(1); + rerender( + + {}} isDisabled /> + , + ); + expect(watches[0].stop).toHaveBeenCalledTimes(1); + expect(watches).toHaveLength(1); + rerender( + + {}} /> + , + ); + expect(watches).toHaveLength(2); + unmount(); + expect(watches[1].stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/List/ListItem.tsx b/packages/core/src/List/ListItem.tsx index 249d20f960efe..41b4a751befd1 100644 --- a/packages/core/src/List/ListItem.tsx +++ b/packages/core/src/List/ListItem.tsx @@ -5,7 +5,8 @@ /** * @file ListItem.tsx * @input Uses React, ReactNode, StyleXStyles, theme tokens, the List row edge - * treatment, CheckboxInput, and the marker or task checkbox a + * treatment, the touch hit area (utils/touchHitArea.ts and its layer), + * CheckboxInput, and the marker or task checkbox a * ListMarkerScope sets (Markdown's nested lists and the task items of mixed * lists) * @output Exports ListItem component, ListItemProps type @@ -22,7 +23,7 @@ * - /packages/cli/assets/templates/blocks/components/List/ (showcase blocks) */ -import {use, type ReactNode} from 'react'; +import {use, useRef, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {StyleXStyles} from '@stylexjs/stylex'; import { @@ -36,8 +37,12 @@ import {ListContext, type ListMarker} from './ListContext'; import {CheckboxInput} from '../CheckboxInput'; import {mergeProps} from '../utils'; import {Item} from '../Item'; +import {servesSwipeActions} from '../Item/Item'; import type {ItemAlign, ItemSwipeActions, ItemSwipeBehavior} from '../Item'; import {themeProps} from '../utils/themeProps'; +import {useMergedRefs} from '../hooks/useMergedRefs'; +import {useTouchHitArea} from '../utils/touchHitArea'; +import {touchHitAreaStyles} from '../utils/touchHitArea.stylex'; // ============================================================================= // Types @@ -183,6 +188,14 @@ const styles = stylex.create({ 'max(var(--container-padding-inline-end, 0px), var(--_item-inset-inline))', borderRadius: 0, }, + // A swipeable row is a stacking context at rest (Item isolates and + // translates its swipe root), so its touch band's own z-index orders only + // inside it: under a finger the row itself rises above the rows after it, + // whose boxes its band reaches into. The list isolates, so it stays below + // a sticky header (List.tsx). + swipeRaised: { + zIndex: {default: null, '@media (pointer: coarse)': 1}, + }, withDivider: { borderBlockEndWidth: borderVars['--border-width'], borderBlockEndStyle: 'solid', @@ -194,6 +207,12 @@ const styles = stylex.create({ ':last-child': { borderBlockEndWidth: 0, }, + // The touch hit layer's band starts past the divider, the edge of the box + // its insets are measured from (utils/touchHitArea.stylex.ts). + '--_hit-border-bottom': { + default: borderVars['--border-width'], + ':last-child': '0px', + }, }, }); @@ -327,6 +346,27 @@ export function ListItem({ ...restProps }: ListItemProps) { const ctx = use(ListContext); + // Under a finger, an interactive row's hit target grows toward the touch + // target where its neighbors leave room, with no visual or layout change + // (`spec:AST-094` FR42; `component:List` FR10): useTouchHitArea keeps the row's insets live, and + // its invisible layer (touchHitAreaStyles.layer) reads them. + const isTouchTarget = + (onClick != null || href != null || interactiveRef != null) && !isDisabled; + const rowRef = useRef(null); + const mergedRef = useMergedRefs(ref, rowRef); + useTouchHitArea(rowRef, isTouchTarget); + // A row Item serves swipe actions on is a stacking context at rest, so its + // band's own z-index orders only inside it: under a finger, the row raises + // itself (styles.swipeRaised). Its root is its `
  • `, never a link. + const isRaisedSwipeRow = + isTouchTarget && + servesSwipeActions({ + swipeActions: restProps.swipeActions, + isDisabled, + isDelegate: interactiveRef != null, + isLinkRoot: false, + role: restProps.role, + }); const density = ctx?.density ?? 'balanced'; const hasDividers = ctx?.hasDividers ?? false; const edge = ctx?.edge ?? 'padded'; @@ -371,7 +411,7 @@ export function ListItem({ return ( + 1 - releaseEase(Math.min(1, Math.max(0, t / RELEASE_MS))); + +/** Long enough that the press is believed (scroll.delaysContentTouchesMs). */ +const HOLD_MS = 400; + +interface RowStory { + readonly name: string; + readonly story: string; + /** The pressable row under test. */ + readonly row: (page: Page) => Locator; +} + +const LIST_ROW: RowStory = { + name: 'List row', + story: 'core-list--interactive', + // "Sent": not the selected row, so its resting background is the page's. + row: page => + page.locator('[data-astryx-pressable]').filter({hasText: 'Sent'}).first(), +}; + +const SCROLLER_ROW: RowStory = { + name: 'Item row in a scroller', + story: 'core-press-feedback-touch--rows', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(), +}; + +const TREE_ROW: RowStory = { + name: 'TreeList row', + story: 'core-treelist--basic', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'App.tsx'}) + .first(), +}; + +interface OpenRow { + driver: TouchDriver; + row: Locator; + /** + * A point inside the row's trailing padding, clear of its text, icons and + * (in a tree) the guide lines, CSS px. + */ + probe: {x: number; y: number}; + center: {x: number; y: number}; +} + +async function openRow(page: Page, story: RowStory): Promise { + await installLongTaskLog(page); + await openStory(page, storybook(), story.story); + const driver = await touchDriver(page); + const row = story.row(page); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error(`${story.name}: no row`); + } + return { + driver, + row, + probe: { + x: Math.round(box.x + box.width - 3), + y: Math.round(box.y + box.height / 2), + }, + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +/** Opacity of the row's press layer (the `::before`), as the engine presents it. */ +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +/** The press attribute the controller writes on the row. */ +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +interface PixelFrame { + readonly t: number; + /** Mean of the RGB channels at the probe point, 0–255. */ + readonly value: number; +} + +/** + * The probe point's value in every frame. Decoding runs in a scratch page of + * the same browser (no image decoder in Node), like the harness's own marker + * tracking. + */ +async function samplePixel( + page: Page, + frames: ReadonlyArray, + probe: {x: number; y: number}, +): Promise { + const scratch = await page.context().newPage(); + try { + const out: PixelFrame[] = []; + for (let i = 0; i < frames.length; i += 12) { + const batch = frames.slice(i, i + 12); + const values = await scratch.evaluate( + async ({items, at}) => { + const results: number[] = []; + for (const item of items) { + const bitmap = await createImageBitmap( + await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).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 px = context.getImageData( + Math.round(at.x * scale), + Math.round(at.y * scale), + 1, + 1, + ).data; + results.push((px[0] + px[1] + px[2]) / 3); + } + return results; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + })), + at: probe, + }, + ); + values.forEach((value, k) => out.push({t: batch[k].t, value})); + } + return out; + } finally { + await scratch.close(); + } +} + +test('a held row paints the pressed token and its release follows the cell release curve, sampled frame by frame', async ({ + page, +}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + expect(await layerOpacity(row)).toBe(0); + // Gate the page's timers before the press, so the controller's check at + // the release clock (after which the release ends with its animation) can + // be held while the release is seeked. + await installTimerGate(page); + await driver.play(tap(center, {holdMs: HOLD_MS})); + expect(await pressState(row)).toBe('fading'); + await freezeTimers(page); + const held = await holdAnimations(page, row); + const release = held.find( + a => a.target?.startsWith('li') && a.kind === 'CSSAnimation', + ); + expect(release, JSON.stringify(held)).toBeDefined(); + expect(release?.durationMs).toBe(RELEASE_MS); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(RELEASE_MS)) { + await seekAnimations(page, t); + samples.push({t, value: await layerOpacity(row)}); + } + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + const comparison = compareCurve(samples, expectedStrength, 0.005); + await writeEvidence( + 'curves/list-row-press-release', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + releaseMs: RELEASE_MS, + curve: list.highlight.releaseCurve, + held, + samples, + comparison, + }, + null, + 2, + ), + }, + testInfo, + ); + expect(samples[0].value).toBeCloseTo(1, 2); + expect( + comparison.maxError, + JSON.stringify(comparison.worst), + ).toBeLessThanOrEqual(0.005); + // At rest after the release: no press, no layer. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await layerOpacity(row)).toBe(0); +}); + +for (const story of [LIST_ROW, SCROLLER_ROW, TREE_ROW]) { + test(`${story.name}: the release keeps fading on every frame while the main thread is blocked`, async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright; WebKit has its own video proof below', + ); + const {driver, row, center, probe} = await openRow(page, story); + const cast = await startScreencast(page); + // Strength 0 is the row at rest; strength 1 the believed press. + await driver.play(tap(center, {holdMs: HOLD_MS})); + const [release] = await waitForAnimation(row, {minProgressMs: 16}); + const block = await blockMainThread(page, 700); + await page.waitForTimeout(150); + const frames = await cast.stop(); + const pixels = await samplePixel(page, frames, probe); + const rest = pixels[0].value; + const pressedFrames = pixels.filter(p => p.t < release.from); + const pressed = Math.min(...pressedFrames.map(p => p.value)); + const strength = (v: number) => (rest - v) / (rest - pressed); + // Judge the release while it was still visibly moving inside the block. + const end = Math.min(block.to, release.to) - 10; + const during = pixels.filter(p => p.t > block.from && p.t <= end); + const strengths = during.map(p => ({ + t: +(p.t - block.from).toFixed(1), + strength: +strength(p.value).toFixed(3), + })); + const intervals = during + .map((p, i) => (i === 0 ? p.t - block.from : p.t - during[i - 1].t)) + .filter(dt => dt > 0); + const longestGap = Math.max( + end - (during.at(-1)?.t ?? block.from), + ...intervals, + ); + await writeEvidence( + `busy-main-thread/${story.story}`, + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, release, rest, pressed, strengths}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, frames, { + name: `${story.story}-release-busy-main-thread`, + zero: block.from, + from: release.from - 40, + to: release.to + 40, + columns: 6, + cellScale: 0.45, + data: t => { + const p = pixels.find(f => f.t === t); + return {strength: p == null ? -1 : +strength(p.value).toFixed(3)}; + }, + }); + await film.write(testInfo, {block, release}); + // The press painted at full strength before the lift. + expect(rest - pressed).toBeGreaterThan(10); + // Frames kept coming through the block, each one further along the fade. + expect(during.length, JSON.stringify(strengths)).toBeGreaterThanOrEqual( + Math.floor((end - block.from) / (1000 / 60)) - 2, + ); + expect(longestGap).toBeLessThan(40); + for (let i = 1; i < strengths.length; i++) { + expect(strengths[i].strength).toBeLessThanOrEqual( + strengths[i - 1].strength + 0.01, + ); + } + const last = strengths.at(-1); + expect(last).toBeDefined(); + expect(strengths[0].strength).toBeGreaterThan((last?.strength ?? 1) + 0.2); + }); +} + +test('a busy first frame after the lift delays the release instead of skipping into it', async ({ + page, +}) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + // When the press controller writes the release, 150 ms of script hold the + // main thread before the first frame. The release starts when the browser + // styles it, so the first frame due after the block shows it at most one + // frame in. + await row.evaluate(el => { + (window as unknown as {__first: Promise}).__first = new Promise( + resolve => { + const observer = new MutationObserver(() => { + if (el.getAttribute('data-astryx-press') !== 'fading') { + return; + } + observer.disconnect(); + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + // The first frame due after the block: a frame scheduled before + // it runs with that earlier time. + const read = () => { + if ((document.timeline.currentTime as number) < until) { + requestAnimationFrame(read); + return; + } + resolve(Number(getComputedStyle(el, '::before').opacity)); + }; + requestAnimationFrame(read); + }); + observer.observe(el, {attributeFilter: ['data-astryx-press']}); + }, + ); + }); + await driver.play(tap(center, {holdMs: 300})); + const first = await page.evaluate( + async () => (window as unknown as {__first: Promise}).__first, + ); + // At most one frame into the release: its ease-out curve takes the first + // frame to about 0.74; a start pinned before the block would be past 0.4. + expect(first).toBeGreaterThan(0.7); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('a busy first frame after the lift still plays the whole release before the press attribute goes', async ({ + page, +}) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + // When the press controller writes the release, 150 ms of script hold the + // main thread before the first frame, so the release starts after the + // block. Every frame after it is read until the attribute goes: the layer + // must reach rest on the curve, not vanish while it is still painted. + await row.evaluate(el => { + const frames: [number, number, string | null][] = []; + (window as unknown as {__frames: typeof frames}).__frames = frames; + const observer = new MutationObserver(() => { + if (el.getAttribute('data-astryx-press') !== 'fading') { + return; + } + observer.disconnect(); + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + const sample = (time: number) => { + const layer = getComputedStyle(el, '::before'); + const strength = + layer.content === 'none' || layer.content === 'normal' + ? 0 + : Number(layer.opacity); + const press = el.getAttribute('data-astryx-press'); + if ((document.timeline.currentTime as number) >= until) { + frames.push([+(time - until).toFixed(1), strength, press]); + } + if (press === 'fading' && frames.length < 120) { + requestAnimationFrame(sample); + } + }; + requestAnimationFrame(sample); + }); + observer.observe(el, {attributeFilter: ['data-astryx-press']}); + }); + await driver.play(tap(center, {holdMs: 300})); + await expect.poll(async () => pressState(row)).toBeNull(); + const frames = await page.evaluate( + () => + (window as unknown as {__frames: [number, number, string | null][]}) + .__frames, + ); + const fading = frames.filter(([, , press]) => press === 'fading'); + expect(fading.length, JSON.stringify(frames)).toBeGreaterThan(3); + // The last frame still fading is at rest or within one frame of it: the + // attribute goes once the release has played, however late it started. + expect(fading.at(-1)?.[1], JSON.stringify(frames)).toBeLessThanOrEqual(0.05); + // And the release played on the way there, frame by frame. + for (let i = 1; i < fading.length; i++) { + expect(fading[i][1], JSON.stringify(frames)).toBeLessThanOrEqual( + fading[i - 1][1] + 0.01, + ); + } +}); + +test('the release does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, row, center} = await openRow(page, LIST_ROW); + let release = {from: 0, to: 0}; + const started = Date.now(); + const trace = await traceRendering(page, async () => { + await driver.play(tap(center, {holdMs: HOLD_MS})); + [release] = await waitForAnimation(row); + await page.waitForTimeout(RELEASE_MS + 100); + }); + // The steady part of the release: its first and last frames may commit. + const during = countBetween(trace, release.from + 30, release.to - 30); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(await readLongTasks(page, {from: started, to: Date.now()})).toEqual( + [], + ); +}); + +test('WebKit: the release keeps fading while the main thread is blocked (video)', async ({ + browser, + browserName, +}, testInfo) => { + test.skip( + browserName !== 'webkit', + 'the Chromium screencast test covers Chromium', + ); + const size = {width: 402, height: 714}; + const context = await browser.newContext({ + viewport: size, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + recordVideo: {dir: testInfo.outputPath('video'), size}, + }); + const page = await context.newPage(); + const {driver, center, probe} = await openRow(page, LIST_ROW); + // A beacon the main thread paints: blue while it is about to block, green + // once it has run again. Frames in which the beacon is blue were presented + // while script could not run. + await page.evaluate(() => { + const beacon = document.createElement('div'); + beacon.id = 'beacon'; + Object.assign(beacon.style, { + position: 'fixed', + right: '0', + bottom: '0', + width: '40px', + height: '40px', + background: 'rgb(128, 128, 128)', + zIndex: '2147483647', + }); + document.body.append(beacon); + }); + // Armed in the page so the block starts two frames after the lift, with + // no test-runner round trip in between: blue, committed by two frames, + // then the block; every frame showing blue was presented while script + // could not run. + await page.evaluate(() => { + addEventListener( + 'touchend', + () => { + const beacon = document.getElementById('beacon') as HTMLElement; + beacon.style.background = 'rgb(0, 0, 255)'; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const end = performance.now() + 700; + while (performance.now() < end) { + // blocked + } + beacon.style.background = 'rgb(0, 200, 0)'; + }), + ); + }, + {once: true, passive: true, capture: true}, + ); + }); + await page.waitForTimeout(300); + await driver.play(tap(center, {holdMs: HOLD_MS})); + await page.waitForTimeout(1300); + const video = page.video(); + await context.close(); + const path = await video?.path(); + expect(path).toBeTruthy(); + const bytes = (await readFile(path as string)).toString('base64'); + const decoder = await chromium.launch(); + try { + const player = await decoder.newPage(); + const samples = await player.evaluate( + async ({at, webm, beaconAt}) => { + const element = document.createElement('video'); + element.muted = true; + element.src = URL.createObjectURL( + new Blob([Uint8Array.from(atob(webm), c => c.charCodeAt(0))], { + type: 'video/webm', + }), + ); + document.body.append(element); + if (element.readyState < 1) { + await new Promise(r => + element.addEventListener('loadedmetadata', r, {once: true}), + ); + } + element.pause(); + const canvas = document.createElement('canvas'); + canvas.width = element.videoWidth; + canvas.height = element.videoHeight; + const context2d = canvas.getContext('2d', { + willReadFrequently: true, + }) as CanvasRenderingContext2D; + const out: {t: number; value: number; beacon: string}[] = []; + const duration = Number.isFinite(element.duration) + ? element.duration + : 10; + for (let t = 0; t < duration; t += 0.01) { + element.currentTime = t; + await new Promise(r => + element.addEventListener('seeked', r, {once: true}), + ); + context2d.drawImage(element, 0, 0); + const px = context2d.getImageData(at.x, at.y, 1, 1).data; + const b = context2d.getImageData(beaconAt.x, beaconAt.y, 1, 1).data; + const beacon = + b[2] > 180 && b[0] < 80 + ? 'blocked' + : b[1] > 150 && b[2] < 80 + ? 'after' + : 'before'; + out.push({t, value: (px[0] + px[1] + px[2]) / 3, beacon}); + } + return out; + }, + { + at: probe, + webm: bytes, + beaconAt: {x: size.width - 20, y: size.height - 20}, + }, + ); + const blocked = samples.filter(s => s.beacon === 'blocked'); + const rest = Math.max( + ...samples.filter(s => s.beacon === 'before').map(s => s.value), + ); + // Judge the release up to the frame it reaches the row's resting color. + // (Measured in the Linux WebKit build: once an accelerated animation has + // finished while script is still blocked, the compositor shows the value + // the main thread last committed until script runs again. That is this + // build's end-of-animation hand-back, after the release; iOS Safari 26.2 + // holds the resting color on the simulator. It is recorded below, not + // judged here.) + const restIndex = blocked.findIndex(s => s.value >= rest - 1); + const fading = restIndex === -1 ? blocked : blocked.slice(0, restIndex + 1); + const distinct = fading + .map(s => Math.round(s.value)) + .filter((v, i, all) => i === 0 || v !== all[i - 1]); + const afterEnd = restIndex === -1 ? [] : blocked.slice(restIndex + 1); + await writeEvidence( + 'busy-main-thread/list-row-release-webkit', + { + 'webkit.json': JSON.stringify( + {rest, blocked, distinct, afterEnd}, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'WebKit frames during the block', + description: `release: ${distinct.join(' ')}; after it ended, still blocked: ${[ + ...new Set(afterEnd.map(s => Math.round(s.value))), + ].join(' ')}`, + }); + expect(blocked.length).toBeGreaterThan(0); + // The row kept getting lighter while script could not run, all the way + // to its resting color: from a frame still short of rest, it reached rest + // inside the blocked window. A release on the main thread would hold its + // first frame's value until the block ends, 700 ms on, and could not. (The + // recording's frames are 40 ms apart, so how many land inside the 250 ms + // release depends on its phase: the count is not judged.) + const values = JSON.stringify(blocked.map(s => Math.round(s.value))); + expect(blocked[0].value, values).toBeLessThan(rest - 1); + expect(restIndex, values).toBeGreaterThan(0); + for (let i = 1; i < distinct.length; i++) { + expect(distinct[i], values).toBeGreaterThan(distinct[i - 1]); + } + } finally { + await decoder.close(); + } +}); + +test( + 'storyboard: a row released after a hold', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + await installTimerGate(page); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const clip = { + x: 0, + y: Math.max(0, box.y - 16), + width: page.viewportSize()?.width ?? 402, + height: box.height + 32, + }; + await driver.play(tap(center, {holdMs: HOLD_MS})); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'list-row-press-release', + clip, + scope: row, + measure: async () => ({ + opacity: await layerOpacity(row), + state: await pressState(row), + }), + columns: 7, + cellScale: 0.6, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + // The trace is the assertion; the sheet is the evidence. + const opacities = storyboard.frames.map(f => f.data.opacity as number); + expect(opacities[0]).toBeCloseTo(1, 2); + expect(opacities.at(-1)).toBeLessThan(0.01); + for (let i = 1; i < opacities.length; i++) { + expect(opacities[i]).toBeLessThanOrEqual(opacities[i - 1] + 1e-6); + } + storyboard.frames.forEach(f => + expect(f.data.opacity as number).toBeCloseTo(expectedStrength(f.t), 2), + ); + }, +); diff --git a/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts new file mode 100644 index 0000000000000..804721091904d --- /dev/null +++ b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts @@ -0,0 +1,361 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListRowPressPaths.interaction.browser.spec.ts + * @input The built List "Interactive" and Press Feedback "Rows" stories, the + * interaction harness, and the cell release clock (motion/uikit/list.ts) + * @output The row press paths beside the main release proofs: a press the + * browser takes for a scroll drops the highlight at once (the press + * controller's cancel, unchanged by the compositor layer); Reduce + * Motion keeps the fade (a fade, not a movement); a mouse press and hover on + * a fine pointer leave the press layer out of it + * @position Interaction evidence for the row press layer + * (utils/rowPressLayer.ts `rowPressLayerStyles.cell`), beside + * ListRowPress.interaction.browser.spec.ts + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + holdAnimations, + openStory, + releaseAnimations, + seekAnimations, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {list} from '../../motion/uikit/list'; + +const storybook = withStorybook(test); + +const RELEASE_MS: number = list.highlight.releaseMs; + +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +async function touchOnly(page: Page) { + const touch = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip(!touch, 'the press layer serves a touch screen'); +} + +test("a press the browser takes for a scroll drops the highlight at once (the controller's cancel, unchanged)", async ({ + page, +}) => { + await openStory(page, storybook(), 'core-press-feedback-touch--rows'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // Every press state the controller writes, and every animation the row's + // layer starts, with page times. + await row.evaluate(el => { + const log: string[] = []; + (window as unknown as {__pressLog: string[]}).__pressLog = log; + new MutationObserver(() => + log.push(`press:${el.getAttribute('data-astryx-press')}`), + ).observe(el, {attributeFilter: ['data-astryx-press']}); + el.addEventListener('animationstart', event => + log.push(`start:${(event as AnimationEvent).pseudoElement}`), + ); + }); + const from = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Held long enough for the highlight to show, then a vertical pan the + // scroller takes: the browser cancels the touch for the page. + await driver.play( + drag(from, {x: from.x, y: from.y - 80}, {holdMs: 300, durationMs: 160}), + ); + await expect.poll(async () => pressState(row)).toBeNull(); + const log = await page.evaluate( + () => (window as unknown as {__pressLog: string[]}).__pressLog, + ); + // Highlighted while held, then the scroll's cancel removes it outright: + // no release fade starts, and the row is at rest. + expect(log, JSON.stringify(log)).toContain('press:on'); + expect(log.at(-1), JSON.stringify(log)).toBe('press:null'); + expect(log, JSON.stringify(log)).not.toContain('press:fading'); + expect(log, JSON.stringify(log)).not.toContain('start:::before'); + expect(await layerOpacity(row)).toBe(0); +}); + +test('Reduce Motion keeps the release fade: a fade, not a movement', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await driver.play( + tap({x: box.x + box.width / 2, y: box.y + box.height / 2}, {holdMs: 300}), + ); + await holdAnimations(page); + await seekAnimations(page, 0); + expect(await layerOpacity(row)).toBeCloseTo(1, 2); + await seekAnimations(page, RELEASE_MS / 2); + const midway = await layerOpacity(row); + expect(midway).toBeGreaterThan(0.05); + expect(midway).toBeLessThan(0.95); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('a mouse press and hover on a fine pointer leave the press layer out of it', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), 'core-list--interactive'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const fine = await page.evaluate( + () => matchMedia('(pointer: fine)').matches, + ); + expect(fine).toBe(true); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // No layer at all with a fine pointer: hovering, pressing and lifting + // never give the row a `::before` box. + const layer = async () => + row.evaluate(el => getComputedStyle(el, '::before').content); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + expect(await layer()).toBe('none'); + await page.mouse.down(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + await page.mouse.up(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + } finally { + await context.close(); + } +}); + +test('a fine pointer: a row is as it was, with no press layer and no stacking context (desktop unchanged)', async ({ + browser, +}) => { + // AST-017 FR59/DEC-25: touch work leaves a fine pointer's rows as they were. The + // press layer and the stacking context it needs exist under a finger only, + // so a caller's own `::before` and z-index behave as before. + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + const rowBox = async (story: string, selector: string, label: string) => { + await openStory(page, storybook(), story); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const row = page.locator(selector).filter({hasText: label}).first(); + await expect(row).toBeVisible(); + return row.evaluate(el => ({ + before: getComputedStyle(el, '::before').content, + isolation: getComputedStyle(el).isolation, + })); + }; + expect( + await rowBox('core-list--interactive', '[data-astryx-pressable]', 'Sent'), + ).toEqual({before: 'none', isolation: 'auto'}); + // A row's own `::before` is exactly as its author wrote it: nothing of + // the layer (its opacity, stacking, hit testing) reaches it. + const own = await page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first() + .evaluate(el => { + const style = document.createElement('style'); + style.textContent = + '[data-test-own]::before{content:"•";position:static;opacity:1}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + }; + }); + expect(own).toEqual({ + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + }); + expect( + await rowBox( + 'core-treelist--interactive', + '#storybook-root [role="treeitem"] [data-astryx-pressable]', + 'Settings', + ), + ).toEqual({before: 'none', isolation: 'auto'}); + } finally { + await context.close(); + } +}); + +test('a radio row under a finger paints its circle’s press, and keeps its bare look: no row layer', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-radiolist--default'); + await touchOnly(page); + const row = page.locator('#storybook-root [data-astryx-pressable]').first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // On the label, away from the circle: the row takes the press. + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width * 0.6, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + const look = await row.evaluate(el => { + const wrapper = [...el.querySelectorAll('*')].find( + child => + child.querySelector(':scope > input[type="radio"]') != null && + getComputedStyle(child, '::after').content !== 'none', + ); + return { + circle: + wrapper == null + ? null + : getComputedStyle(wrapper, '::after').backgroundImage, + layer: getComputedStyle(el, '::before').content, + }; + }); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The circle's own press paint: the pressed token, not a transparent + // gradient. + expect(look.circle).toMatch(/gradient/); + expect(look.circle).not.toMatch( + /^linear-gradient\(rgba\(0, 0, 0, 0\), rgba\(0, 0, 0, 0\)\)$/, + ); + // Only the circle paints, as with a mouse: the row has no layer. + expect(look.layer).toBe('none'); +}); + +test('under a finger, a row at rest is as it was: its own ::before keeps its look and it is no stacking context; the press layer takes the ::before only while pressed', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const look = async () => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + isolation: getComputedStyle(el).isolation, + }; + }); + // A caller's own decoration on the row's `::before` (a page rule beats + // the components' layered styles for what it sets; the rest is theirs). + await row.evaluate(el => { + const style = document.createElement('style'); + style.textContent = '[data-test-own]::before{content:"•"}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + }); + const own = { + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + isolation: 'auto', + }; + expect(await look()).toEqual(own); + // A press: the layer takes the `::before`, in its own stacking context. + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width / 2, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + // The layer's box (with the caller's content), painted, in its own + // stacking context. + const pressed = await look(); + expect(pressed.content).toBe('"•"'); + expect(pressed.position).toBe('absolute'); + expect(pressed.zIndex).toBe('-1'); + expect(pressed.opacity).toBe('1'); + expect(pressed.isolation).toBe('isolate'); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The press over, the row's own decoration is back as it was. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await look()).toEqual(own); +}); diff --git a/packages/core/src/List/__tests__/ListTouchHitArea.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListTouchHitArea.interaction.browser.spec.ts new file mode 100644 index 0000000000000..e7c16b5944371 --- /dev/null +++ b/packages/core/src/List/__tests__/ListTouchHitArea.interaction.browser.spec.ts @@ -0,0 +1,626 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListTouchHitArea.interaction.browser.spec.ts + * @input The built List stories in the touch phone context (primary pointer + * coarse) and in a mouse context of the same viewport and scale + * @output Proofs for `spec:AST-094` FR42 (`component:List` FR10) under a finger: an interactive + * row's hit layer grows toward the touch target, taking half the gap to + * each neighboring row and growing further only where nothing is near; a + * tap in that growth activates the row and a tap past it does not; no two + * rows' areas overlap; the row's own content keeps its taps; a row already + * as tall as the touch target and a disabled row gain nothing; the theme's + * touch target and row padding reach it; the layer adds no layout and + * paints nothing; a mouse gets no layer. Geometry is read from the layer's + * computed edges: Chromium's elementFromPoint snaps a point to whole CSS + * pixels, too coarse for a 1 px half gap, so the hit tests probe whole + * pixels inside a wider growth + * @position Interaction evidence for ListItem's touch hit area + * (utils/touchHitArea.stylex.ts, kept live by utils/touchHitArea.ts) under a finger + * (./touchStory.ts) + */ + +import { + expect, + test, + type Browser, + type Page, + type TestInfo, +} from '@playwright/test'; +import {withStorybook} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; + +const storybook = withStorybook(test); + +interface Row { + readonly text: string; + readonly top: number; + readonly bottom: number; + readonly height: number; + readonly x: number; + readonly layer: { + readonly content: string; + readonly top: number; + readonly bottom: number; + readonly paint: string; + }; + readonly isolation: string; + readonly written: string; +} + +/** The story's rows (Storybook's own hidden error page has list items too). */ +async function rowsOf(page: Page): Promise { + return page.locator('#storybook-root li').evaluateAll(elements => + elements.map(el => { + const box = el.getBoundingClientRect(); + const layer = getComputedStyle(el, '::after'); + const own = getComputedStyle(el); + return { + text: el.textContent?.trim() ?? '', + top: box.top, + bottom: box.bottom, + height: box.height, + x: box.left + box.width / 2, + layer: { + content: layer.content, + // How far the layer reaches past the row's border box, px: its + // offsets are from the row's padding box, so past a border (a + // divided row's) it reaches that much less. + top: + layer.content === 'none' + ? 0 + : 0 - + Number.parseFloat(layer.top) - + Number.parseFloat(own.borderTopWidth), + bottom: + layer.content === 'none' + ? 0 + : 0 - + Number.parseFloat(layer.bottom) - + Number.parseFloat(own.borderBottomWidth), + paint: [ + layer.backgroundColor, + layer.backgroundImage, + layer.boxShadow, + layer.borderTopWidth, + layer.outlineStyle, + ].join(' | '), + }, + isolation: getComputedStyle(el).isolation, + written: (el as HTMLElement).style.getPropertyValue( + '--astryx-hit-inset-top', + ), + }; + }), + ); +} + +/** + * The rows once the shared hit area has measured them: its pass runs in a + * frame after the rows show, and writes only insets that differ from 0, so + * wait a few frames and then until two frames read the same. + */ +async function measuredRowsOf(page: Page): Promise { + const frames = async (count: number) => + page.evaluate(async n => { + for (let i = 0; i < n; i++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + } + }, count); + await frames(4); + let previous = JSON.stringify(await rowsOf(page)); + await expect + .poll(async () => { + await frames(2); + const current = JSON.stringify(await rowsOf(page)); + const settled = current === previous; + previous = current; + return settled; + }) + .toBe(true); + return rowsOf(page); +} + +/** A theme that sets the touch target size, before the story renders. */ +async function themeTouchTarget(page: Page, size: string): Promise { + // Storybook rewrites the root element's style attribute, so a stylesheet. + await page.addInitScript(value => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = `:root { --size-touch-target: ${value}; }`; + document.head.append(style); + }); + }, size); +} + +/** The story row the browser hit-tests at a point, by its text. */ +async function rowAt(page: Page, x: number, y: number): Promise { + return page.evaluate( + ([px, py]) => + document + .elementFromPoint(px, py) + ?.closest('#storybook-root li') + ?.textContent?.trim() ?? null, + [x, y] as const, + ); +} + +const isTouch = async (page: Page) => + page.evaluate(() => matchMedia('(pointer: coarse)').matches); + +/** FR42's invariant: no row's area reaches into the next row's area. */ +function expectNoOverlap(rows: ReadonlyArray): void { + for (let i = 0; i + 1 < rows.length; i++) { + expect( + rows[i].bottom + rows[i].layer.bottom, + JSON.stringify(rows), + ).toBeLessThanOrEqual(rows[i + 1].top - rows[i + 1].layer.top + 0.01); + } +} + +/** The same page with a mouse: the phone's viewport and scale, a fine pointer. */ +async function withMouse( + browser: Browser, + testInfo: TestInfo, + run: (page: Page) => Promise, +): Promise { + const {viewport, deviceScaleFactor} = testInfo.project.use; + const context = await browser.newContext({ + viewport, + deviceScaleFactor, + hasTouch: false, + isMobile: false, + }); + try { + const page = await context.newPage(); + return await run(page); + } finally { + await context.close(); + } +} + +test('under a finger, a short interactive row takes half the gap to each neighbor and grows toward the touch target where nothing is near', async ({ + page, +}) => { + await openStory(page, storybook(), 'core-list--interactive'); + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + const rows = await measuredRowsOf(page); + expect(rows).toHaveLength(3); + const gap = rows[1].top - rows[0].bottom; + const grow = (44 - rows[0].height) / 2; + const summary = JSON.stringify({gap, grow, rows}); + // One-line rows, shorter than the 44px touch target, 2px apart. + expect(rows[0].height, summary).toBeLessThan(44); + expect(gap, summary).toBeCloseTo(2, 1); + // Half the gap between rows; the full growth past the list's ends, where + // nothing interactive is near. + const reach = rows.map(row => [row.layer.top, row.layer.bottom]); + expect(reach, summary).toEqual([ + [grow, gap / 2], + [gap / 2, gap / 2], + [gap / 2, grow], + ]); + expectNoOverlap(rows); +}); + +test('under a finger, a tap in a row’s growth activates that row, and nothing is hit past it', async ({ + page, +}) => { + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + // A theme with a 60px touch target, so the growth past the list's first row + // spans whole pixels a tap can land on. + await themeTouchTarget(page, '60px'); + await openStory(page, storybook(), 'core-list--interactive'); + const rows = await measuredRowsOf(page); + const grow = (60 - rows[0].height) / 2; + expect(rows[0].layer.top, JSON.stringify(rows)).toBeCloseTo(grow, 1); + const inside = Math.round(rows[0].top - grow / 2); + const outside = Math.round(rows[0].top - grow - 3); + // The browser's own hit test at each point, then a real tap in the growth. + // (No tap past it: a touch engine's own touch adjustment may move a tap + // that lands a few pixels from a target onto it.) + expect(await rowAt(page, rows[0].x, inside)).toBe(rows[0].text); + expect(await rowAt(page, rows[0].x, outside)).toBe(null); + await page.evaluate(() => { + const taps: string[] = []; + (window as unknown as {taps: string[]}).taps = taps; + for (const row of document.querySelectorAll('#storybook-root li')) { + row.addEventListener('click', () => + taps.push(row.textContent?.trim() ?? ''), + ); + } + }); + await page.touchscreen.tap(rows[0].x, inside); + expect( + await page.evaluate(() => (window as unknown as {taps: string[]}).taps), + ).toEqual([rows[0].text]); +}); + +test('under a finger, the row’s own content keeps its taps', async ({page}) => { + await openStory(page, storybook(), 'core-list--interactive'); + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + await measuredRowsOf(page); + const hits = await page.locator('#storybook-root li').evaluateAll(rows => + rows.map(row => { + // The row's end content (its chevron), above the layer. + const icons = row.querySelectorAll('svg'); + const end = icons[icons.length - 1]; + const box = end.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + return hit != null && hit !== row && row.contains(hit); + }), + ); + expect(hits).toEqual([true, true, true]); +}); + +test('under a finger, a row’s end control keeps its taps, and the interactive rows beside it grow short of it', async ({ + page, +}) => { + await openStory(page, storybook(), 'core-list--with-end-controls'); + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + const rows = await measuredRowsOf(page); + // (The switch's own hidden label repeats the row's text, and Network's + // swipe action adds its own.) + expect(rows.map(row => row.text)).toEqual([ + expect.stringMatching(/^Network/), + expect.stringMatching(/^Wi-Fi/), + expect.stringMatching(/^Bluetooth/), + ]); + // The control's row is not interactive: it has no hit area of its own. + expect(rows[1].layer.content, JSON.stringify(rows)).toBe('none'); + const toggle = page.getByRole('switch', {name: 'Wi-Fi'}); + await expect(toggle).toBeChecked(); + const control = await toggle.evaluate(input => { + const box = (input.parentElement ?? input).getBoundingClientRect(); + const own = input.getBoundingClientRect(); + return { + top: box.top, + bottom: box.bottom, + x: own.left + own.width / 2, + y: own.top + own.height / 2, + }; + }); + // The rows beside it grow toward the touch target only short of it: to + // half the gap to the control, the control's half being the control's. + const [above, , below] = rows; + expect(above.layer.bottom, JSON.stringify({rows, control})).toBeCloseTo( + (control.top - above.bottom) / 2, + 1, + ); + expect(below.layer.top, JSON.stringify({rows, control})).toBeCloseTo( + (below.top - control.bottom) / 2, + 1, + ); + expect( + above.bottom + above.layer.bottom, + JSON.stringify({rows, control}), + ).toBeLessThanOrEqual(control.top); + expect( + below.top - below.layer.top, + JSON.stringify({rows, control}), + ).toBeGreaterThanOrEqual(control.bottom); + // The browser sends a tap in Network's band to Network, over the static + // row after it, up to the band's end; and one in Bluetooth's band to + // Bluetooth. Network is swipeable, so it is a stacking context at rest, + // and its band still wins. (Hit tests, not taps: a touch engine's own + // touch adjustment may move a tap a few pixels onto a target.) + const owner = async (y: number) => + page.evaluate( + ({x, y: at}) => { + const row = document + .elementFromPoint(x, at) + ?.closest('#storybook-root li'); + if (row == null) { + return null; + } + // The row's own text, without its swipe actions' labels. + const copy = row.cloneNode(true) as Element; + for (const panel of copy.querySelectorAll('[data-swipe-panel]')) { + panel.remove(); + } + return (copy.textContent ?? '').trim().split(/\s+/)[0] ?? null; + }, + {x: above.x, y}, + ); + expect( + await page + .locator('#storybook-root li') + .first() + .evaluate(row => getComputedStyle(row).isolation), + ).toBe('isolate'); + expect(above.layer.bottom, JSON.stringify(rows)).toBeGreaterThan(1); + expect(await owner(above.bottom + 0.5)).toBe('Network'); + expect(await owner(above.bottom + above.layer.bottom - 0.5)).toBe('Network'); + expect(await owner(above.bottom + above.layer.bottom + 0.5)).toBe('Wi-Fi'); + expect(await owner(below.top - below.layer.top + 0.5)).toBe('Bluetooth'); + // The browser hit-tests the control there, and a real tap toggles it, + // with no tap reaching the rows beside it. + expect( + await page.evaluate( + ({x, y}) => + document.elementFromPoint(x, y)?.closest('[role="switch"]') != null, + control, + ), + ).toBe(true); + await page.evaluate(() => { + const taps: string[] = []; + (window as unknown as {taps: string[]}).taps = taps; + for (const row of document.querySelectorAll('#storybook-root li')) { + row.addEventListener('click', event => { + if ( + !(event.target instanceof Element) || + event.target.closest('[role="switch"]') == null + ) { + taps.push(row.textContent?.trim() ?? ''); + } + }); + } + }); + await page.touchscreen.tap(control.x, control.y); + await expect(toggle).not.toBeChecked(); + expect( + await page.evaluate(() => (window as unknown as {taps: string[]}).taps), + ).toEqual([]); +}); + +test('under a finger, a row’s band takes no tap from a control near the list: a button in its header, or a button just after it', async ({ + page, +}) => { + await openStory(page, storybook(), 'core-list--with-end-controls'); + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + const rows = await measuredRowsOf(page); + const first = rows[0]; + const last = rows[rows.length - 1]; + const box = async (name: string) => + page.getByRole('button', {name, exact: true}).evaluate(el => { + const r = el.getBoundingClientRect(); + return {top: r.top, bottom: r.bottom, x: r.left + r.width / 2}; + }); + const edit = await box('Edit'); + const more = await box('Load more'); + // What the browser hit-tests at a point: a button's name, or a row's + // first word. + const hit = async (x: number, y: number) => + page.evaluate( + ({x: px, y: py}) => { + const el = document.elementFromPoint(px, py); + const button = el?.closest('button'); + if (button != null && button.closest('li') == null) { + return button.textContent?.trim() ?? ''; + } + return ( + (el?.closest('#storybook-root li')?.textContent ?? '') + .trim() + .split(/\s+/)[0] || null + ); + }, + {x, y}, + ); + // The button 4 px after the list: the last row's band stops at half that + // gap, so the button keeps every point of its own box, and the band keeps + // its half. + expect(more.top - last.bottom, JSON.stringify({rows, more})).toBeCloseTo( + 4, + 0, + ); + expect(last.layer.bottom, JSON.stringify({rows, more})).toBeLessThanOrEqual( + (more.top - last.bottom) / 2 + 0.01, + ); + expect(await hit(more.x, more.top + 0.5)).toBe('Load more'); + expect(await hit(last.x, last.bottom + last.layer.bottom - 0.5)).toBe( + 'Bluetooth', + ); + // The header's button above the first row keeps its own box too. + expect( + first.top - first.layer.top, + JSON.stringify({rows, edit}), + ).toBeGreaterThanOrEqual(edit.bottom - 0.01); + expect(await hit(edit.x, edit.bottom - 0.5)).toBe('Edit'); +}); + +test('under a finger, a row already as tall as the touch target gains nothing, rows that touch take nothing from each other, and a disabled row has no layer', async ({ + page, +}) => { + test.slow(); + // Links: a one-line row, then two rows with descriptions (taller than the + // touch target): only the short row grows. + await openStory(page, storybook(), 'core-list--links'); + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + const links = await measuredRowsOf(page); + const summary = JSON.stringify(links); + expect(links[0].height, summary).toBeLessThan(44); + expect(links[0].layer.bottom, summary).toBeCloseTo( + (links[1].top - links[0].bottom) / 2, + 1, + ); + for (const row of links.slice(1)) { + expect(row.height, summary).toBeGreaterThanOrEqual(44); + expect([row.layer.top, row.layer.bottom], summary).toEqual([0, 0]); + } + expectNoOverlap(links); + // Compact with dividers: the rows touch, so between them nothing grows. + await openStory(page, storybook(), 'core-list--compact'); + const compact = await measuredRowsOf(page); + expect(compact.length).toBeGreaterThan(1); + for (let i = 0; i + 1 < compact.length; i++) { + expect(compact[i].layer.bottom, JSON.stringify(compact)).toBe(0); + expect(compact[i + 1].layer.top, JSON.stringify(compact)).toBe(0); + } + expectNoOverlap(compact); + // A disabled row offers no target: no layer, nothing measured. + await openStory(page, storybook(), 'core-list--disabled-items'); + await measuredRowsOf(page); + const disabled = await page + .locator('#storybook-root li[aria-disabled="true"]') + .evaluateAll(rows => + rows.map(row => [ + getComputedStyle(row, '::after').content, + (row as HTMLElement).style.getPropertyValue('--astryx-hit-inset-top'), + ]), + ); + expect(disabled.length).toBeGreaterThan(0); + for (const [content, written] of disabled) { + expect(content).toBe('none'); + expect(written).toBe(''); + } +}); + +test('under a finger, a page can raise the touch target but never lower it below 44px, and row padding reaches the layer', async ({ + page, +}) => { + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + // A page's touch target under 44px leaves the 44px target (spec:AST-094 + // FR42): the rows grow exactly as with none set. + await openStory(page, storybook(), 'core-list--interactive'); + const plain = (await measuredRowsOf(page)).map(row => row.layer); + await themeTouchTarget(page, '30px'); + await openStory(page, storybook(), 'core-list--interactive'); + const lowered = (await measuredRowsOf(page)).map(row => row.layer); + expect(plain.some(layer => layer.top > 0 || layer.bottom > 0)).toBe(true); + expect(lowered).toEqual(plain); + // A theme whose row padding makes the row as tall as the target (the later + // stylesheet's 44px wins over the 30px one). + await themeTouchTarget(page, '44px'); + await page.addInitScript(() => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = + '#storybook-root li { padding-block: 12px !important; }'; + document.head.append(style); + }); + }); + await openStory(page, storybook(), 'core-list--interactive'); + const padded = await measuredRowsOf(page); + expect(padded[0].height).toBeGreaterThanOrEqual(44); + for (const row of padded) { + expect([row.layer.top, row.layer.bottom]).toEqual([0, 0]); + } +}); + +test('under a finger, a tap in a link row’s growth follows its link', async ({ + page, +}) => { + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + // A 60px touch target, so the growth above the first row spans whole + // pixels a tap can land on, well clear of the row's anchor. + await themeTouchTarget(page, '60px'); + await openStory(page, storybook(), 'core-list--links'); + const rows = await measuredRowsOf(page); + const grow = rows[0].layer.top; + expect(grow, JSON.stringify(rows)).toBeGreaterThan(4); + const inside = Math.round(rows[0].top - grow / 2); + expect(await rowAt(page, rows[0].x, inside)).toBe(rows[0].text); + // The row is the Links story's "Documentation" (`href="/docs"`). + const followed = page.waitForURL(/\/docs$/, {timeout: 5000}); + await page.touchscreen.tap(rows[0].x, inside); + await followed; +}); + +test('under a finger, a tap in a link row’s own padding, outside its anchor, follows its link', async ({ + page, +}) => { + test.skip(!(await isTouch(page)), 'the hit area applies under a finger only'); + await openStory(page, storybook(), 'core-list--links'); + const row = page.locator('#storybook-root li').first(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // The row's leading padding: inside its box, outside its anchor. (Chromium + // also moves a tap this close onto the anchor by its touch adjustment; + // WebKit does not, and its click from a finger says `mouse`.) + const x = box.x + 2; + const y = box.y + box.height / 2; + expect( + await page.evaluate( + ([px, py]) => document.elementFromPoint(px, py)?.closest('a') == null, + [x, y], + ), + ).toBe(true); + // The row is the Links story's "Documentation" (`href="/docs"`). + const followed = page.waitForURL(/\/docs$/, {timeout: 5000}); + await page.touchscreen.tap(x, y); + await followed; +}); + +test('a mouse click in a link row’s padding does nothing, as before (spec:AST-017 FR59)', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 900, height: 700}, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), 'core-list--links'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const row = page.locator('#storybook-root li').first(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const url = page.url(); + // The row's leading padding: inside its box, outside its anchor. + await page.mouse.click(box.x + 2, box.y + box.height / 2); + await page.waitForTimeout(500); + expect(page.url()).toBe(url); + } finally { + await context.close(); + } +}); + +for (const story of [ + 'core-list--interactive', + 'core-list--links', + 'core-list--compact', + 'core-list--with-media', +]) { + test(`${story}: the layer adds no layout and paints nothing, and a mouse gets none`, async ({ + browser, + page, + }, testInfo) => { + await openStory(page, storybook(), story); + test.skip( + !(await isTouch(page)), + 'the hit area applies under a finger only', + ); + const touch = await measuredRowsOf(page); + // The same page with every row's layer taken away: the same boxes. + await page.addStyleTag({ + content: '#storybook-root li::after { display: none !important; }', + }); + const bare = await rowsOf(page); + const mouse = await withMouse(browser, testInfo, async mousePage => { + await openStory(mousePage, storybook(), story); + expect(await isTouch(mousePage)).toBe(false); + // A frame for a pass that must not come. + await mousePage.evaluate(async () => { + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + return rowsOf(mousePage); + }); + const summary = JSON.stringify({touch, bare, mouse}); + expect(touch.length, summary).toBeGreaterThan(0); + expect(bare.length, summary).toBe(touch.length); + expect(mouse.length, summary).toBe(touch.length); + for (const [i, row] of touch.entries()) { + expect(row.top, summary).toBeCloseTo(bare[i].top, 2); + expect(row.height, summary).toBeCloseTo(bare[i].height, 2); + if (row.layer.content !== 'none') { + expect(row.layer.paint, summary).toMatch( + /^rgba\(0, 0, 0, 0\) \| none \| none \| 0px \| none$/, + ); + } + // A mouse: no pseudo-element, no stacking context, nothing measured. + expect(mouse[i].layer.content, summary).toBe('none'); + expect(mouse[i].isolation, summary).toBe('auto'); + expect(mouse[i].written, summary).toBe(''); + } + }); +} diff --git a/packages/core/src/List/__tests__/touchStory.ts b/packages/core/src/List/__tests__/touchStory.ts new file mode 100644 index 0000000000000..e33cdd9f32111 --- /dev/null +++ b/packages/core/src/List/__tests__/touchStory.ts @@ -0,0 +1,44 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchStory.ts + * @input Playwright's Page and a built Storybook's origin + * @output `openStory(page, origin, storyId, options)`: opens a story and + * waits for its first rows, its fonts and two frames + * @position Shared helper for the list specs. Their behaviour is the one + * touch model under any finger (TOUCH_QUERY in utils/touchPresentation.ts): + * the projects' contexts already have the finger, and nothing is emulated, + * so Chromium shows it as Android's engine does and WebKit as Safari's. A + * mouse case opens its own context without touch. + * + * SYNC: When modified, update the specs that import it. + */ + +import type {Page} from '@playwright/test'; + +/** + * Opens a story and waits for its first rows, its fonts and two frames. + * `globals`: Storybook globals such as `direction:rtl`. + */ +export async function openStory( + page: Page, + origin: string, + storyId: string, + {globals}: {globals?: string} = {}, +): Promise { + const params = new URLSearchParams({id: storyId, viewMode: 'story'}); + if (globals != null) { + params.set('globals', globals); + } + await page.goto(`${origin}/iframe.html?${params.toString()}`); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); +} diff --git a/packages/core/src/NumberInput/NumberInput.doc.mjs b/packages/core/src/NumberInput/NumberInput.doc.mjs index eb33db907e06a..19e5164edd8e1 100644 --- a/packages/core/src/NumberInput/NumberInput.doc.mjs +++ b/packages/core/src/NumberInput/NumberInput.doc.mjs @@ -226,12 +226,14 @@ export const docs = { { name: 'containerClassName', type: 'string', - description: 'Class name for the field container. Same targeting as `containerXstyle`.', + description: + 'Class name for the field container. Same targeting as `containerXstyle`.', }, { name: 'containerStyle', type: 'CSSProperties', - description: 'Inline styles for the field container. Same targeting as `containerXstyle`.', + description: + 'Inline styles for the field container. Same targeting as `containerXstyle`.', }, { name: 'controlXstyle', @@ -336,7 +338,7 @@ export const docs = { name: 'Number steppers', required: false, description: - 'Optional buttons that increment or decrement by the configured step.', + 'Optional buttons that increment or decrement by the configured step. Under a finger, on iOS and Android alike, they sit beside the field as one control, − at the start and + at the end, mirrored in right-to-left layouts, as on iOS (inside an InputGroup, or in a field narrower than 170 px, they stay in the field). A tap steps once when the pointer lifts. A button held down by a finger, a pen or a mouse keeps stepping: after half a second, then faster and faster until the pointer lifts; sliding away pauses it and coming back resumes it. The field shows each step as it comes and calls onChange once, with the final value, when the press ends.', }, ], }, @@ -641,7 +643,7 @@ export const docsZh = { name: 'Number steppers', required: false, description: - 'Optional buttons that increment or decrement by the configured step.', + 'Optional buttons that increment or decrement by the configured step. Under a finger, on iOS and Android alike, they sit beside the field as one control, − at the start and + at the end, mirrored in right-to-left layouts, as on iOS (inside an InputGroup, or in a field narrower than 170 px, they stay in the field). A tap steps once when the pointer lifts. A button held down by a finger, a pen or a mouse keeps stepping: after half a second, then faster and faster until the pointer lifts; sliding away pauses it and coming back resumes it. The field shows each step as it comes and calls onChange once, with the final value, when the press ends.', }, ], }, @@ -715,15 +717,17 @@ export const docsDense = { name: 'Number steppers', required: false, description: - 'Optional buttons that increment or decrement by the configured step.', + 'Optional buttons that increment or decrement by the configured step. Under a finger, on iOS and Android alike, they sit beside the field as one control, − at the start and + at the end, mirrored in right-to-left layouts, as on iOS (inside an InputGroup, or in a field narrower than 170 px, they stay in the field). A tap steps once when the pointer lifts. A button held down by a finger, a pen or a mouse keeps stepping: after half a second, then faster and faster until the pointer lifts; sliding away pauses it and coming back resumes it. The field shows each step as it comes and calls onChange once, with the final value, when the press ends.', }, ], }, propDescriptions: { - containerXstyle: 'StyleX styles for the field container; no effect in InputGroup or horizontal-labels FormLayout. No root xstyle/className/style.', + containerXstyle: + 'StyleX styles for the field container; no effect in InputGroup or horizontal-labels FormLayout. No root xstyle/className/style.', containerClassName: 'Class name for the field container.', containerStyle: 'Inline styles for the field container.', - controlXstyle: 'StyleX styles for the visual control box (border, background, icons); not the native input.', + controlXstyle: + 'StyleX styles for the visual control box (border, background, icons); not the native input.', controlClassName: 'Class name for the visual control box.', controlStyle: 'Inline styles for the visual control box.', label: 'Label text (always rendered for accessibility).', diff --git a/packages/core/src/NumberInput/NumberInput.spec.md b/packages/core/src/NumberInput/NumberInput.spec.md index 8c187c5d4c41c..593358a8fb504 100644 --- a/packages/core/src/NumberInput/NumberInput.spec.md +++ b/packages/core/src/NumberInput/NumberInput.spec.md @@ -14,11 +14,17 @@ verified_by: [ packages/core/src/NumberInput/NumberInput.test.tsx, packages/core/src/NumberInput/NumberInput.fr10.test.tsx, + packages/core/src/NumberInput/NumberInput.stepperLook.test.tsx, + packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts, + packages/core/src/NumberInput/stepperTracking.test.ts, + packages/core/src/NumberInput/NumberInput.stepperTouch.test.tsx, + packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts, packages/core/src/NumberInput/numberInputCommit.test.ts, packages/core/src/NumberInput/numberParser.test.ts, packages/core/src/NumberInput/numberParser.docblock.test.ts, packages/core/src/NumberInput/__tests__/NumberInput.a11y.test.tsx, packages/core/src/NumberInput/__tests__/NumberInput.a11y.chromium.spec.ts, + packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts, packages/core/src/InputGroup/InputGroup.test.tsx, ] modules: [] @@ -40,15 +46,15 @@ system_specs: [spec:AST-085, spec:AST-086, spec:AST-102] ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. NumberInput is a controlled numeric field with Field props, size, status, `min`, `max`, `step`, `isIntegerOnly`, `formatValue`, `units`, steppers, wheel stepping, clear, disabled and read-only states, and the input-family part props. | -| Behavior | A text-backed `spinbutton`. Typing edits a draft that commits only on blur or Enter, parsed for the locale and clamped to the range. Arrow keys, the wheel, and the steppers step on a grid and commit at once. | -| End-user impact | People can type or paste numbers the way their locale and spreadsheets write them, and get told at once when an entry is invalid. Keyboard and screen-reader users can reach a disabled field. Read-only is not announced. | -| Builder impact | None. | -| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. The read-only state is not exposed to assistive technology, and the clear button scales under reduced motion; both are listed under Current-state impact. | -| Review checks | Reject an `onChange` for every keystroke, a commit of an unparsable draft, a step off the grid or past a bound, a disabled field that leaves the tab order or submits, or arrows that step during IME composition. | -| Governing rules | `family:input-fields` FR3, FR4, FR8–FR11; `spec:AST-085` FR1–FR3, FR6; `spec:AST-086` FR3, FR4; `architecture:internationalization`; `design:reduced-motion` DR1. | +| Area | Contract | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | None changes. NumberInput is a controlled numeric field with Field props, size, status, `min`, `max`, `step`, `isIntegerOnly`, `formatValue`, `units`, steppers, wheel stepping, clear, disabled and read-only states, and the input-family part props. | +| Behavior | A text-backed `spinbutton`. Typing edits a draft that commits only on blur or Enter, parsed for the locale and clamped to the range. Arrow keys, the wheel, and a stepper press step on a grid and commit at once; a held stepper repeats and commits once, at its release. | +| End-user impact | People can type or paste numbers the way their locale and spreadsheets write them, and get told at once when an entry is invalid. Keyboard and screen-reader users can reach a disabled field. Read-only is not announced. | +| Builder impact | None. | +| Compatibility/readiness | Records shipped behavior; no API, default, or DOM change. The read-only state is not exposed to assistive technology, and the clear button scales under reduced motion; both are listed under Current-state impact. | +| Review checks | Reject an `onChange` for every keystroke, a commit of an unparsable draft, a step off the grid or past a bound, a disabled field that leaves the tab order or submits, or arrows that step during IME composition. | +| Governing rules | `family:input-fields` FR3, FR4, FR8–FR11; `spec:AST-085` FR1–FR3, FR6; `spec:AST-086` FR3, FR4; `architecture:internationalization`; `design:reduced-motion` DR1. | This table is a review projection; the body below is authoritative. @@ -112,21 +118,21 @@ Consumer migration instructions belong in consumer docs and release notes. ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| FR1 | The input is a native text input with `role="spinbutton"`, `inputMode` `numeric` when integer-only and `decimal` otherwise, `aria-valuemin` and `aria-valuemax` from the range, `aria-valuenow` from the committed `value`, and `aria-valuetext` set to the formatted value when `formatValue` is supplied. | Current tests | -| FR2 | Typing changes only a draft; `onChange` is not called while typing. A draft is valid when it parses for the active locale (grouping, decimal separator, digit script, and sign) and, for validity display, lies within the range and meets integer-only. An invalid draft colors the text secondary, sets `aria-invalid`, and fills a persistent assertive region with the localized invalid-number message. | `spec:AST-086` FR3; `architecture:internationalization`; current tests | -| FR3 | On blur the draft resolves once and ends: an empty draft clears (`onChange(null)` when clearable and a value exists) or reverts; an unparsable or wrong-kind draft reverts; otherwise the number clamps to the range and commits with `onChange(number)` when it differs from `value`. Enter resolves a valid draft the same way and clears an empty draft when clearable. Enter commits nothing for an empty draft without `hasClear`, which stays in the field and is not marked invalid, or for an unparsable or wrong-kind draft, which stays in the field and stays invalid. Enter ends the draft only when its commit clamped. In a field that is not disabled, every Enter outside composition then calls `onEnter`, whatever it committed. | Current tests | -| FR4 | ArrowUp and ArrowDown without modifiers, the wheel over the focused input (when `isWheelEnabled`), and the steppers step by one grid position and commit at once. The grid starts at `min` (when it suits integer-only) or 0. From an empty field or an emptied draft, up goes to `min` or 0 and down to `max` or 0. Results round to the finer of the step's and the grid start's precision (at most 12 decimal places), clamp to the range, and never leave integer-only; a stepper that cannot move does nothing, while an arrow or wheel step that cannot move still ends the draft, committing a valid draft's number and dropping an unparsable one. A caller `onKeyDown` that prevents an arrow cancels that step. A wheel step stops the page from scrolling; otherwise the page scrolls. | Current tests | -| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control. They are outside the tab order, are named with the localized increment and decrement phrases and the label, keep focus on the input when pressed, and expose `aria-disabled` when stepping that way cannot move or the field is disabled or read-only. | `spec:AST-085` FR1, FR2; current tests | -| FR6 | With `formatValue`, the input shows the formatted committed value at rest and the plain locale-formatted number while focused. The form value is then submitted from a hidden input carrying the raw number, and the visible input has no `name`; without `formatValue` the visible input carries `name`. | Current tests | -| FR7 | `units` renders as secondary text after the input and is part of the input's description. | Current tests | -| FR8 | With `hasClear`, a clear button shows while `value` is not `null` and the field is editable. Activating it calls `onChange(null)`, drops the draft, and focuses the input as TextInput's clear does. The clear button is a ghost small `component:Button`, so its press scale MUST NOT apply under reduced motion (`design:reduced-motion` DR1). Implementation gap: it still scales under reduced motion. | `family:input-fields` FR11; current tests | -| FR9 | NumberInput is disabled when its own `isDisabled` or an enclosing InputGroup's `isDisabled` is true; an enabled group never re-enables a field that disables itself (`spec:AST-085` FR7). Disabled: `aria-disabled="true"` and `readOnly` on the input, a kept tab stop, no submitted value, no draft commit (a draft in progress is dropped), no stepping by any route, no caller handlers other than focus and blur, Enter cancelled, the clear button hidden, the control dimmed, and a keyboard focus ring. With `disabledMessage`, a reason tooltip opens on hover and keyboard focus and describes the input. | `spec:AST-085` FR1–FR3, FR6; `family:input-fields` FR4; current tests | -| FR10 | Read-only: `readOnly`, full opacity, still submitted, no edits, and no stepping by keyboard, wheel, or steppers; the wheel then scrolls the page. Disabled wins when both are set. | Current tests | -| FR11 | Arrow and Enter keys that belong to an IME composition neither step nor commit. | Current tests | -| FR12 | Field composition, InputGroup adaptation, status presentation, start icon, click-to-focus, size and iOS text floor, and the prop targets match `component:TextInput` FR1, FR9, FR11–FR13, including its implementation gaps (the fixed height against `spec:AST-102` FR4, an error status without text, the color-only `detached` status, and the grouped description), plus a `labelIcon` passed to Field. | `family:input-fields` FR3, FR8–FR10; current tests | -| FR13 | The control box carries `astryx-number-input` with `data-size`, `data-status`, `data-disabled`, and `data-readonly`. A theme `padding` in any spelling on `number-input` pads the control box and the stepper column together. | `architecture:component-theming-surface`; current tests | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| FR1 | The input is a native text input with `role="spinbutton"`, `inputMode` `numeric` when integer-only and `decimal` otherwise, `aria-valuemin` and `aria-valuemax` from the range, `aria-valuenow` from the committed `value`, and `aria-valuetext` set to the formatted value when `formatValue` is supplied. | Current tests | +| FR2 | Typing changes only a draft; `onChange` is not called while typing. A draft is valid when it parses for the active locale (grouping, decimal separator, digit script, and sign) and, for validity display, lies within the range and meets integer-only. An invalid draft colors the text secondary, sets `aria-invalid`, and fills a persistent assertive region with the localized invalid-number message. | `spec:AST-086` FR3; `architecture:internationalization`; current tests | +| FR3 | On blur the draft resolves once and ends: an empty draft clears (`onChange(null)` when clearable and a value exists) or reverts; an unparsable or wrong-kind draft reverts; otherwise the number clamps to the range and commits with `onChange(number)` when it differs from `value`. Enter resolves a valid draft the same way and clears an empty draft when clearable. Enter commits nothing for an empty draft without `hasClear`, which stays in the field and is not marked invalid, or for an unparsable or wrong-kind draft, which stays in the field and stays invalid. Enter ends the draft only when its commit clamped. In a field that is not disabled, every Enter outside composition then calls `onEnter`, whatever it committed. | Current tests | +| FR4 | ArrowUp and ArrowDown without modifiers, the wheel over the focused input (when `isWheelEnabled`), and a stepper press step by one grid position and commit at once. A held stepper repeats once after 0.5 s, then on a ramp whose interval starts at 250 ms and shortens to 0.9 of the last, down to 50 ms; the input shows each step, and the press commits once, at its release. Moving off the stepper pauses the repeat and moving back resumes it with no immediate step; sliding onto the other stepper carries it on; a held press selects no text and opens no callout (`spec:AST-094` FR41). The grid starts at `min` (when it suits integer-only) or 0. From an empty field or an emptied draft, up goes to `min` or 0 and down to `max` or 0. Results round to the finer of the step's and the grid start's precision (at most 12 decimal places), clamp to the range, and never leave integer-only; a stepper that cannot move does nothing, while an arrow or wheel step that cannot move still ends the draft, committing a valid draft's number and dropping an unparsable one. A caller `onKeyDown` that prevents an arrow cancels that step. A wheel step stops the page from scrolling; otherwise the page scrolls. | UIStepper's 0.5 s delay and 50 ms floor, measured on the iOS 26.2 simulator; `spec:AST-094` FR41; current tests | +| FR5 | With `hasNumberSteppers`, increment and decrement buttons render at the end of the control, stacked. Where the primary pointer is coarse they render beside the control instead, as one capsule of two side-by-side halves at the field's element height, − at the inline start and + at the inline end, unless the field sits in an InputGroup or its box is narrower than 170 px. They are outside the tab order, are named with the localized increment and decrement phrases and the label, never take focus themselves, and expose `aria-disabled` when stepping that way cannot move or the field is disabled or read-only. A mouse press, and a pen or pointerless press where the primary pointer is fine, moves focus to the input; a touch press, and a pen or pointerless press where the primary pointer is coarse, leaves focus where it is, so an input being edited stays focused and a tap raises no on-screen keyboard. | `spec:AST-085` FR1, FR2; UIKit's UIStepper never becomes first responder; current tests | +| FR6 | With `formatValue`, the input shows the formatted committed value at rest and the plain locale-formatted number while focused. The form value is then submitted from a hidden input carrying the raw number, and the visible input has no `name`; without `formatValue` the visible input carries `name`. | Current tests | +| FR7 | `units` renders as secondary text after the input and is part of the input's description. | Current tests | +| FR8 | With `hasClear`, a clear button shows while `value` is not `null` and the field is editable. Activating it calls `onChange(null)`, drops the draft, and focuses the input as TextInput's clear does. The clear button is a ghost small `component:Button`, so its press scale MUST NOT apply under reduced motion (`design:reduced-motion` DR1). Implementation gap: it still scales under reduced motion. | `family:input-fields` FR11; current tests | +| FR9 | NumberInput is disabled when its own `isDisabled` or an enclosing InputGroup's `isDisabled` is true; an enabled group never re-enables a field that disables itself (`spec:AST-085` FR7). Disabled: `aria-disabled="true"` and `readOnly` on the input, a kept tab stop, no submitted value, no draft commit (a draft in progress is dropped), no stepping by any route, no caller handlers other than focus and blur, Enter cancelled, the clear button hidden, the control dimmed, and a keyboard focus ring. With `disabledMessage`, a reason tooltip opens on hover and keyboard focus and describes the input. | `spec:AST-085` FR1–FR3, FR6; `family:input-fields` FR4; current tests | +| FR10 | Read-only: `readOnly`, full opacity, still submitted, no edits, and no stepping by keyboard, wheel, or steppers; the wheel then scrolls the page. Disabled wins when both are set. | Current tests | +| FR11 | Arrow and Enter keys that belong to an IME composition neither step nor commit. | Current tests | +| FR12 | Field composition, InputGroup adaptation, status presentation, start icon, click-to-focus, size and iOS text floor, and the prop targets match `component:TextInput` FR1, FR9, FR11–FR13, including its implementation gaps (the fixed height against `spec:AST-102` FR4, an error status without text, the color-only `detached` status, and the grouped description), plus a `labelIcon` passed to Field. | `family:input-fields` FR3, FR8–FR10; current tests | +| FR13 | The control box carries `astryx-number-input` with `data-size`, `data-status`, `data-disabled`, and `data-readonly`. A theme `padding` in any spelling on `number-input` pads the control box and the stepper column together. | `architecture:component-theming-surface`; current tests | ### Allowed variation @@ -176,11 +182,11 @@ Consumer migration instructions belong in consumer docs and release notes. ## Design relationships -| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | -| ---------------- | --------------------------------------------------------- | ------------------------ | -------------- | ------------------ | -| Input | Numeric text; secondary color while the draft is invalid. | Current source and tests | Prominent | FR2 | -| Units | Secondary text after the number. | Current source and tests | Supporting | FR7 | -| Steppers | Stacked increment and decrement at the end. | Current source and tests | Supporting | FR5, FR13 | +| Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -------------- | ------------------ | +| Input | Numeric text; secondary color while the draft is invalid. | Current source and tests | Prominent | FR2 | +| Units | Secondary text after the number. | Current source and tests | Supporting | FR7 | +| Steppers | Stacked increment and decrement at the end; where the primary pointer is coarse, a capsule of side-by-side halves beside the field. | Current source and tests | Supporting | FR5, FR13 | `design:reduced-motion` governs the clear button's press scale (FR8); no other design record governs NumberInput. The control box carries `number-input`; @@ -201,19 +207,19 @@ anatomy-theming map. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, AR1 | `NumberInput.test.tsx` spinbutton-attribute suite; `NumberInput.a11y.chromium.spec.ts` | Bounded, unbounded, formatted | A missing role or ARIA value fails. | `audit:NumberInput/accessibility` | -| FR2, AR2 | `numberParser.test.ts`; `numberParser.docblock.test.ts`; `NumberInput.test.tsx` invalid-feedback suite | Locales, grouping, invalid text | A misparse, a commit of a guess, or a missing announcement fails. | `audit:NumberInput/behavior` | -| FR3, ORD1 | `numberInputCommit.test.ts`; `NumberInput.test.tsx` validation, commit-policy, clamping, keyboard-clearing suites | Blur, Enter, empty, clamp, inverted range | A per-keystroke `onChange`, an unclamped commit, or a wrong clear fails. | `audit:NumberInput/behavior` | -| FR4, FR5, ORD2, AR4, PR1 | `NumberInput.test.tsx` stepping and steppers suites; wheel-listener cases | Keys, wheel, steppers; bounds; empty; precision | A step off the grid, past a bound, or with modifiers fails. Evidence gap: no test steps from an emptied draft over a value. | `audit:NumberInput/behavior` | -| FR6, FR7 | `NumberInput.test.tsx` formatted-display, form, and units suites | Rest and focus; form submission | A formatted value submitted, or units not described, fails. | `audit:NumberInput/behavior` | -| FR8 | `NumberInput.test.tsx` hasClear suite | Clear; disabled; read-only | A clear on a disabled field, or no `null`, fails. Evidence gap: no test presses the clear button under reduced motion. | `audit:NumberInput/behavior` | -| FR9, FR10, ORD3, AR3 | `NumberInput.test.tsx` disabled, disabledMessage, read-only, and form suites; `InputGroup.test.tsx` disabled-propagation cases | Disabled, reason, read-only | A lost tab stop, a submitted disabled value, or stepping while read-only fails. | `audit:NumberInput/accessibility` | -| FR12 | `NumberInput.fr10.test.tsx`; `NumberInput.test.tsx` status and click-to-focus suites | Standalone and grouped | A base prop off the input, or styling on the wrong part, fails. | `audit:NumberInput/api` | -| FR13 | `NumberInput.test.tsx` theme-state and stepper-padding suites | Each padding spelling | Steppers that ignore the theme padding fail. | `audit:NumberInput/theming` | -| AR5 | `NumberInput.a11y.chromium.spec.ts` with its recorded known failure | Read-only | The known failure stops matching when the gap closes. | `audit:NumberInput/accessibility` | -| FR11 | `NumberInput.test.tsx` "does not step or commit on a composing keydown (IME)" and "waits for IME composition to finish before committing" | Composing arrows and Enter | A step or commit during composition fails. | `audit:NumberInput/behavior` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1, AR1 | `NumberInput.test.tsx` spinbutton-attribute suite; `NumberInput.a11y.chromium.spec.ts` | Bounded, unbounded, formatted | A missing role or ARIA value fails. | `audit:NumberInput/accessibility` | +| FR2, AR2 | `numberParser.test.ts`; `numberParser.docblock.test.ts`; `NumberInput.test.tsx` invalid-feedback suite | Locales, grouping, invalid text | A misparse, a commit of a guess, or a missing announcement fails. | `audit:NumberInput/behavior` | +| FR3, ORD1 | `numberInputCommit.test.ts`; `NumberInput.test.tsx` validation, commit-policy, clamping, keyboard-clearing suites | Blur, Enter, empty, clamp, inverted range | A per-keystroke `onChange`, an unclamped commit, or a wrong clear fails. | `audit:NumberInput/behavior` | +| FR4, FR5, ORD2, AR4, PR1 | `NumberInput.test.tsx` stepping and steppers suites; wheel-listener cases; `NumberInput.stepper-focus.a11y.chromium.spec.ts` (touch and mouse presses in a browser); `stepperTracking.test.ts`; `NumberInput.stepperTouch.test.tsx`; `NumberInputStepperHold.interaction.browser.spec.ts`; `NumberInput.stepperLook.test.tsx`; `NumberInputStepperLook.interaction.browser.spec.ts` | Keys, wheel, steppers; bounds; empty; precision; mouse, touch, pen, and pointerless presses under fine and coarse pointers; held steppers; coarse pointer, right to left, each size, narrow, InputGroup | A step off the grid, past a bound, or with modifiers fails, as does a press whose focus does not follow its kind and the primary pointer, as does a held stepper that commits before its release or steps while the pointer is off it, as does a capsule in an InputGroup or a narrow field, or one that does not mirror. Evidence gap: no test steps from an emptied draft over a value. | `audit:NumberInput/behavior` | +| FR6, FR7 | `NumberInput.test.tsx` formatted-display, form, and units suites | Rest and focus; form submission | A formatted value submitted, or units not described, fails. | `audit:NumberInput/behavior` | +| FR8 | `NumberInput.test.tsx` hasClear suite | Clear; disabled; read-only | A clear on a disabled field, or no `null`, fails. Evidence gap: no test presses the clear button under reduced motion. | `audit:NumberInput/behavior` | +| FR9, FR10, ORD3, AR3 | `NumberInput.test.tsx` disabled, disabledMessage, read-only, and form suites; `InputGroup.test.tsx` disabled-propagation cases | Disabled, reason, read-only | A lost tab stop, a submitted disabled value, or stepping while read-only fails. | `audit:NumberInput/accessibility` | +| FR12 | `NumberInput.fr10.test.tsx`; `NumberInput.test.tsx` status and click-to-focus suites | Standalone and grouped | A base prop off the input, or styling on the wrong part, fails. | `audit:NumberInput/api` | +| FR13 | `NumberInput.test.tsx` theme-state and stepper-padding suites | Each padding spelling | Steppers that ignore the theme padding fail. | `audit:NumberInput/theming` | +| AR5 | `NumberInput.a11y.chromium.spec.ts` with its recorded known failure | Read-only | The known failure stops matching when the gap closes. | `audit:NumberInput/accessibility` | +| FR11 | `NumberInput.test.tsx` "does not step or commit on a composing keydown (IME)" and "waits for IME composition to finish before committing" | Composing arrows and Enter | A step or commit during composition fails. | `audit:NumberInput/behavior` | ## Current-state impact diff --git a/packages/core/src/NumberInput/NumberInput.stepperLook.test.tsx b/packages/core/src/NumberInput/NumberInput.stepperLook.test.tsx new file mode 100644 index 0000000000000..2d279efe9f88a --- /dev/null +++ b/packages/core/src/NumberInput/NumberInput.stepperLook.test.tsx @@ -0,0 +1,492 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file NumberInput.stepperLook.test.tsx + * @input vitest (jsdom), React Testing Library, NumberInput with steppers, + * UIStepper's shape in motion/uikit/stepper.ts, and the raw CSS the StyleX + * runtime injects (recorded before jsdom parses it) + * @output Checks the steppers' touch presentation: under a finger (a coarse + * pointer, iOS and Android alike) they sit beside the field as one 94 px + * wide capsule at the field size's element height (the field gives up that + * width and a gap), − at the inline start and + at the inline end (mirrored + * in a right-to-left page), a divider 4 px from the top and the bottom, + * transparent halves over the theme's neutral fill, UIKit's glyphs in place + * of the chevrons, with no iOS gate; with a mouse, inside an InputGroup and + * in a narrow field the column is unchanged; the width is measured through + * the shared resize observer; and the values NumberInput spells out match + * stepper.ts + * @position Testing; validates NumberInput.tsx's touch presentation styles. + * jsdom's CSS parser drops media blocks, so the declarations are read from + * the rules StyleX hands to `insertRule`; real engines are covered by + * __tests__/NumberInputStepperLook.interaction.browser.spec.ts + */ + +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {render, screen} from '@testing-library/react'; +import {beforeAll, describe, expect, it, vi} from 'vitest'; +import type {NumberInput as NumberInputComponent} from './NumberInput'; +import type {InputGroup as InputGroupComponent} from '../InputGroup'; +import {stepper} from '../motion/uikit/stepper'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; + +/** Every rule StyleX inserted, as the raw text it handed to `insertRule`. */ +const rawRules: string[] = []; +let NumberInput: typeof NumberInputComponent; +let InputGroup: typeof InputGroupComponent; + +beforeAll(async () => { + const insertRule = CSSStyleSheet.prototype.insertRule; + CSSStyleSheet.prototype.insertRule = function ( + this: CSSStyleSheet, + rule: string, + index?: number, + ) { + rawRules.push(rule); + return insertRule.call(this, rule, index); + }; + // StyleX injects when the styles are created: load NumberInput after the + // recorder is in place. + ({NumberInput} = await import('./NumberInput')); + ({InputGroup} = await import('../InputGroup')); +}); + +/** + * The declarations `el`'s own classes make, by property, outside any media + * block (`media` omitted) or inside `@media `; `pseudo` reads a + * pseudo-element's rules. StyleX minifies values: they are compared without + * whitespace. + */ +function declared( + el: Element, + {at = [], pseudo = ''}: {at?: ReadonlyArray; pseudo?: string} = {}, +): Map { + const out = new Map(); + for (const className of Array.from(el.classList)) { + // Inside a media block StyleX repeats the class for specificity. + const selector = new RegExp( + `^\\.${className}(?:\\.${className})*(?::not\\(#\\\\#\\))*${pseudo.replace(/:/g, ':')}\\{([^}]*)\\}$`, + ); + for (const rule of rawRules) { + // The rule's at-rule blocks, outermost first, must be exactly `at`. + const prefix = at.map(prelude => `${prelude}{`).join(''); + if (!rule.startsWith(prefix)) { + continue; + } + const body = rule.slice(prefix.length, rule.length - at.length); + if (body.startsWith('@')) { + continue; + } + const match = selector.exec(body); + if (match == null) { + continue; + } + for (const declaration of match[1].split(';')) { + const colon = declaration.indexOf(':'); + if (colon > 0) { + out.set( + declaration.slice(0, colon).trim(), + declaration.slice(colon + 1).replace(/\s+/g, ''), + ); + } + } + } + } + return out; +} + +function renderSteppers( + inGroup = false, + width?: number, + size?: 'sm' | 'md' | 'lg', +) { + const field = ( + {}} + hasNumberSteppers + width={width} + size={size} + /> + ); + render(inGroup ? {field} : field); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + const decrement = screen.getByRole('button', {name: 'Decrement Quantity'}); + const column = increment.parentElement as HTMLElement; + const wrapper = column.parentElement as HTMLElement; + // The Field box: the nearest ancestor that also holds the label. + let fieldBox: HTMLElement | null = wrapper; + while (fieldBox != null && fieldBox.querySelector('label') == null) { + fieldBox = fieldBox.parentElement; + } + return {increment, decrement, column, wrapper, fieldBox}; +} + +// The touch key as the StyleX runtime writes it: a coarse primary pointer +// (the resolver's TOUCH_QUERY), with no iOS gate around it. +const TOUCH = [`@media ${TOUCH_QUERY}`]; +// The iOS gate, in either nesting, which the control must not use. +const IOS_SUPPORTS = '@supports (-webkit-touch-callout: none)'; + +describe('NumberInput steppers under a finger, on iOS and Android alike', () => { + it('sit beside the field as one 94 px wide capsule, centred on it', () => { + const {column, wrapper, fieldBox} = renderSteppers(); + const touch = declared(column, {at: TOUCH}); + expect(touch.get('position')).toBe('absolute'); + expect(touch.get('inset-inline-start')).toBe( + 'calc(100%+var(--border-width)+var(--spacing-2))', + ); + // StyleX writes block-axis logical properties as their physical ones. + // Centred at any height: pinned top and bottom, auto margins. + expect(touch.get('top')).toBe('0'); + expect(touch.get('bottom')).toBe('0'); + expect(touch.get('width')).toBe('94px'); + for (const corner of [ + 'border-start-start-radius', + 'border-start-end-radius', + 'border-end-start-radius', + 'border-end-end-radius', + ]) { + // A capsule at any height: the browser clamps it to half the height. + expect(touch.get(corner), corner).toBe('9999px'); + } + expect(touch.get('background-color')).toBe('var(--color-neutral)'); + expect(touch.get('border-inline-start-width')).toBe('0'); + expect(touch.get('margin-top')).toBe('auto'); + expect(touch.get('margin-bottom')).toBe('auto'); + // The Field box gives up the capsule's width and a gap, so the input box + // and an attached status under it end at the same edge; the input box + // takes its own end padding back. + expect( + declared(fieldBox as HTMLElement, {at: TOUCH}).get('padding-inline-end'), + ).toBe('calc(94px+var(--spacing-2))'); + const box = declared(wrapper, {at: TOUCH}); + expect(box.has('margin-inline-end')).toBe(false); + expect(box.get('padding-inline-end')).toContain( + '--astryx-number-input-padding-inline-end', + ); + }); + + it('keep the column in a Field box too narrow for the control', () => { + // A quantity field sized to 96 px (the checkout-wizard template) cannot + // hold the control (94 px and a gap) beside a usable box. + const {column, wrapper, increment, fieldBox} = renderSteppers(false, 96); + // The column keeps its place and shape: under a finger it declares nothing + // of its own (no touch-target growth). + expect(declared(column, {at: TOUCH})).toEqual(new Map()); + expect(declared(wrapper, {at: TOUCH})).toEqual(new Map()); + expect(declared(fieldBox as HTMLElement, {at: TOUCH})).toEqual(new Map()); + expect(declared(increment, {at: TOUCH, pseudo: '::before'})).toEqual( + new Map(), + ); + }); + + it('measure a field through the shared resize observer, not one of its own', () => { + // One ResizeObserver per document (utils/sharedResizeObserver.ts), however + // many stepper fields measure their width under a finger. + let constructed = 0; + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query === TOUCH_QUERY, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + ); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor() { + constructed += 1; + } + observe() {} + unobserve() {} + disconnect() {} + }, + ); + try { + render( + <> + {}} + hasNumberSteppers + width="50%" + /> + {}} + hasNumberSteppers + width="50%" + /> + , + ); + expect(constructed).toBeLessThanOrEqual(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each([ + ['sm', '--size-element-sm'], + ['md', '--size-element-md'], + ['lg', '--size-element-lg'], + ] as const)( + "take the %s size's element height (the component-size rules)", + (size, token) => { + const {column} = renderSteppers(false, undefined, size); + expect(declared(column, {at: TOUCH}).get('height')).toBe(`var(${token})`); + }, + ); + + it("round each half's outer corners at its inline side, with no right-to-left override", () => { + const {increment, decrement} = renderSteppers(); + const plus = declared(increment, {at: TOUCH}); + const minus = declared(decrement, {at: TOUCH}); + // + is at the inline end, − at the inline start, in either direction. + expect(plus.get('border-start-end-radius')).toBe('9999px'); + expect(plus.get('border-end-end-radius')).toBe('9999px'); + expect(plus.get('border-start-start-radius')).toBe('0'); + expect(minus.get('border-start-start-radius')).toBe('9999px'); + expect(minus.get('border-end-start-radius')).toBe('9999px'); + expect(minus.get('border-end-end-radius')).toBe('0'); + expect( + rawRules.some( + rule => rule.includes('[dir="rtl"]') && rule.includes('radius'), + ), + ).toBe(false); + }); + + it("keep the glyphs, the divider and the capsule's edge under forced colors", () => { + // Forced colors repaint backgrounds as Canvas: the glyphs and the divider, + // drawn as backgrounds, opt out and take system colours, and the capsule + // keeps an outline for its edge. + const FORCED = ['@media (forced-colors: active)']; + const {column, increment} = renderSteppers(); + const divider = declared(column, {at: FORCED, pseudo: '::after'}); + expect(divider.get('background-color')).toBe('CanvasText'); + expect(divider.get('forced-color-adjust')).toBe('none'); + const glyph = declared(increment, {at: FORCED, pseudo: '::before'}); + expect(glyph.get('background-color')).toBe('CanvasText'); + expect(glyph.get('forced-color-adjust')).toBe('none'); + const edge = declared(column, {at: [...TOUCH, ...FORCED]}); + expect(edge.get('outline-style')).toBe('solid'); + expect(edge.get('outline-color')).toBe('CanvasText'); + }); + + it('draw a half at its bound in GrayText under forced colors', () => { + render( + {}} + hasNumberSteppers + />, + ); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + const glyph = declared(increment, { + at: ['@media (forced-colors: active)'], + pseudo: '::before', + }); + expect(glyph.get('background-color')).toBe('GrayText'); + }); + + it('show the control in a Field box wide enough for it', () => { + const {column} = renderSteppers(false, 200); + expect(declared(column, {at: TOUCH}).get('position')).toBe('absolute'); + }); + + it('put − at the inline start and + at the inline end, mirrored in a right-to-left page (design:bidirectionality DR3)', () => { + const {column} = renderSteppers(); + expect(declared(column, {at: TOUCH}).get('flex-direction')).toBe( + 'row-reverse', + ); + // No right-to-left override: the reversed row follows the direction. + const rtl = rawRules.find( + rule => + rule.startsWith(TOUCH.map(prelude => `${prelude}{`).join('')) && + rule.includes('[dir="rtl"]') && + rule.includes('flex-direction'), + ); + expect(rtl).toBeUndefined(); + // The increment button is first in the DOM, so a reversed row puts it + // at the inline end. + expect(column.firstElementChild?.getAttribute('aria-label')).toBe( + 'Increment Quantity', + ); + }); + + it('draw a 1 px divider 4px from the top and the bottom, at every height', () => { + const {column} = renderSteppers(); + const divider = declared(column, {at: TOUCH, pseudo: '::after'}); + expect(divider.get('content')).toBe('""'); + const shape = declared(column, {pseudo: '::after'}); + expect(shape.get('width')).toBe('1px'); + expect(shape.has('height')).toBe(false); + expect(shape.get('top')).toBe('4px'); + expect(shape.get('bottom')).toBe('4px'); + expect(shape.get('inset-inline-start')).toBe('calc(50%-.5px)'); + expect(shape.get('background-color')).toBe('var(--color-track)'); + }); + + it('paint each half transparent over the capsule, glyphs in the primary icon colour', () => { + const {increment, decrement} = renderSteppers(); + for (const half of [increment, decrement]) { + const touch = declared(half, {at: TOUCH}); + expect(touch.get('background-color')).toBe('transparent'); + expect(touch.get('color')).toBe('var(--color-icon-primary)'); + } + expect(declared(decrement, {at: TOUCH}).get('border-top-width')).toBe('0'); + }); + + it('draw UIKit’s − and + glyphs in place of the chevrons, as decoration', () => { + // Each half's glyph is its own pseudo-element (the half's label names it): + // UIKit's 13pt vector glyph as a mask over the half's glyph colour. + const {increment, decrement} = renderSteppers(); + const chevron = increment.children[0]; + expect(declared(chevron, {at: TOUCH}).get('display')).toBe('none'); + // Elsewhere the icon keeps its own display. + expect(declared(chevron).get('display')).toBe('inline-flex'); + // One element per half: the icon; no second glyph element. + expect(increment.children).toHaveLength(1); + expect(decrement.children).toHaveLength(1); + for (const half of [increment, decrement]) { + expect( + declared(half, {at: TOUCH, pseudo: '::before'}).get('content'), + ).toBe('""'); + const glyph = declared(half, {pseudo: '::before'}); + expect(glyph.get('content')).toBe('none'); + expect(glyph.get('width')).toBe('13px'); + expect(glyph.get('height')).toBe('13px'); + expect(declared(half, {pseudo: '::after'})).toEqual(new Map()); + } + // The paths, as the reader keeps them (whitespace dropped): − is one + // stroke, + adds the upright. + expect( + declared(decrement, {pseudo: '::before'}).get('mask-image'), + ).toContain("d='M06.5H13'"); + expect( + declared(increment, {pseudo: '::before'}).get('mask-image'), + ).toContain("d='M06.5H13M6.50V13'"); + }); + + it('draw a disabled half’s glyph in the divider’s colour', () => { + render( + {}} + hasNumberSteppers + />, + ); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + expect(declared(increment, {at: TOUCH}).get('color')).toBe( + 'var(--color-track)', + ); + }); + + it('key the control on a coarse pointer alone, with no iOS gate', () => { + // One touch model: Android gets the control iOS does. + const {column, wrapper, increment, decrement, fieldBox} = renderSteppers(); + const chevron = increment.children[0]; + expect(declared(column, {at: TOUCH}).get('position')).toBe('absolute'); + for (const el of [column, wrapper, increment, decrement, chevron]) { + expect(declared(el, {at: [IOS_SUPPORTS, ...TOUCH]})).toEqual(new Map()); + expect(declared(el, {at: [...TOUCH, IOS_SUPPORTS]})).toEqual(new Map()); + } + expect( + declared(fieldBox as HTMLElement, {at: [IOS_SUPPORTS, ...TOUCH]}), + ).toEqual(new Map()); + }); + + it('keep the column in an InputGroup on a touch screen too', () => { + const {column, wrapper, increment} = renderSteppers(true); + // In the field: nothing of its own under a finger (no touch-target growth). + expect(declared(column, {at: TOUCH}).has('position')).toBe(false); + expect(declared(wrapper, {at: TOUCH}).has('margin-inline-end')).toBe(false); + expect(declared(increment, {at: TOUCH, pseudo: '::before'})).toEqual( + new Map(), + ); + }); +}); + +describe('NumberInput steppers with a mouse', () => { + it('keep the column inside the field with a mouse', () => { + // A fine pointer: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {column, wrapper, increment, fieldBox} = renderSteppers(); + const mouse = declared(column); + expect(mouse.get('flex-direction')).toBe('column'); + expect(mouse.get('width')).toBe('var(--spacing-4)'); + // Outside the gate every touch style is a value, never null (a null would + // erase the base): CSS initial values where the base sets nothing. + expect(mouse.get('position')).toBe('static'); + expect(mouse.get('background-color')).toBe('transparent'); + expect(declared(wrapper).get('padding-inline-end')).toBe('0'); + expect(declared(wrapper).has('margin-inline-end')).toBe(false); + expect(declared(fieldBox as HTMLElement).get('padding-inline-end')).toBe( + '0', + ); + expect(declared(increment).get('background-color')).toBe( + 'var(--color-background-surface)', + ); + }); + + it('leave each chevron icon its own inline-flex display', () => { + // A fine pointer: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + // The hide's default is the icon's own display (inline-flex), so nothing + // of Icon's base style is erased where the touch key does not match. + const {increment, decrement} = renderSteppers(); + for (const half of [increment, decrement]) { + const icon = half.querySelector('svg')?.parentElement as HTMLElement; + expect(declared(icon).get('display')).toBe('inline-flex'); + } + }); +}); + +describe('the values NumberInput spells out match stepper.ts', () => { + // StyleX inlines only same-file or .stylex.ts values, so NumberInput writes + // UIKit's shape as literals; this keeps them equal to the table. The height + // follows the field's size (the component-size rules); md's is UIKit's. + const source = readFileSync(join(__dirname, 'NumberInput.tsx'), 'utf8'); + const {look} = stepper; + + it.each([ + ['the width', `'${look.widthPt}px'`], + ['the width the field gives up', `calc(${look.widthPt}px + `], + ['the divider width', `'${look.dividerWidthPt}px'`], + ['the divider inset', `'${(look.heightPt - look.dividerHeightPt) / 2}px'`], + ['the glyph size', `'${look.glyphLengthPt}px'`], + [ + 'the glyph box', + `viewBox='0 0 ${look.glyphLengthPt} ${look.glyphLengthPt}'`, + ], + ['the glyph stroke', `stroke-width='${look.glyphStrokePt}'`], + ])('%s', (_, literal) => { + expect(source).toContain(literal); + }); + + it("and its md height is UIKit's: the theme's md element height, a capsule at every height", () => { + const tokens = readFileSync( + join(__dirname, '../theme/tokens.stylex.ts'), + 'utf8', + ); + expect(tokens).toContain(`'--size-element-md': '${look.heightPt}px'`); + // UIKit's radius is half its height; the control keeps that at any size. + expect(look.cornerRadiusPt * 2).toBe(look.heightPt); + expect(source).toContain("'9999px'"); + }); + + it('and its touch key is spelled as TOUCH_QUERY', () => { + // A literal, not the imported key: StyleX gives an imported at-rule key a + // different priority than its literal. + expect(source).toContain(`const TOUCH = '@media ${TOUCH_QUERY}';`); + }); +}); diff --git a/packages/core/src/NumberInput/NumberInput.stepperTouch.test.tsx b/packages/core/src/NumberInput/NumberInput.stepperTouch.test.tsx new file mode 100644 index 0000000000000..d506cd4abeb76 --- /dev/null +++ b/packages/core/src/NumberInput/NumberInput.stepperTouch.test.tsx @@ -0,0 +1,1182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file NumberInput.stepperTouch.test.tsx + * @input vitest + Testing Library, NumberInput with steppers, touch, pen and + * mouse events with timestamps on the page clock, and laid-out stepper rects + * @output The steppers under any pointer: under a finger (a coarse primary + * pointer) through the gesture system, a tap steps once, on the click each + * engine sends after it (Chrome on Android, Safari on iOS), a tap trembling + * on the edge too, and that click steps the half the press lifted on; + * a hold shows each step in the field and commits once, at the release, + * and the parent's value wins after it; the touched half paints at once and + * clears at once; a slide moves the paint and the repeat; edge tremble adds + * nothing; leaving the column pauses the repeat and coming back resumes it; + * a mouse holds like a finger on the column's own pointer events and + * focuses the field, and under a fine pointer nothing touch-only attaches; + * disabled, read-only and + * bound halves do nothing; a press cancelled under a blocked field commits + * nothing; steppers shown after the field mounts, or shown again, track a + * finger; a click no pointer sent always steps; no shared press feedback + * @position jsdom tests of the steppers' touch layer, NumberInputTouch.tsx (the + * rules themselves: stepperTracking.test.ts; real engines: + * NumberInput.stepperHold.interaction.browser.spec.ts) + */ + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {useState} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {NumberInput} from './NumberInput'; +import {pointerQuery} from '../gestures/__tests__/pointerQuery'; +import {CLICK_GUARD_WINDOW_MS} from '../utils/clickGuard'; + +interface Box { + top: number; + bottom: number; + left: number; + right: number; +} + +// The column sits at x 200–224; + is its top half (y 0–16), − the bottom. +const COLUMN: Record = { + column: {top: 0, bottom: 32, left: 200, right: 224}, + increment: {top: 0, bottom: 16, left: 200, right: 224}, + decrement: {top: 16, bottom: 32, left: 200, right: 224}, +}; +// The touch control at md, left to right: a 94 x 32 capsule beside the +// field, − left, + right. +const CAPSULE: Record = { + column: {top: 0, bottom: 32, left: 300, right: 394}, + decrement: {top: 0, bottom: 32, left: 300, right: 347}, + increment: {top: 0, bottom: 32, left: 347, right: 394}, +}; +let RECTS = COLUMN; +const INCREMENT = {x: 212, y: 8}; +const DECREMENT = {x: 212, y: 24}; + +function rectOf(element: Element): DOMRect { + const name = + element.getAttribute('aria-label')?.startsWith('Increment') === true + ? 'increment' + : element.getAttribute('aria-label')?.startsWith('Decrement') === true + ? 'decrement' + : element.querySelector('[aria-label^="Increment"]') != null && + element.querySelector('input') == null + ? 'column' + : null; + const rect = + name == null ? {top: 0, bottom: 0, left: 0, right: 0} : RECTS[name]; + const {left, right} = rect; + return { + x: left, + y: rect.top, + top: rect.top, + bottom: rect.bottom, + left, + right, + width: right - left, + height: rect.bottom - rect.top, + toJSON: () => ({}), + }; +} + +let finger = 1; + +/** + * A finger's touch event, as a browser sends it: a move or a lift comes after + * its pointer event (pointermove, pointerup), from which the gesture host + * reads a finger once it has seen its pointerdown. + */ +function touch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: EventTarget, + at: {x: number; y: number}, + id: number, +): void { + if (type !== 'touchstart') { + const pointer = { + pointerType: 'touch', + pointerId: id, + isPrimary: true, + clientX: at.x, + clientY: at.y, + }; + if (type === 'touchmove') { + fireEvent.pointerMove(target as Element, pointer); + } else { + fireEvent.pointerUp(target as Element, pointer); + } + } + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: id, clientX: at.x, clientY: at.y, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [entry], + }); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + act(() => { + target.dispatchEvent(event); + }); +} + +/** A finger down on `target`, as a browser sends it: pointerdown, then touchstart. */ +function fingerDown(target: Element, at: {x: number; y: number}): number { + const id = finger++; + fireEvent.pointerDown(target, { + pointerType: 'touch', + pointerId: id, + clientX: at.x, + clientY: at.y, + }); + touch('touchstart', target, at, id); + return id; +} + +/** + * A click as Chrome and Firefox dispatch one: a pointer event that names its + * pointer ('' for a click no pointer sent). Chrome on Android sends a tap's + * click as 'touch' with detail 0; Safari on iOS as 'mouse' with detail 1. + */ +function clickNaming(pointerType: string, detail = 0): MouseEvent { + const click = new MouseEvent('click', { + bubbles: true, + cancelable: true, + detail, + }); + Object.defineProperty(click, 'pointerType', {value: pointerType}); + return click; +} + +/** + * Moves the page clock `ms`, in slices shorter than the fastest repeat: act() + * renders only when its callback returns, and a browser renders between two + * timer tasks, so each step must see the value the one before it produced. + */ +function wait(ms: number): void { + for (let left = ms; left > 0; left -= 10) { + act(() => { + vi.advanceTimersByTime(Math.min(10, left)); + }); + } +} + +function Controlled({ + initial = 5, + onChange, + ...props +}: { + initial?: number; + onChange?: (value: number | null) => void; + max?: number; + isDisabled?: boolean; + isReadOnly?: boolean; + hasNumberSteppers?: boolean; + /** Whether the parent takes the value onChange hands it. */ + isAccepting?: boolean; +}) { + const {isAccepting = true, ...rest} = props; + const [value, setValue] = useState(initial); + return ( + { + onChange?.(next); + if (isAccepting) { + setValue(next); + } + }} + hasNumberSteppers + {...rest} + /> + ); +} + +function setup(props: Parameters[0] = {}) { + const onChange = vi.fn<(value: number | null) => void>(); + render(); + return { + onChange, + input: screen.getByRole('spinbutton'), + increment: screen.getByRole('button', {name: 'Increment Quantity'}), + decrement: screen.getByRole('button', {name: 'Decrement Quantity'}), + }; +} + +const press = (element: Element) => element.getAttribute('data-astryx-press'); +/** Whether a text selection starting on `target` is refused. */ +function refusesSelection(target: Element): boolean { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + target.dispatchEvent(event); + return event.defaultPrevented; +} +const shown = (input: HTMLElement) => (input as HTMLInputElement).value; +const committed = (onChange: {mock: {calls: [number | null][]}}) => + onChange.mock.calls.map(([value]) => value); +// Above the column, past the control profile's 70 pt tracking area. +const FAR_ABOVE = {x: 212, y: -80}; + +// The document's gesture host outlives a test and keeps event times +// monotonic, while each fake clock starts at 0: every test starts later than +// the one before it ended. +let epoch = 0; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'], + }); + epoch += 100_000; + vi.advanceTimersByTime(epoch); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function (this: Element) { + return rectOf(this); + }, + ); +}); + +afterEach(() => { + RECTS = COLUMN; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +/** + * The primary pointer is coarse (a phone, a tablet), as the press tracker + * needs: jsdom's matchMedia models a desktop, where touch work stays off + * (`spec:AST-094` FR19). Restored with the other mocks after each test. + */ +function coarsePrimaryPointer(): void { + const matchMedia = window.matchMedia.bind(window); + vi.spyOn(window, 'matchMedia').mockImplementation( + (query: string): MediaQueryList => ({ + media: query, + matches: query.includes('(pointer: coarse)') || matchMedia(query).matches, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }), + ); +} + +describe('NumberInput steppers under a finger', () => { + beforeEach(() => { + coarsePrimaryPointer(); + }); + + it('a tap steps nothing at the lift and once on the click the browser sends after it', () => { + // A control activates by the browser's click on a lift inside + // (`spec:AST-094` FR11). + const {onChange, input, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(60); + expect(onChange).not.toHaveBeenCalled(); + touch('touchend', increment, INCREMENT, id); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(increment, {detail: 1}); + expect(committed(onChange)).toEqual([6]); + expect(input).not.toHaveFocus(); + }); + + it('the click Safari on iOS sends after a tap (named mouse, detail 1) is its one step, with no focus', () => { + const {onChange, input, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + touch('touchend', increment, INCREMENT, id); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + expect(input).not.toHaveFocus(); + }); + + it('the click Chrome on Android sends after a tap (named touch, detail 0) is its one step', () => { + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + touch('touchend', increment, INCREMENT, id); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + increment.dispatchEvent(clickNaming('touch')); + }); + expect(committed(onChange)).toEqual([6]); + }); + + it('a light tap trembling on the top edge steps exactly once', () => { + const {onChange, increment} = setup(); + const id = fingerDown(increment, {x: 212, y: 1}); + for (const y of [-2, 1, -3, 0, -2]) { + touch('touchmove', increment, {x: 212, y}, id); + } + wait(70); + touch('touchend', increment, {x: 212, y: -2}, id); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + }); + + it('a hold shows each step in the field and commits once, at the release', () => { + const {onChange, input, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(499); + expect(shown(input)).toBe('5'); + wait(1); + expect(shown(input)).toBe('6'); + // 250 ms, then 225 ms, then 202.5 ms: one steady ramp. + wait(250); + expect(shown(input)).toBe('7'); + wait(225); + expect(shown(input)).toBe('8'); + wait(205); + expect(shown(input)).toBe('9'); + // Nothing is committed, or told to assistive technology, mid-hold. + expect(onChange).not.toHaveBeenCalled(); + expect(input).toHaveAttribute('aria-valuenow', '5'); + touch('touchend', increment, INCREMENT, id); + fireEvent.click(increment, {detail: 1}); + expect(committed(onChange)).toEqual([9]); + expect(shown(input)).toBe('9'); + expect(input).toHaveAttribute('aria-valuenow', '9'); + }); + + it('after the release the field shows the value the parent passes back', () => { + const {onChange, input, increment} = setup({isAccepting: false}); + const id = fingerDown(increment, INCREMENT); + wait(800); + expect(shown(input)).toBe('7'); + touch('touchend', increment, INCREMENT, id); + // The parent rejected 7: its 5 wins. + expect(committed(onChange)).toEqual([7]); + expect(shown(input)).toBe('5'); + }); + + it('the column takes touch-action manipulation from the gesture host for its kept press (spec:AST-094 FR18)', async () => { + // The document's host outlives a test: a frame read an earlier test + // scheduled runs first, on a real frame. + vi.useRealTimers(); + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + // The host reads an author's touch-action in the next frame and writes + // its own then (FR18): fake the frame as well. + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'Date', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + vi.advanceTimersByTime(epoch); + // jsdom drops touch-action, so the host's writes are observed. + const writes: {style: CSSStyleDeclaration; value: string | null}[] = []; + const setProperty = CSSStyleDeclaration.prototype.setProperty; + vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation( + function ( + this: CSSStyleDeclaration, + name: string, + value: string | null, + priority?: string, + ) { + if (name === 'touch-action') { + writes.push({style: this, value}); + } + setProperty.call(this, name, value, priority); + }, + ); + const {increment} = setup(); + wait(16); + const column = increment.parentElement as HTMLElement; + // The host may write it more than once (at attach and as it re-applies); + // every write is manipulation. + const columnWrites = writes + .filter(({style}) => style === column.style) + .map(w => w.value); + expect(columnWrites).toContain('manipulation'); + expect(new Set(columnWrites)).toEqual(new Set(['manipulation'])); + }); + + it('a held finger refuses text selection on the column for that press only, with no style write (spec:AST-094 FR41)', () => { + const {increment} = setup(); + const column = increment.parentElement as HTMLElement; + expect(refusesSelection(column)).toBe(false); + const id = fingerDown(increment, INCREMENT); + expect(refusesSelection(column)).toBe(true); + wait(600); + expect(refusesSelection(column)).toBe(true); + // Away from iOS WebKit the lock cancels `selectstart`: nothing repaints. + expect(column.style.getPropertyValue('user-select')).toBe(''); + touch('touchend', increment, INCREMENT, id); + // Let go at the lift: the column's text is selectable again. + expect(refusesSelection(column)).toBe(false); + }); + + it('where iOS WebKit runs, a held finger locks selection on the column with a style, for that press only (spec:AST-094 FR41)', () => { + // iOS WebKit fires no `selectstart` for a long press: only the style + // refuses it there. + const css = globalThis.CSS; + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: { + ...css, + supports: (query: string) => query === '(-webkit-touch-callout: none)', + }, + }); + try { + const {increment} = setup(); + const column = increment.parentElement as HTMLElement; + expect(column.style.getPropertyValue('user-select')).toBe(''); + const id = fingerDown(increment, INCREMENT); + expect(column.style.getPropertyValue('user-select')).toBe('none'); + wait(600); + expect(column.style.getPropertyValue('user-select')).toBe('none'); + touch('touchend', increment, INCREMENT, id); + expect(column.style.getPropertyValue('user-select')).toBe(''); + } finally { + Object.defineProperty(globalThis, 'CSS', { + configurable: true, + value: css, + }); + } + }); + + it('paints the touched half at once and clears it at once', () => { + const {increment, decrement} = setup(); + const id = fingerDown(increment, INCREMENT); + expect(press(increment)).toBe('on'); + expect(press(decrement)).toBeNull(); + touch('touchend', increment, INCREMENT, id); + // No release fade: UIStepper's fill returns in the first frame. + expect(press(increment)).toBeNull(); + }); + + it('a slide onto the other half moves the paint and the repeat', () => { + const {onChange, input, increment, decrement} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(800); + expect(shown(input)).toBe('7'); + touch('touchmove', increment, DECREMENT, id); + expect(press(increment)).toBeNull(); + expect(press(decrement)).toBe('on'); + // The cadence runs on: the next beat, at 975 ms, steps −. + wait(175); + expect(shown(input)).toBe('6'); + touch('touchend', increment, DECREMENT, id); + expect(press(decrement)).toBeNull(); + expect(committed(onChange)).toEqual([6]); + }); + + it('a finger that slid from − onto + before any repeat steps + on the click, whichever half it reaches', () => { + // The click steps the half the press lifted on, even where the engine + // sends it to the half the touch began on. + const {onChange, decrement} = setup(); + const id = fingerDown(decrement, DECREMENT); + wait(200); + touch('touchmove', decrement, INCREMENT, id); + wait(150); + touch('touchend', decrement, INCREMENT, id); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + decrement.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + }); + + it('a finger trembling over the edge during a hold adds no step', () => { + const steady = setup(); + const a = fingerDown(steady.increment, INCREMENT); + wait(1200); + touch('touchend', steady.increment, INCREMENT, a); + document.body.innerHTML = ''; + + const trembling = setup(); + const b = fingerDown(trembling.increment, INCREMENT); + wait(800); + // Back and forth across the top edge, within the tracking area. + for (const y of [-6, 2, -9, 1, -4, 3, -7, 2]) { + touch('touchmove', trembling.increment, {x: 212, y}, b); + wait(10); + } + expect(press(trembling.increment)).toBe('on'); + wait(320); + touch('touchend', trembling.increment, {x: 212, y: 2}, b); + expect(committed(trembling.onChange)).toEqual(committed(steady.onChange)); + }); + + it('leaving the column pauses the repeat and coming back resumes it, with no step at once', () => { + const {onChange, input, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(800); + expect(shown(input)).toBe('7'); + touch('touchmove', increment, FAR_ABOVE, id); + expect(press(increment)).toBeNull(); + wait(2000); + expect(shown(input)).toBe('7'); + touch('touchmove', increment, INCREMENT, id); + expect(shown(input)).toBe('7'); + expect(press(increment)).toBe('on'); + // The rest of the 975 ms beat (175 ms) runs, then the next step. + wait(170); + expect(shown(input)).toBe('7'); + wait(10); + expect(shown(input)).toBe('8'); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([8]); + }); + + it('a tap on the column itself (its border) steps once and focuses nothing', () => { + // A tap on the column's border is the column's, and its click must not + // reach the field's click-to-focus (no keyboard). + const {onChange, input, increment} = setup(); + const column = increment.parentElement as HTMLElement; + const border = {x: 200, y: 8}; + const id = fingerDown(column, border); + touch('touchend', column, border, id); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + column.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + expect(input).not.toHaveFocus(); + }); + + it('a press on the column itself keeps an editing field focused, as one on a half does', () => { + const {input, increment} = setup(); + act(() => { + input.focus(); + }); + const column = increment.parentElement as HTMLElement; + const notCancelled = fireEvent.pointerDown(column, { + pointerType: 'touch', + pointerId: 9, + clientX: 200, + clientY: 8, + }); + // Default-prevented, so the browser moves no focus off the field. + expect(notCancelled).toBe(false); + expect(input).toHaveFocus(); + }); + + it('a lift outside the column steps nothing', () => { + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(60); + touch('touchmove', increment, FAR_ABOVE, id); + touch('touchend', increment, FAR_ABOVE, id); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('the click an engine sends after a lift outside adds nothing', () => { + // iOS Safari still sends the button its click after a short slide off it. + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(60); + touch('touchmove', increment, FAR_ABOVE, id); + touch('touchend', increment, FAR_ABOVE, id); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a click no pointer sent steps its own half, even right after a tracked touch', () => { + // A screen reader's activation, a key or a programmatic click(): detail + // 0, and where clicks name their pointer, none (''). + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + touch('touchend', increment, INCREMENT, id); + fireEvent.click(increment, {detail: 0}); + expect(committed(onChange)).toEqual([6]); + act(() => { + increment.dispatchEvent(clickNaming('')); + }); + expect(committed(onChange)).toEqual([6, 7]); + }); + + it('a pointer click long after a hold the engine sent no click for is a press of its own', () => { + // Safari on iOS sends no click after a hold; an activation that comes a + // second or more later is not the hold's. + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(600); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([6]); + wait(1500); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6, 7]); + }); + + it("a pointer click past the click guard's window after a hold is a press of its own", () => { + // A press\'s own click follows its lift at once; the gesture system bounds + // that wait with the click guard\'s window (CLICK_GUARD_WINDOW_MS, M-G17 + // in motion/MEASUREMENTS.md), so half a second later a click steps. + const {onChange, increment} = setup(); + const id = fingerDown(increment, INCREMENT); + wait(600); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([6]); + wait(CLICK_GUARD_WINDOW_MS + 100); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6, 7]); + }); + + it('a press delivered under a finger keeps its touch: the page cannot pan it away', () => { + // UIScrollView never cancels a tracking UIControl; the host cancels the + // browser's pan for this finger once the press is delivered (keepsTouch). + const {increment} = setup(); + const id = fingerDown(increment, INCREMENT); + const move = new Event('touchmove', {bubbles: true, cancelable: true}); + const entry = { + identifier: id, + clientX: INCREMENT.x, + clientY: INCREMENT.y + 12, + target: increment, + }; + Object.defineProperty(move, 'changedTouches', {value: [entry]}); + Object.defineProperty(move, 'touches', {value: [entry]}); + Object.defineProperty(move, 'timeStamp', {value: performance.now()}); + act(() => { + increment.dispatchEvent(move); + }); + expect(move.defaultPrevented).toBe(true); + touch('touchend', increment, INCREMENT, id); + }); + + it.each([ + ['disabled', {isDisabled: true}], + ['read-only', {isReadOnly: true}], + ] as const)( + 'a field turned %s mid-hold lets go of the press and commits nothing', + (_, blocked) => { + // PressTracker reads isEnabled only when a touch begins, so the press + // layer itself must let go: no pressed paint beside aria-disabled, no + // repeat timer behind the refused steps, and no commit at the lift. + const onChange = vi.fn<(value: number | null) => void>(); + const {rerender} = render(); + const input = screen.getByRole('spinbutton'); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const id = fingerDown(increment, INCREMENT); + wait(600); + expect(shown(input)).toBe('6'); + expect(press(increment)).toBe('on'); + rerender(); + expect(press(increment)).toBeNull(); + expect(shown(input)).toBe('5'); + wait(1000); + // Turned back on with the finger still down: the press stays let go + // until a new touch, so nothing resumes and nothing repaints. + rerender(); + wait(1000); + expect(shown(input)).toBe('5'); + expect(press(increment)).toBeNull(); + touch('touchend', increment, INCREMENT, id); + expect(onChange).not.toHaveBeenCalled(); + expect(press(increment)).toBeNull(); + }, + ); + + it('steppers turned on after mount follow a finger', () => { + const onChange = vi.fn<(value: number | null) => void>(); + const {rerender} = render( + , + ); + expect( + screen.queryByRole('button', {name: 'Increment Quantity'}), + ).toBeNull(); + rerender(); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + const id = fingerDown(increment, INCREMENT); + wait(600); + expect(shown(screen.getByRole('spinbutton'))).toBe('6'); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([6]); + }); + + it('steppers removed and shown again follow a finger, once', () => { + const onChange = vi.fn<(value: number | null) => void>(); + const {rerender} = render(); + rerender(); + rerender(); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + const id = fingerDown(increment, INCREMENT); + wait(800); + // 500 and 750 ms: one tracker, not one per column the field ever had. + expect(shown(screen.getByRole('spinbutton'))).toBe('7'); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([7]); + }); + + it('a hold stops at the bound, and the bound shows on the half as it is reached', () => { + const {onChange, input, increment} = setup({initial: 8, max: 10}); + const id = fingerDown(increment, INCREMENT); + wait(2600); + expect(shown(input)).toBe('10'); + expect(increment).toHaveAttribute('aria-disabled', 'true'); + touch('touchend', increment, INCREMENT, id); + expect(committed(onChange)).toEqual([10]); + }); + + it('does nothing while disabled or read-only', () => { + for (const props of [{isDisabled: true}, {isReadOnly: true}]) { + const {onChange, increment} = setup(props); + const id = fingerDown(increment, INCREMENT); + expect(press(increment)).toBeNull(); + wait(1200); + touch('touchend', increment, INCREMENT, id); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + document.body.innerHTML = ''; + } + }); +}); + +describe('NumberInput steppers under a pen and a mouse', () => { + it('a pen tap steps once, on its click, and focuses the field where the primary pointer is fine', () => { + // A fine pointer: this test's context has no coarse pointer. No + // on-screen keyboard comes up, so a pen focuses the field as a mouse + // does (`component:NumberInput` FR5). + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {onChange, input, increment} = setup(); + const pointer = {pointerType: 'pen', pointerId: 40, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + expect(press(increment)).toBe('on'); + wait(80); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + expect(onChange).not.toHaveBeenCalled(); + expect(press(increment)).toBeNull(); + fireEvent.click(increment, {detail: 1}); + expect(committed(onChange)).toEqual([6]); + expect(input).toHaveFocus(); + }); + + it('a pen press on the column itself (its border) steps once and focuses the field where the primary pointer is fine', () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {onChange, input, increment} = setup(); + const column = increment.parentElement as HTMLElement; + const pointer = {pointerType: 'pen', pointerId: 41, isPrimary: true}; + fireEvent.pointerDown(column, {...pointer, clientX: 200, clientY: 8}); + wait(80); + fireEvent.pointerUp(column, {...pointer, clientX: 200, clientY: 8}); + act(() => { + column.dispatchEvent(clickNaming('pen', 1)); + }); + expect(committed(onChange)).toEqual([6]); + expect(input).toHaveFocus(); + }); + + it('a mouse click steps once and focuses the field', () => { + // A fine pointer: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {onChange, input, increment} = setup(); + const pointer = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + wait(80); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + expect(input).toHaveFocus(); + }); + + it('a mouse held on a stepper repeats like a finger and commits once, at the release', () => { + // A fine pointer: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {onChange, input, increment} = setup(); + const pointer = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + expect(press(increment)).toBe('on'); + wait(1200); + expect(shown(input)).toBe('9'); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([9]); + expect(press(increment)).toBeNull(); + expect(input).toHaveFocus(); + }); +}); + +describe('NumberInput steppers under a mouse where the primary pointer is coarse', () => { + beforeEach(() => { + coarsePrimaryPointer(); + }); + + // A pen, too, takes the press tracker here, and focusing the field would + // bring up the on-screen keyboard (`component:NumberInput` FR5). + it('a pen tap steps once, on its click, without focusing the field', () => { + const {onChange, input, increment} = setup(); + const pointer = {pointerType: 'pen', pointerId: 42, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + act(() => { + increment.dispatchEvent(clickNaming('pen', 1)); + }); + expect(committed(onChange)).toEqual([6]); + expect(input).not.toHaveFocus(); + }); + + // A touch device with a mouse or trackpad: the gesture host serves every + // pointer there, so the press tracker is a mouse press's one feeder. + it('a mouse click steps once', () => { + const {onChange, increment} = setup(); + const pointer = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + }); + + it('a held mouse repeats like a finger and commits once, at the release', () => { + const {onChange, input, increment} = setup(); + const pointer = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + wait(1200); + expect(shown(input)).toBe('9'); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([9]); + }); +}); + +describe('NumberInput steppers losing a held press', () => { + // A press whose owner goes away mid-hold (the steppers unmounting) lets go + // at once, as a disabled field does. The primary pointer changing kind + // detaches nothing: one press tracker serves every pointer (`spec:AST-094` + // FR43), and the press is the gesture host's to keep or cancel. + it('a hold lets go when the steppers go away: no further step, no commit', () => { + coarsePrimaryPointer(); + const onChange = vi.fn<(value: number | null) => void>(); + const {rerender} = render(); + const input = screen.getByRole('spinbutton'); + const increment = screen.getByRole('button', {name: 'Increment Quantity'}); + fingerDown(increment, INCREMENT); + wait(700); + expect(shown(input)).toBe('6'); + rerender(); + wait(2000); + expect(shown(input)).toBe('5'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a finger hold the gesture host cancels as the primary pointer turns fine commits nothing', () => { + const pointer = pointerQuery(true); + try { + const {onChange, input, increment} = setup(); + fingerDown(increment, INCREMENT); + wait(700); + expect(shown(input)).toBe('6'); + act(() => { + pointer.set(false); + }); + wait(2000); + expect(shown(input)).toBe('5'); + expect(onChange).not.toHaveBeenCalled(); + } finally { + pointer.restore(); + } + }); + + it('a mouse hold carries on as the primary pointer turns coarse, and commits once at its release (spec:AST-094 FR43)', () => { + const pointer = pointerQuery(false); + try { + const {onChange, input, increment} = setup(); + const mouse = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...mouse, clientX: 212, clientY: 8}); + wait(700); + expect(shown(input)).toBe('6'); + act(() => { + pointer.set(true); + }); + wait(500); + expect(Number(shown(input))).toBeGreaterThan(6); + expect(onChange).not.toHaveBeenCalled(); + const reached = Number(shown(input)); + fireEvent.pointerUp(increment, {...mouse, clientX: 212, clientY: 8}); + expect(committed(onChange)).toEqual([reached]); + wait(1000); + expect(committed(onChange)).toEqual([reached]); + } finally { + pointer.restore(); + } + }); +}); + +describe('NumberInput steppers where the primary pointer is fine', () => { + it('the column listens through the gesture host alone: its passive pointerdown while idle and its claiming touchmove once a press is held, no page listener, no warning (spec:AST-094 FR43, FR24)', () => { + // A fine pointer: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const added: {target: EventTarget; type: string; passive: boolean}[] = []; + const listening = new Set(); + const scopeOf = (target: EventTarget) => + target === document ? 'document' : target === window ? 'window' : null; + const addEventListener = EventTarget.prototype.addEventListener; + const removeEventListener = EventTarget.prototype.removeEventListener; + vi.spyOn(EventTarget.prototype, 'addEventListener').mockImplementation( + function (this: EventTarget, type, listener, options) { + added.push({ + target: this, + type, + passive: typeof options === 'object' && options.passive === true, + }); + const scope = scopeOf(this); + if (scope != null) { + listening.add(`${scope}:${type}`); + } + addEventListener.call(this, type, listener, options); + }, + ); + vi.spyOn(EventTarget.prototype, 'removeEventListener').mockImplementation( + function (this: EventTarget, type, listener, options) { + const scope = scopeOf(this); + if (scope != null) { + listening.delete(`${scope}:${type}`); + } + removeEventListener.call(this, type, listener, options); + }, + ); + // The press's page listeners (the modality tracking's document + // pointerdown is a page-lifetime listener, not the press's). + const pageListeners = () => + [...listening].filter(key => + /:(touch|pointermove|pointerup|pointercancel)/.test(key), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const {onChange, increment} = setup(); + const column = increment.parentElement as HTMLElement; + // Idle: the column carries the gesture host's passive pointerdown alone. + // Where the primary pointer is fine, a press that keeps its touch takes + // its blocking touchmove only once the press is armed, since that + // press's pointerdown comes before any touch it brings (FR24, for every + // pointer). Nothing else listens for touch on the page or the field, and + // the press's page listeners are not there. + expect( + added + .filter(entry => entry.target === column) + .map(({type, passive}) => ({type, passive})) + .sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0)), + ).toEqual([{type: 'pointerdown', passive: true}]); + const field = column.closest('div:has(input)') ?? column.parentElement; + expect( + added.filter( + ({target, type}) => + type.startsWith('touch') && + target !== column && + (scopeOf(target) != null || + (target instanceof Node && field?.contains(target) === true)), + ), + ).toEqual([]); + expect(pageListeners()).toEqual([]); + expect(warn).not.toHaveBeenCalled(); + // A mouse press is the press tracker's, as a finger's is: it paints at + // press-down, commits once at the release, and leaves nothing listening + // on the page. + const pointer = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + expect(press(increment)).toBe('on'); + // Held, the press claims touch moves on the column itself (FR24). + expect( + added + .filter(entry => entry.target === column && entry.type === 'touchmove') + .map(({passive}) => passive), + ).toEqual([false]); + wait(600); + fireEvent.pointerUp(increment, {...pointer, clientX: 212, clientY: 8}); + expect(committed(onChange)).toEqual([6]); + expect(press(increment)).toBeNull(); + expect(pageListeners()).toEqual([]); + }); + + it('the column takes touch-action manipulation for its kept press, as under a finger (spec:AST-094 FR18, FR43)', async () => { + // A touchscreen laptop's finger presses the steppers as on a phone. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + // As under a finger: a frame read an earlier test scheduled runs first, + // on a real frame, and the host writes its touch-action in the next one. + vi.useRealTimers(); + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'Date', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); + vi.advanceTimersByTime(epoch); + // jsdom drops touch-action, so the host's writes are observed. + const writes: {style: CSSStyleDeclaration; value: string | null}[] = []; + const setProperty = CSSStyleDeclaration.prototype.setProperty; + vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation( + function ( + this: CSSStyleDeclaration, + name: string, + value: string | null, + priority?: string, + ) { + if (name === 'touch-action') { + writes.push({style: this, value}); + } + setProperty.call(this, name, value, priority); + }, + ); + const {increment} = setup(); + wait(16); + const column = increment.parentElement as HTMLElement; + const columnWrites = writes + .filter(({style}) => style === column.style) + .map(w => w.value); + expect(columnWrites).toContain('manipulation'); + expect(new Set(columnWrites)).toEqual(new Set(['manipulation'])); + }); + + it("a finger or pen held on a stepper refuses text selection for that press only, and a mouse keeps the browser's selection (spec:AST-094 FR41)", () => { + // A pen\'s hold acts as a finger\'s under any primary pointer, so a + // touchscreen laptop\'s finger holding a stepper selects nothing; a mouse + // keeps its released behavior. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {increment} = setup(); + const column = increment.parentElement as HTMLElement; + expect(refusesSelection(column)).toBe(false); + const id = fingerDown(increment, INCREMENT); + expect(refusesSelection(column)).toBe(true); + wait(600); + expect(refusesSelection(column)).toBe(true); + touch('touchend', increment, INCREMENT, id); + expect(refusesSelection(column)).toBe(false); + const pen = {pointerType: 'pen', pointerId: 43, isPrimary: true}; + fireEvent.pointerDown(increment, {...pen, clientX: 212, clientY: 8}); + expect(refusesSelection(column)).toBe(true); + wait(600); + fireEvent.pointerUp(increment, {...pen, clientX: 212, clientY: 8}); + expect(refusesSelection(column)).toBe(false); + const mouse = {pointerType: 'mouse', pointerId: 1, isPrimary: true}; + fireEvent.pointerDown(increment, {...mouse, clientX: 212, clientY: 8}); + expect(refusesSelection(column)).toBe(false); + wait(600); + expect(refusesSelection(column)).toBe(false); + fireEvent.pointerUp(increment, {...mouse, clientX: 212, clientY: 8}); + }); + + it('a right click on a stepper presses nothing: no paint, no step (spec:AST-094 FR43)', () => { + // The host takes a mouse's primary button only. + const {onChange, increment} = setup(); + const pointer = { + pointerType: 'mouse', + pointerId: 1, + isPrimary: true, + button: 2, + buttons: 2, + }; + fireEvent.pointerDown(increment, {...pointer, clientX: 212, clientY: 8}); + expect(press(increment)).toBeNull(); + wait(800); + fireEvent.pointerUp(increment, { + ...pointer, + buttons: 0, + clientX: 212, + clientY: 8, + }); + fireEvent.contextMenu(increment); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('NumberInput steppers on every touch device', () => { + it('carry no shared press feedback: the press tracker paints them', () => { + const {increment, decrement} = setup(); + expect(increment).not.toHaveAttribute('data-astryx-pressable'); + expect(decrement).not.toHaveAttribute('data-astryx-pressable'); + }); +}); + +describe('NumberInput steppers as the touch control', () => { + // The touch presentation lays the halves side by side; the half under a + // finger is the nearer one, whatever the layout. + const MINUS = {x: 312, y: 16}; + const PLUS = {x: 380, y: 16}; + + beforeEach(() => { + coarsePrimaryPointer(); + RECTS = CAPSULE; + }); + + it('a tap on the left half steps down, on the right half up', () => { + const {onChange, increment, decrement} = setup(); + let id = fingerDown(decrement, MINUS); + expect(press(decrement)).toBe('on'); + expect(press(increment)).toBeNull(); + touch('touchend', decrement, MINUS, id); + // Each tap steps on the click its engine sends after the lift (FR11). + expect(onChange).not.toHaveBeenCalled(); + act(() => { + decrement.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(onChange).toHaveBeenLastCalledWith(4); + id = fingerDown(increment, PLUS); + touch('touchend', increment, PLUS, id); + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(onChange).toHaveBeenLastCalledWith(5); + }); + + it('a slide across the divider carries the paint and the repeat', () => { + const {onChange, input, increment, decrement} = setup(); + const id = fingerDown(decrement, MINUS); + wait(800); + expect(shown(input)).toBe('3'); + touch('touchmove', decrement, PLUS, id); + expect(press(decrement)).toBeNull(); + expect(press(increment)).toBe('on'); + // The next beat, at 975 ms, steps +. + wait(175); + expect(shown(input)).toBe('4'); + touch('touchend', decrement, PLUS, id); + expect(committed(onChange)).toEqual([4]); + }); + + it('a finger just past the capsule’s end stays on the press', () => { + // The control profile's tracking area reaches past the capsule. + const {onChange, increment} = setup(); + const id = fingerDown(increment, PLUS); + wait(60); + touch('touchmove', increment, {x: 395, y: 16}, id); + expect(press(increment)).toBe('on'); + touch('touchend', increment, {x: 395, y: 16}, id); + // The lift names +, so the click an engine sends within its tap slop + // steps it. + act(() => { + increment.dispatchEvent(clickNaming('mouse', 1)); + }); + expect(committed(onChange)).toEqual([6]); + }); + + it('a finger past the tracking area beyond the capsule’s end is outside it', () => { + const {onChange, increment} = setup(); + const id = fingerDown(increment, PLUS); + wait(60); + touch('touchmove', increment, {x: 480, y: 16}, id); + touch('touchend', increment, {x: 480, y: 16}, id); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/NumberInput/NumberInput.test.tsx b/packages/core/src/NumberInput/NumberInput.test.tsx index 64dd2b6127039..426b1dcfc1c25 100644 --- a/packages/core/src/NumberInput/NumberInput.test.tsx +++ b/packages/core/src/NumberInput/NumberInput.test.tsx @@ -9,6 +9,7 @@ * SYNC: When NumberInput.tsx changes, update tests to match new behavior */ +import {readFileSync} from 'node:fs'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import {act, useState} from 'react'; import {render, screen, fireEvent, waitFor} from '@testing-library/react'; @@ -39,6 +40,38 @@ const RING = stylex .className!.split(' ') .pop()!; +/** + * Press a stepper as `pointerType` does: a pointer press and its click, or, + * with no pointer (a screen reader, a programmatic `click()`), a click of + * detail 0 alone. + */ +function pressStepper(button: HTMLElement, pointerType: string | null): void { + if (pointerType == null) { + fireEvent.click(button); + return; + } + fireEvent.pointerDown(button, {pointerType}); + fireEvent.click(button, {detail: 1}); +} + +/** The primary pointer is coarse (a phone, a tablet) until the restore runs. */ +function coarsePrimaryPointer(): () => void { + const matchMedia = window.matchMedia.bind(window); + const spy = vi + .spyOn(window, 'matchMedia') + .mockImplementation((query: string): MediaQueryList => ({ + media: query, + matches: query.includes('(pointer: coarse)') || matchMedia(query).matches, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); + return () => spy.mockRestore(); +} + // FieldStatus announces status messages through the persistent useAnnounce // singletons; remove them between tests so role/aria-live queries in this // file never match a leftover region. @@ -2375,7 +2408,9 @@ describe('NumberInput stepping', () => { warnSpy.mockRestore(); }); - it('steps the value and returns focus to the input', () => { + it('steps the value and moves focus to the input on a mouse click', () => { + // A mouse: this test's context has no coarse pointer. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); const onChange = vi.fn(); render( { />, ); const input = screen.getByRole('spinbutton'); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); - fireEvent.click(screen.getByRole('button', {name: 'Increment Quantity'})); + fireEvent.pointerDown(increment, {pointerType: 'mouse'}); + fireEvent.click(increment, {detail: 1}); expect(onChange).toHaveBeenLastCalledWith(6); expect(input).toHaveFocus(); - fireEvent.click(screen.getByRole('button', {name: 'Decrement Quantity'})); + fireEvent.pointerDown(decrement, {pointerType: 'mouse'}); + fireEvent.click(decrement, {detail: 1}); + expect(onChange).toHaveBeenLastCalledWith(4); + expect(input).toHaveFocus(); + }); + + // UIStepper never becomes first responder, and focusing the field on a + // phone brings up the on-screen keyboard. A tracked finger or pen steps + // through the gesture system (NumberInput.stepperTouch.test.tsx); a touch + // click the steppers did not track still steps, without focus. + it('steps the value without focusing the input on a touch tap', () => { + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('spinbutton'); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + const decrement = screen.getByRole('button', { + name: 'Decrement Quantity', + }); + + pressStepper(increment, 'touch'); + expect(onChange).toHaveBeenLastCalledWith(6); + expect(input).not.toHaveFocus(); + + pressStepper(decrement, 'touch'); expect(onChange).toHaveBeenLastCalledWith(4); + expect(input).not.toHaveFocus(); + }); + + // Where the primary pointer is fine no on-screen keyboard comes up, so a + // pen (a desktop tablet's) and an activation with no pointer (a desktop + // screen reader's) focus the field as a mouse click does, and the arrow + // keys and the wheel keep stepping. + it.each([ + ['a pen tap', 'pen'], + ['an activation with no pointer', null], + ] as const)( + '%s steps the value and focuses the input where the primary pointer is fine', + (_, pointerType) => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const onChange = vi.fn(); + render( + , + ); + pressStepper( + screen.getByRole('button', {name: 'Increment Quantity'}), + pointerType, + ); + expect(onChange).toHaveBeenLastCalledWith(6); + expect(screen.getByRole('spinbutton')).toHaveFocus(); + }, + ); + + // Only a primary click presses a stepper (`spec:AST-094` FR43): a right + // click's press names no pointer, so the activation after it is judged + // on its own. + it('a right click on a stepper leaves the next activation with no pointer to focus the input where the primary pointer is fine', () => { + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const onChange = vi.fn(); + render( + , + ); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + fireEvent.pointerDown(increment, { + pointerType: 'mouse', + button: 2, + buttons: 2, + }); + fireEvent.pointerUp(increment, {pointerType: 'mouse', button: 2}); + fireEvent.contextMenu(increment); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('spinbutton')).not.toHaveFocus(); + pressStepper(increment, null); + expect(onChange).toHaveBeenLastCalledWith(6); + expect(screen.getByRole('spinbutton')).toHaveFocus(); + }); + + // Where it is coarse (a phone, a tablet), focusing the field would bring + // up the on-screen keyboard: a pen and an activation with no pointer step + // without moving focus, as a finger does. + it.each([ + ['a pen tap', 'pen'], + ['an activation with no pointer', null], + ] as const)( + '%s steps the value without focusing the input where the primary pointer is coarse', + (_, pointerType) => { + const restore = coarsePrimaryPointer(); + try { + const onChange = vi.fn(); + render( + , + ); + pressStepper( + screen.getByRole('button', {name: 'Increment Quantity'}), + pointerType, + ); + expect(onChange).toHaveBeenLastCalledWith(6); + expect(screen.getByRole('spinbutton')).not.toHaveFocus(); + } finally { + restore(); + } + }, + ); + + it('keeps an editing field focused and steps from its draft on a tap', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole('spinbutton'); + await user.click(input); + await user.clear(input); + await user.type(input, '7.5'); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + + const pointerDown = fireEvent.pointerDown(increment, { + pointerType: 'touch', + }); + fireEvent.click(increment, {detail: 1}); + + // The press is default-prevented, so focus never leaves the field: + // canceling the mousedown alone would not do, since Chrome on Android + // focuses a held stepper at its long press unless the touch's + // pointerdown is canceled (measured). + expect(pointerDown).toBe(false); + expect(onChange).toHaveBeenLastCalledWith(8); expect(input).toHaveFocus(); }); + it('keeps an editing field focused through a quick double tap: its second tap’s mousedown is default-prevented too', async () => { + // iOS Safari follows the second tap of a quick double tap with a + // compatibility mousedown even though its pointerdown was prevented; + // that mousedown's default would focus the stepper and blur the field. + const user = userEvent.setup(); + render( + {}} + hasNumberSteppers + />, + ); + const input = screen.getByRole('spinbutton'); + await user.click(input); + for (const name of ['Increment Quantity', 'Decrement Quantity']) { + const stepper = screen.getByRole('button', {name}); + expect(fireEvent.pointerDown(stepper, {pointerType: 'touch'})).toBe( + false, + ); + expect(fireEvent.mouseDown(stepper, {detail: 2})).toBe(false); + } + expect(input).toHaveFocus(); + }); + + it('does nothing for a tap on the stepper at its bound', () => { + const onChange = vi.fn(); + render( + , + ); + const increment = screen.getByRole('button', { + name: 'Increment Quantity', + }); + fireEvent.pointerDown(increment, {pointerType: 'touch'}); + fireEvent.click(increment, {detail: 1}); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole('spinbutton')).not.toHaveFocus(); + }); + it('disables only the stepper at the reached boundary', () => { render( { expect(css).toContain('border-radius: 2px'); }); }); + +describe('NumberInput stepper column under a held finger', () => { + it('declares no touch-action or selection style of its own: the gesture host writes them for its kept press', () => { + // Where the primary pointer is coarse the host gives the column's kept + // press `touch-action: manipulation` (`spec:AST-094` FR18) and the + // selection lock (FR41); a fine pointer keeps the browser's (FR19). + const source = readFileSync( + 'packages/core/src/NumberInput/NumberInput.tsx', + 'utf8', + ); + const start = source.indexOf(' numberSteppers: {'); + const column = source.slice(start, source.indexOf('\n },', start)); + expect(column).not.toContain('touchAction'); + expect(column).not.toContain('userSelect'); + expect(column).not.toContain('WebkitTouchCallout'); + const touchSource = readFileSync( + 'packages/core/src/NumberInput/NumberInputTouch.tsx', + 'utf8', + ); + expect(touchSource).toContain('keepsTouch: true,'); + expect(touchSource).toContain('selectionLock: lockSelection,'); + // The column's press tracker attaches for every pointer (`spec:AST-094` + // FR43, FR19). + expect(touchSource).toMatch(/usePressTracker\(columnRef, options\)/); + }); +}); diff --git a/packages/core/src/NumberInput/NumberInput.tsx b/packages/core/src/NumberInput/NumberInput.tsx index ee285dab0c6dc..736f7b7b7bec4 100644 --- a/packages/core/src/NumberInput/NumberInput.tsx +++ b/packages/core/src/NumberInput/NumberInput.tsx @@ -54,7 +54,10 @@ import {useTooltip} from '../Tooltip'; import {getInputARIA} from '../utils'; import {joinAriaIDs} from '../utils/inputAria'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; -import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useStepperTouch} from './NumberInputTouch'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {observeResize} from '../utils/sharedResizeObserver'; +import {useTouchPresentation} from '../utils/touchPresentation'; import {inputDisabledFocusStyles} from '../Field/inputDisabledFocus'; import {useSize} from '../SizeContext/SizeContext'; import {useInputContainer} from '../hooks/useInputContainer'; @@ -87,6 +90,24 @@ const padInlineEnd = `var(--astryx-number-input-padding-inline-end, ${padInline} const padBlockStart = `var(--astryx-number-input-padding-block-start, ${padBlockAll})`; const padBlockEnd = `var(--astryx-number-input-padding-block-end, ${padBlockAll})`; +// A coarse primary pointer: the touch presentation resolver's TOUCH_QUERY +// (utils/touchPresentation.ts) as a StyleX media key, so the touch control +// paints before hydration on every touch device, iOS and Android alike. +// Spelled as a literal: the StyleX build gives an imported at-rule key a +// different priority than its literal, which reorders the cascade; +// NumberInput.stepperLook.test.tsx keeps it equal to TOUCH_QUERY. +const TOUCH = '@media (pointer: coarse)'; +// UIStepper's glyphs (motion/uikit/stepper.ts `look`): 13pt paths with +// 1.67pt strokes, as SVG masks for the touch control's halves. +const TOUCH_GLYPH_MINUS_MASK = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'%3E%3Cpath d='M0 6.5H13' stroke='black' stroke-width='1.67'/%3E%3C/svg%3E\")"; +const TOUCH_GLYPH_PLUS_MASK = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13'%3E%3Cpath d='M0 6.5H13M6.5 0V13' stroke='black' stroke-width='1.67'/%3E%3C/svg%3E\")"; +// The narrowest Field box the touch control fits in: the control (94 px), the +// gap and an input box that still shows a few digits. A narrower field keeps +// the column inside its box. +const TOUCH_CONTROL_MIN_FIELD_WIDTH = 170; + const styles = stylex.create({ wrapper: { zIndex: 1, @@ -102,6 +123,19 @@ const styles = stylex.create({ wrapperWithNumberSteppers: { paddingInlineEnd: 0, }, + // Under a finger the steppers leave the field (numberSteppersTouch): the + // input box takes its own end padding back, and the Field box gives up the + // control's width and a gap at its inline end (fieldWithTouchSteppers), so + // the input box and an attached status under it end at the same edge. + wrapperWithTouchSteppers: { + paddingInlineEnd: {default: 0, [TOUCH]: padInlineEnd}, + }, + fieldWithTouchSteppers: { + paddingInlineEnd: { + default: 0, + [TOUCH]: `calc(94px + ${spacingVars['--spacing-2']})`, + }, + }, input: { display: 'block', flex: 1, @@ -158,6 +192,117 @@ const styles = stylex.create({ overflow: 'hidden', borderStartEndRadius: 'var(--_field-radius)', borderEndEndRadius: 'var(--_field-radius)', + // No touch-action, selection or touch-target style of its own: where the + // primary pointer is coarse the gesture host gives the column's kept press + // `touch-action: manipulation` (`spec:AST-094` FR18) and its selection + // lock (FR41); a fine pointer keeps the browser's defaults (FR19). Each + // half keeps its own size: the theme's touch target is not for spin + // buttons. + }, + // Every default here is a value, never null: a null default would erase the + // same property of a style merged before it (the column's base, an icon's + // own display) wherever the gate does not match. The ones the base does not + // set are their CSS initial values. + // Under a finger (TOUCH above), on iOS and Android alike, the steppers are + // one control beside the field, shaped like iOS 26's UIStepper + // (motion/uikit/stepper.ts `look`): a 94 px wide capsule of the theme's + // neutral fill at the field size's element height (md: UIKit's 32), − at + // the inline start and + at the inline end, and a 1 px divider 4 px from + // the top and the bottom. StyleX inlines only same-file values, so the + // table's numbers are spelled here; NumberInput.stepperLook.test.tsx keeps + // them equal. + numberSteppersTouch: { + position: {default: 'static', [TOUCH]: 'absolute'}, + insetInlineStart: { + default: 'auto', + [TOUCH]: `calc(100% + ${borderVars['--border-width']} + ${spacingVars['--spacing-2']})`, + }, + // Centred on the field at any height: pinned top and bottom, auto margins. + insetBlockStart: {default: 'auto', [TOUCH]: 0}, + insetBlockEnd: {default: 'auto', [TOUCH]: 0}, + // The increment button comes first in the DOM: a reversed row puts it at + // the inline end, so − is at the inline start and + at the end, mirrored + // in a right-to-left page (design:bidirectionality DR3). + flexDirection: {default: 'column', [TOUCH]: 'row-reverse'}, + width: { + default: spacingVars['--spacing-4'], + [TOUCH]: '94px', + }, + // The field's size sets the height (the component-size rules): md is + // UIKit's 32; touchControlSm and touchControlLg take theirs. + height: {default: 'auto', [TOUCH]: sizeVars['--size-element-md']}, + marginBlockStart: { + default: `calc(-1 * ${padBlockStart})`, + [TOUCH]: 'auto', + }, + marginBlockEnd: { + default: `calc(-1 * ${padBlockEnd})`, + [TOUCH]: 'auto', + }, + borderInlineStartWidth: { + default: borderVars['--border-width'], + [TOUCH]: 0, + }, + borderStartStartRadius: { + default: 0, + [TOUCH]: '9999px', + }, + borderStartEndRadius: { + default: 'var(--_field-radius)', + [TOUCH]: '9999px', + }, + borderEndStartRadius: {default: 0, [TOUCH]: '9999px'}, + borderEndEndRadius: { + default: 'var(--_field-radius)', + [TOUCH]: '9999px', + }, + backgroundColor: { + default: 'transparent', + [TOUCH]: colorVars['--color-neutral'], + }, + // Forced colors repaint that fill as Canvas: an outline keeps the + // control's edge. Defaults are CSS initial values, so no base is erased. + outlineStyle: { + default: 'none', + [TOUCH]: {default: 'none', '@media (forced-colors: active)': 'solid'}, + }, + outlineWidth: { + default: 'medium', + [TOUCH]: {default: 'medium', '@media (forced-colors: active)': '1px'}, + }, + outlineColor: { + default: 'currentColor', + [TOUCH]: { + default: 'currentColor', + '@media (forced-colors: active)': 'CanvasText', + }, + }, + '::after': { + content: {default: 'none', [TOUCH]: '""'}, + position: 'absolute', + insetBlockStart: '4px', + insetBlockEnd: '4px', + insetInlineStart: 'calc(50% - 0.5px)', + width: '1px', + // Forced colors repaint backgrounds as Canvas: the divider, drawn as + // one, opts out and takes the text colour. + backgroundColor: { + default: colorVars['--color-track'], + '@media (forced-colors: active)': 'CanvasText', + }, + forcedColorAdjust: { + default: 'auto', + '@media (forced-colors: active)': 'none', + }, + pointerEvents: 'none', + }, + }, + // The touch control at the sm and lg field sizes (md: numberSteppersTouch). + touchControlSm: { + height: {default: 'auto', [TOUCH]: sizeVars['--size-element-sm']}, + }, + touchControlLg: { + height: {default: 'auto', [TOUCH]: sizeVars['--size-element-lg']}, }, numberStepperButton: { boxSizing: 'border-box', @@ -178,6 +323,79 @@ const styles = stylex.create({ }, outline: 'none', }, + // Each half of the touch control: transparent over the capsule, so the + // press paint darkens just that half, with the glyph in the primary icon + // colour. + numberStepperButtonTouch: { + backgroundColor: { + default: colorVars['--color-background-surface'], + [TOUCH]: 'transparent', + }, + color: { + default: colorVars['--color-icon-secondary'], + [TOUCH]: colorVars['--color-icon-primary'], + }, + }, + // UIKit draws a disabled half's glyph in the divider's colour. + numberStepperButtonDisabledTouch: { + color: { + default: colorVars['--color-icon-disabled'], + [TOUCH]: colorVars['--color-track'], + }, + '::before': { + backgroundColor: { + default: 'currentColor', + '@media (forced-colors: active)': 'GrayText', + }, + }, + }, + decrementButtonTouch: { + borderBlockStartWidth: { + default: borderVars['--border-width'], + [TOUCH]: 0, + }, + }, + // The column's chevrons, hidden under a finger for the touch control's + // glyphs. Set on the icon itself; its default is the icon's own display + // (inline-flex), so nothing of the icon's base style is erased. + chevronNotTouch: { + display: {default: 'inline-flex', [TOUCH]: 'none'}, + }, + // UIKit's glyphs, drawn under a finger only, as each half's own decoration + // (the half's label names it, so no element of its own): the 13pt vector + // glyph as a mask over the half's glyph colour, so its 1.67pt strokes keep + // their weight at every pixel ratio (a box that thin snaps to whole device + // pixels). + touchGlyph: { + position: {default: 'static', [TOUCH]: 'relative'}, + '::before': { + content: {default: 'none', [TOUCH]: '""'}, + position: 'absolute', + insetBlockStart: 'calc(50% - 6.5px)', + insetInlineStart: 'calc(50% - 6.5px)', + width: '13px', + height: '13px', + // Forced colors repaint backgrounds as Canvas: the glyph, drawn as one, + // opts out and takes the text colour (GrayText at a bound). + backgroundColor: { + default: 'currentColor', + '@media (forced-colors: active)': 'CanvasText', + }, + forcedColorAdjust: { + default: 'auto', + '@media (forced-colors: active)': 'none', + }, + maskRepeat: 'no-repeat', + maskSize: '13px 13px', + pointerEvents: 'none', + }, + }, + touchGlyphMinus: { + '::before': {maskImage: TOUCH_GLYPH_MINUS_MASK}, + }, + touchGlyphPlus: { + '::before': {maskImage: TOUCH_GLYPH_PLUS_MASK}, + }, numberStepperButtonDisabled: { color: colorVars['--color-icon-disabled'], cursor: 'default', @@ -191,6 +409,22 @@ const styles = stylex.create({ incrementIcon: { transform: 'rotate(180deg)', }, + // The touch control's outer corners, on each half, so a pressed half's + // paint keeps the capsule's shape on its own. − is at the inline start and + // + at the inline end in either direction (numberSteppersTouch), so logical + // corners mirror with the page; 9999px is clamped to half the height. + incrementTouchCorners: { + borderStartStartRadius: {default: 0, [TOUCH]: 0}, + borderEndStartRadius: {default: 0, [TOUCH]: 0}, + borderStartEndRadius: {default: 0, [TOUCH]: '9999px'}, + borderEndEndRadius: {default: 0, [TOUCH]: '9999px'}, + }, + decrementTouchCorners: { + borderStartStartRadius: {default: 0, [TOUCH]: '9999px'}, + borderEndStartRadius: {default: 0, [TOUCH]: '9999px'}, + borderStartEndRadius: {default: 0, [TOUCH]: 0}, + borderEndEndRadius: {default: 0, [TOUCH]: 0}, + }, }); const sizeStyles = stylex.create({ @@ -225,6 +459,8 @@ import type {SizeValue} from '../utils/types'; import {themeProps} from '../utils/themeProps'; import {useTranslator, useLocale} from '../i18n'; import {formatEditableNumber} from './numberParser'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; +import {isPrimaryClick} from '../utils/primaryClick'; import {parseNumberInput, resolveNumberInputCommit} from './numberInputCommit'; interface NumberInputPropsBase extends Omit< @@ -523,6 +759,15 @@ function getSteppedValue({ return Object.is(nextValue, -0) ? 0 : nextValue; } +/** Whether the primary pointer is coarse (a phone, a tablet). */ +function isCoarsePrimaryPointer(view: Window | null): boolean { + return ( + view != null && + typeof view.matchMedia === 'function' && + view.matchMedia(TOUCH_QUERY).matches + ); +} + /** * A number input component for collecting numeric user input. * Commits text edits on blur or Enter and only calls onChange when the whole @@ -581,12 +826,15 @@ export function NumberInput({ 'aria-labelledby': ariaLabelledByProp, ...restProps }: NumberInputProps) { - const {rest, container} = useFieldInputStyling('NumberInput', restProps, { - containerXstyle, - containerClassName, - containerStyle, - }); - const pressable = usePressFeedback(); + const {rest, container, hasFieldBox} = useFieldInputStyling( + 'NumberInput', + restProps, + { + containerXstyle, + containerClassName, + containerStyle, + }, + ); const t = useTranslator(); const locale = useLocale(); const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional}); @@ -607,6 +855,9 @@ export function NumberInput({ // Pending input while user is typing (null = show formatted value) const [pendingInput, setPendingInput] = useState(null); const [isFocused, setIsFocused] = useState(false); + // The value a stepper press has stepped to, shown while the press lasts; + // the press commits it once, at its release (NumberInputTouch.tsx). + const [pressValue, setPressValue] = useState(null); // Disabled-reason tooltip. The tooltip listeners attach to the input container (which already exists) and // the input stays perceivable via aria-disabled instead of the native @@ -676,6 +927,11 @@ export function NumberInput({ // Preserve pending text while editing. Otherwise show the formatted value // at rest and the raw numeric value while focused so it remains editable. const displayValue = useMemo(() => { + if (pressValue !== null) { + return isFocused + ? formatEditableNumber(pressValue, locale) + : (formatValue?.(pressValue) ?? String(pressValue)); + } if (pendingInput !== null) { return pendingInput; } @@ -683,7 +939,15 @@ export function NumberInput({ return ''; } return isFocused ? formatEditableNumber(value, locale) : formattedValue; - }, [formattedValue, isFocused, locale, pendingInput, value]); + }, [ + formatValue, + formattedValue, + isFocused, + locale, + pendingInput, + pressValue, + value, + ]); // Check if current pending input is valid (for styling purposes) const isInputValid = useMemo(() => { @@ -892,8 +1156,185 @@ export function NumberInput({ [ref, wheelListenerRef], ); - const canIncrement = getNextValue(1) !== valueForStepping; - const canDecrement = getNextValue(-1) !== valueForStepping; + // One step from any value, for a press that steps from its own value. + const stepFrom = useCallback( + (current: number | null, direction: StepDirection) => + getSteppedValue({ + currentValue: current, + direction, + min, + max, + step, + isIntegerOnly, + }), + [isIntegerOnly, max, min, step], + ); + // The bounds follow the value on show, a press's while it lasts. + const shownValue = pressValue ?? valueForStepping; + const canIncrement = stepFrom(shownValue, 1) !== shownValue; + const canDecrement = stepFrom(shownValue, -1) !== shownValue; + + // Commits a stepper press's final value, once, at its release. + const commitValue = useCallback( + (next: number) => { + if (isDisabled || isReadOnly) { + return; + } + setPendingInput(null); + if (next !== value) { + onChange(next); + } + }, + [isDisabled, isReadOnly, onChange, value], + ); + + // The kind of pointer that pressed a stepper, read when its click lands. + const stepperPointerTypeRef = useRef(null); + + const handleStepperPointerDown = (event: React.PointerEvent) => { + // The steppers never take focus: an editing field stays focused. The + // pointerdown itself is canceled, not only the mousedown below: Chrome on + // Android focuses a held stepper at its long press unless the touch's + // pointerdown is canceled (measured), and that would blur an editing + // field. WebKit's Linux build then sends no click after a tap, unlike + // iOS Safari and Chrome (measured); it is outside `spec:AST-013`'s + // support. + event.preventDefault(); + // Only a primary click presses a stepper: a right click, a pen's barrel + // button and a macOS ctrl-click start no press (`spec:AST-094` FR43), so + // they name no pointer for the click after them. + stepperPointerTypeRef.current = isPrimaryClick(event) + ? event.pointerType + : null; + }; + + // iOS Safari follows the second tap of a quick double tap with a + // compatibility mousedown even though its pointerdown was prevented; that + // mousedown's default would focus the stepper and blur an editing field, so + // the column prevents it. + const handleStepperMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + // Where a stepper press leaves focus (`component:NumberInput` FR5). A mouse + // click moves focus to the field, so the arrow keys and the wheel keep + // stepping, and so do a pen and an activation with no pointer (a screen + // reader's) where the primary pointer is fine. A finger, and a pen or an + // activation with no pointer where the primary pointer is coarse, step the + // value without moving focus: there, focusing the field would bring up the + // on-screen keyboard, which a stepper never does. + const focusAfterPress = ( + pointerType: string | null, + event: React.MouseEvent, + ) => { + const isMouseClick = pointerType === 'mouse' && event.detail > 0; + const isFinePointer = !isCoarsePrimaryPointer( + event.currentTarget.ownerDocument.defaultView, + ); + if ( + isMouseClick || + (isFinePointer && (pointerType === 'pen' || pointerType == null)) + ) { + inputRef.current?.focus(); + } + }; + + const pressStepper = ( + direction: StepDirection, + event: React.MouseEvent, + ) => { + const pointerType = stepperPointerTypeRef.current; + stepperPointerTypeRef.current = null; + // Every click answers the press layer: the click after a tracked tap + // steps the half the press lifted on (spec:AST-094 FR11), the click after + // a press that stepped while held steps nothing, and any other click + // steps its own half. + const click = stepperTouch.takeClick(event.nativeEvent); + // An aria-disabled stepper does nothing, as a natively disabled one did + // (spec:AST-085 FR2). + if ( + isDisabled || + isReadOnly || + (direction === 1 ? !canIncrement : !canDecrement) + ) { + return; + } + focusAfterPress(pointerType, event); + if (click === 'none') { + return; + } + stepValue(click === 'own' ? direction : click === 'increment' ? 1 : -1); + }; + + // A click on the column itself, not on a half (its border), steps the half + // the tap it follows lifted on (spec:AST-094 FR11), and nothing otherwise. + // It never reaches the field's click-to-focus; a press there leaves focus + // as one on a half does (focusAfterPress). Only a pointer presses the + // column itself, so a click no pointer sent moves no focus there. + const handleColumnClick = (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) { + return; + } + event.stopPropagation(); + const pointerType = stepperPointerTypeRef.current; + stepperPointerTypeRef.current = null; + if (pointerType != null) { + focusAfterPress(pointerType, event); + } + const click = stepperTouch.takeClick(event.nativeEvent); + if (click === 'increment' || click === 'decrement') { + stepValue(click === 'increment' ? 1 : -1); + } + }; + + // A press on the steppers by any pointer is tracked by the press layer + // (NumberInputTouch.tsx); a key or assistive technology steps by the click. + const incrementRef = useRef(null); + const decrementRef = useRef(null); + const stepperColumnRef = useRef(null); + const stepperTouch = useStepperTouch( + { + valueForStepping, + stepFrom, + showPressValue: setPressValue, + commitValue, + isDisabled, + isReadOnly, + hasSteppers: hasNumberSteppers, + }, + incrementRef, + decrementRef, + stepperColumnRef, + ); + // The touch control needs a Field box to give up its width (none renders in + // an InputGroup or a horizontal-labels FormLayout) and one wide enough for + // it: a numeric width is known now; under a finger, any other width is + // measured, through the shared resize observer. + const {isTouch} = useTouchPresentation(); + const fieldRef = useRef(null); + const [isMeasuredTooNarrow, setIsMeasuredTooNarrow] = useState(false); + const canHaveTouchControl = + hasNumberSteppers && + !inputGroup && + hasFieldBox && + !(typeof width === 'number' && width < TOUCH_CONTROL_MIN_FIELD_WIDTH); + const measuresField = canHaveTouchControl && isTouch; + const hasTouchControl = + canHaveTouchControl && !(measuresField && isMeasuredTooNarrow); + useIsomorphicLayoutEffect(() => { + const field = fieldRef.current; + if (!measuresField || field == null) { + return undefined; + } + // The shared observer (one per document) answers once as it subscribes, + // then on every resize. A box with no width is not laid out yet. + return observeResize(field, () => { + const fieldWidth = field.getBoundingClientRect().width; + setIsMeasuredTooNarrow( + fieldWidth > 0 && fieldWidth < TOUCH_CONTROL_MIN_FIELD_WIDTH, + ); + }); + }, [measuresField]); // Handle clear button click const handleClear = useCallback( @@ -946,7 +1387,10 @@ export function NumberInput({ stylex.props( inputWrapperStyles.base, styles.wrapper, - hasNumberSteppers && styles.wrapperWithNumberSteppers, + hasNumberSteppers && + (hasTouchControl + ? styles.wrapperWithTouchSteppers + : styles.wrapperWithNumberSteppers), sizeStyles[size], isDisabled && inputWrapperStyles.disabled, isDisabled && inputDisabledFocusStyles.ring, @@ -1034,64 +1478,79 @@ export function NumberInput({ )} {statusIcon} {hasNumberSteppers && ( -
    +
    )} @@ -1111,6 +1570,11 @@ export function NumberInput({ return ( number | null; + /** Shows `value` in the field while a press lasts; null: the committed value. */ + showPressValue: (value: number | null) => void; + /** Commits a press's final value: `onChange`, once. */ + commitValue: (value: number) => void; + isDisabled: boolean; + isReadOnly: boolean; + /** Whether the steppers are shown (`hasNumberSteppers`). */ + hasSteppers: boolean; +} + +/** What a stepper click steps: a half, nothing, or its own half. */ +export type StepperClick = StepperHalf | 'none' | 'own'; + +export interface StepperTouch { + /** + * What a stepper click does. The click a tracked press gets after its lift + * steps the half the press lifted on when it was a tap (`spec:AST-094` + * FR11), and nothing ('none') when the press stepped while held or lifted + * outside. Any other click is its own ('own'): a keyboard's, assistive + * technology's, or one long after the lift. Every click ends the wait. + */ + takeClick: (click: MouseEvent) => StepperClick; +} + +/** Tracks a press on the steppers by any pointer. */ +export function useStepperTouch( + state: StepperTouchState, + incrementRef: RefObject, + decrementRef: RefObject, + columnRef: RefObject, +): StepperTouch { + // The column's tracker, for the paint its control events read. + const trackerRef = useRef(null); + // The last lift a click may follow: when, and the half a tap's click steps + // (null: its click steps nothing). Null while no click is owed. + const liftRef = useRef<{at: number; tap: StepperHalf | null} | null>(null); + // The value the press has stepped to; null before its first step. + const pressValueRef = useRef(null); + const pressRef = useRef<{ + downTime: number; + isDelayed: boolean; + /** + * The halves' centres in client px, read once at touch-down; null with + * no layout (nothing is laid out to tell the halves apart). A point + * belongs to the nearer one: the decrement half is below the increment in + * the column and beside it in the touch control. + */ + centers: { + increment: {x: number; y: number}; + decrement: {x: number; y: number}; + } | null; + /** The half the press landed on. */ + landedOn: StepperHalf; + /** The half under the pointer, kept through the lift for a quick tap's flash. */ + half: StepperHalf | null; + /** Let go of because the field turned disabled or read-only mid-press. */ + isReleased: boolean; + }>({ + downTime: 0, + isDelayed: false, + centers: null, + landedOn: 'increment', + half: null, + isReleased: false, + }); + const latestRef = useRef(state); + useIsomorphicLayoutEffect(() => { + latestRef.current = state; + }); + const [tracking] = useState( + () => + new StepperTracking({ + step: half => { + const latest = latestRef.current; + if (latest.isDisabled || latest.isReadOnly) { + return false; + } + const current = pressValueRef.current ?? latest.valueForStepping; + const next = latest.stepFrom(current, half === 'increment' ? 1 : -1); + if (next == null || next === current) { + return false; + } + pressValueRef.current = next; + latest.showPressValue(next); + return true; + }, + }), + ); + useEffect(() => () => tracking.dispose(), [tracking]); + + const halfOf = (target: unknown): StepperHalf | null => { + if (!(target instanceof Node)) { + return null; + } + if (incrementRef.current?.contains(target) === true) { + return 'increment'; + } + return decrementRef.current?.contains(target) === true ? 'decrement' : null; + }; + + // The half nearer the point; with no layout, the one the press landed on. + const halfUnder = (point: {x: number; y: number}): StepperHalf => { + const {centers, landedOn} = pressRef.current; + return centers == null ? landedOn : halfAt(centers, point); + }; + + // The one layout read of a press: each half's centre; null with no layout + // (nothing is laid out to tell the halves apart). + const readCenters = (): { + increment: {x: number; y: number}; + decrement: {x: number; y: number}; + } | null => { + const boxOf = (button: HTMLButtonElement | null) => + button?.getBoundingClientRect() ?? null; + const incrementBox = boxOf(incrementRef.current); + const decrementBox = boxOf(decrementRef.current); + const centerOf = (box: DOMRect) => ({ + x: box.left + box.width / 2, + y: box.top + box.height / 2, + }); + return incrementBox == null || + decrementBox == null || + (decrementBox.width === 0 && decrementBox.height === 0) + ? null + : {increment: centerOf(incrementBox), decrement: centerOf(decrementBox)}; + }; + + const paint = (half: StepperHalf | null) => { + for (const [own, button] of [ + ['increment', incrementRef.current], + ['decrement', decrementRef.current], + ] as const) { + const next = half === own ? 'on' : 'none'; + // Only real changes are written: an unchanged write still restyles. + if (button != null && readPressPaint(button) !== next) { + paintPress(button, next); + } + } + }; + + // The press ended with its lift: what it stepped to is committed, once. + const commit = () => { + const value = pressValueRef.current; + pressValueRef.current = null; + if (value != null) { + latestRef.current.commitValue(value); + } + latestRef.current.showPressValue(null); + }; + + // The press was taken away: what it stepped to is dropped. + const discard = () => { + pressValueRef.current = null; + latestRef.current.showPressValue(null); + }; + + // A field turned disabled or read-only during a press lets go of it at once + // (PressTracker reads isEnabled only when a touch begins): no pressed paint + // beside aria-disabled, no repeat timer and no commit, until the next press. + const wasBlockedRef = useRef(false); + useIsomorphicLayoutEffect(() => { + const isBlocked = state.isDisabled || state.isReadOnly; + if (isBlocked && !wasBlockedRef.current && tracking.isTracking) { + const press = pressRef.current; + press.isReleased = true; + press.half = null; + tracking.cancel(); + paint(null); + discard(); + } + wasBlockedRef.current = isBlocked; + }); + + const options: UsePressTrackerOptions = { + // No trackingExpansion of its own: the press keeps tracking within the + // control profile's area, `UIKIT_CONTROL.trackingExpansion` (70 px past + // the column), so a pointer trembling over the edge stays on the press. + // Proposed, pending owner review (M-STP20 in motion/MEASUREMENTS.md): + // UIStepper itself tracks within its frame only. + // + // Once a press is delivered the pointer's touch stays the stepper's, as a + // scroll view never cancels a tracking UIControl: a slide onto the other + // half is not the page's pan. (A scroll that starts on the column waits + // on the main thread for it.) + keepsTouch: true, + // A stepper's hold repeats, so its press selects no text and opens no + // callout, for that press only (`spec:AST-094` FR41). + selectionLock: lockSelection, + isEnabled: () => + !latestRef.current.isDisabled && !latestRef.current.isReadOnly, + onControlEvent: (event, touch) => { + const press = pressRef.current; + if (event === 'touchDown') { + press.isReleased = false; + liftRef.current = null; + pressValueRef.current = null; + press.centers = readCenters(); + press.downTime = touch.time; + // PressTracker's control profile delays a finger's highlight inside a + // scroll view; the press reaches the stepper when it lights. + press.isDelayed = + touch.pointerType === 'touch' && + touch.scroll?.isInsideScroller === true; + // The half the press landed on; a press on the control itself (around + // or between its halves) takes the nearer half. + press.landedOn = halfOf(touch.target) ?? halfAt(press.centers, touch); + press.half = press.landedOn; + tracking.begin(press.half); + return; + } + const isLift = event === 'touchUpInside' || event === 'touchUpOutside'; + if (press.isReleased) { + // Let go of when the field turned disabled or read-only: nothing + // tracks, paints or commits until the next press. + liftRef.current = isLift ? {at: browserClock.now(), tap: null} : null; + return; + } + const isInside = + event === 'touchDragInside' || + event === 'touchDragEnter' || + event === 'touchUpInside'; + const half: StepperHalf | null = isInside ? halfUnder(touch) : null; + if (event === 'touchCancel') { + // No click follows a touch a scroll or the system took. + liftRef.current = null; + tracking.cancel(); + press.half = null; + discard(); + } else if (isLift) { + press.half = half; + // A tap steps on the click that follows (FR11): the lift only says + // which half. + liftRef.current = {at: browserClock.now(), tap: tracking.end(half)}; + commit(); + } else { + press.half = half; + tracking.move(half); + } + paint(trackerRef.current?.isHighlighted === true ? press.half : null); + }, + onHighlightChange: isHighlighted => { + const press = pressRef.current; + if (press.isReleased) { + return; + } + if (isHighlighted) { + tracking.deliver( + press.isDelayed + ? press.downTime + UIKIT_PRESS.contentTouchDelay.value + : press.downTime, + ); + } + paint(isHighlighted ? press.half : null); + }, + }; + + const takeClick = (click: MouseEvent): StepperClick => { + const lift = liftRef.current; + liftRef.current = null; + // A click a pointer sent. Where the engine dispatches clicks as pointer + // events it names the pointer: Chrome on Android names a tap's click + // 'touch' (detail 0), Safari on iOS names it 'mouse' (detail 1). Where + // clicks name none, detail > 0. A screen reader's activation, a key or a + // programmatic click() names no pointer ('') and has detail 0. + const clickPointer = + 'pointerType' in click && typeof click.pointerType === 'string' + ? click.pointerType + : undefined; + const isFromPointer = + clickPointer === undefined ? click.detail > 0 : clickPointer !== ''; + // A press's click follows its lift at once. One that comes later (after + // a hold, which the engine sends no click for) is a press of its own. + if ( + !isFromPointer || + lift == null || + browserClock.now() - lift.at > CLICK_GUARD_WINDOW_MS + ) { + return 'own'; + } + return lift.tap ?? 'none'; + }; + + // A press whose owner goes away mid-hold lets go at once, as a disabled + // field's does: the steppers unmounting (`hasNumberSteppers` turning + // false) detach the press tracker, which then sends no end, so nothing else + // would stop the repeat. + const hadSteppersRef = useRef(state.hasSteppers); + useIsomorphicLayoutEffect(() => { + if (state.hasSteppers !== hadSteppersRef.current && tracking.isTracking) { + const press = pressRef.current; + press.isReleased = true; + press.half = null; + liftRef.current = null; + tracking.cancel(); + paint(null); + discard(); + } + hadSteppersRef.current = state.hasSteppers; + }); + + // The hook attaches to the column whenever it is mounted (hasNumberSteppers + // can turn on after the field mounts, or off and on again), for every + // pointer (`spec:AST-094` FR43). + const tracker = usePressTracker(columnRef, options); + useIsomorphicLayoutEffect(() => { + trackerRef.current = tracker; + }); + + return {takeClick}; +} diff --git a/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..d53a9045b255b --- /dev/null +++ b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-focus.a11y.chromium.spec.ts @@ -0,0 +1,144 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Real-browser proof of where focus goes when a number stepper is pressed. + * + * A finger on a stepper steps the value and leaves focus where it was, as + * UIStepper (which never becomes first responder) does: focusing the field + * would bring up the on-screen keyboard on a phone, and so does an activation + * with no pointer there. A mouse click still moves focus to the field, so the + * arrow keys and the wheel keep stepping, and so does an activation with no + * pointer where the primary pointer is fine (a desktop screen reader's). Touches + * are real touch input (Chromium's touch emulation), not synthetic DOM events. + */ + +import {expect, test, type Page} from '@playwright/test'; +import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium'; +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 () => storybook?.close()); + +async function open(page: Page): Promise { + await page.goto( + `${storybook.origin}/iframe.html?id=core-numberinput--with-number-steppers&viewMode=story`, + ); + await holdMotionStill(page); + await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor(); +} + +async function center(page: Page, name: string) { + const box = await page.getByRole('button', {name}).boundingBox(); + if (box == null) { + throw new Error(`${name} has no box`); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +const focusedRole = async (page: Page) => + page.evaluate(() => document.activeElement?.getAttribute('role') ?? null); + +test.describe('on a touch screen', () => { + test.use({ + viewport: {width: 390, height: 844}, + hasTouch: true, + isMobile: true, + }); + + test('a tap on a stepper steps the value and leaves the field unfocused', async ({ + page, + }) => { + await open(page); + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + await expect(input).toHaveValue('2'); + + const increment = await center(page, 'Increment Quantity'); + await page.touchscreen.tap(increment.x, increment.y); + await expect(input).toHaveAttribute('aria-valuenow', '3'); + expect(await focusedRole(page)).not.toBe('spinbutton'); + + const decrement = await center(page, 'Decrement Quantity'); + await page.touchscreen.tap(decrement.x, decrement.y); + await page.touchscreen.tap(decrement.x, decrement.y); + await expect(input).toHaveAttribute('aria-valuenow', '1'); + expect(await focusedRole(page)).not.toBe('spinbutton'); + }); + + test('a tap while the field is being edited keeps it focused and steps from the draft', async ({ + page, + }) => { + await open(page); + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + const field = await input.boundingBox(); + if (field == null) { + throw new Error('the field has no box'); + } + await page.touchscreen.tap(field.x + 20, field.y + field.height / 2); + await expect(input).toBeFocused(); + await input.fill('7.5'); + + const increment = await center(page, 'Increment Quantity'); + await page.touchscreen.tap(increment.x, increment.y); + await expect(input).toHaveAttribute('aria-valuenow', '8'); + await expect(input).toBeFocused(); + }); + + test('an activation with no pointer steps the value and leaves the field unfocused', async ({ + page, + }) => { + await open(page); + // A phone: the primary pointer is coarse. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + await page + .getByRole('button', {name: 'Increment Quantity'}) + .evaluate(button => (button as HTMLElement).click()); + await expect(input).toHaveAttribute('aria-valuenow', '3'); + expect(await focusedRole(page)).not.toBe('spinbutton'); + }); +}); + +test.describe('with a mouse', () => { + test('a click on a stepper steps the value and focuses the field', async ({ + page, + }) => { + await open(page); + // A mouse: this context has no coarse pointer. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + await page.getByRole('button', {name: 'Increment Quantity'}).click(); + await expect(input).toHaveAttribute('aria-valuenow', '3'); + await expect(input).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await expect(input).toHaveAttribute('aria-valuenow', '4'); + }); + + test('an activation with no pointer steps the value and focuses the field', async ({ + page, + }) => { + await open(page); + // A desktop screen reader activates with a click of detail 0 and no + // pointer press; the field takes focus, so the new value is read. + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + await page + .getByRole('button', {name: 'Increment Quantity'}) + .evaluate(button => (button as HTMLElement).click()); + await expect(input).toHaveAttribute('aria-valuenow', '3'); + await expect(input).toBeFocused(); + }); +}); diff --git a/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..feb644050cdda --- /dev/null +++ b/packages/core/src/NumberInput/__tests__/NumberInput.stepper-press.a11y.chromium.spec.ts @@ -0,0 +1,257 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Real-browser proof that a finger held on a number stepper presses it. + * + * iOS Safari selects the nearest text after a finger rests about half a second + * on content that allows selection: on a stepper that is the field's label, + * with the edit menu over the field, and the tap that would have stepped is + * lost. `spec:AST-094` FR41 suppresses text-selection start and the callout + * for a stepper's hold-repeat press: the press tracker's selection lock turns + * them off while a finger is down and back on at the lift. The column takes + * `touch-action: manipulation`, which keeps panning and pinch-zoom the + * browser's: a scroll that starts on a stepper still scrolls the page and + * steps nothing. (Chromium's touch emulation does not select on a long press, + * so the selection itself is checked on the iOS simulator; this proves the + * paths a hold must not change, including a hold longer than a tap.) + */ + +import {expect, test, type Page} from '@playwright/test'; +import {holdMotionStill} from '@astryxdesign/a11y-spec/chromium'; +import {PEN_SKIP, webInputDriver} from '@astryxdesign/interaction-harness'; +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 () => storybook?.close()); + +test.use({viewport: {width: 390, height: 844}, hasTouch: true, isMobile: true}); + +async function open(page: Page): Promise { + await page.goto( + `${storybook.origin}/iframe.html?id=core-numberinput--with-number-steppers&viewMode=story`, + ); + await holdMotionStill(page); + await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor(); +} + +// The gesture host writes it inline for the column's kept press where the +// primary pointer is coarse (`spec:AST-094` FR18); the column declares none. +test('under a finger the stepper column takes touch-action manipulation, and the field stays pannable', async ({ + page, +}) => { + await open(page); + const column = page + .getByRole('button', {name: 'Increment Quantity'}) + .locator('..'); + await expect(column).toHaveCSS('touch-action', 'manipulation'); + await expect(page.getByRole('spinbutton', {name: 'Quantity'})).toHaveCSS( + 'touch-action', + 'auto', + ); +}); + +// `spec:AST-094` FR41: a stepper's hold-repeat press suppresses text-selection +// start and the callout for that press only, through the press tracker's +// selection lock. +test('a held finger refuses text selection on the stepper column for that press only', async ({ + page, +}) => { + await open(page); + const plus = page.getByRole('button', {name: 'Increment Quantity'}); + const column = plus.locator('..'); + // Away from iOS WebKit the lock cancels `selectstart` while a finger is + // down, and writes no style, so nothing repaints. + const refuses = async () => + column.evaluate(el => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + el.dispatchEvent(event); + return event.defaultPrevented; + }); + const style = async () => + column.evaluate(el => + (el as HTMLElement).style.getPropertyValue('user-select'), + ); + expect(await refuses()).toBe(false); + const box = await plus.boundingBox(); + if (box == null) { + throw new Error('the stepper has no box'); + } + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width / 2, y: box.y + box.height / 2}], + }); + await expect.poll(refuses).toBe(true); + expect(await style()).toBe(''); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await expect.poll(refuses).toBe(false); +}); + +test('under a fine pointer the stepper column takes touch-action manipulation too, so a finger on a touchscreen laptop presses as on a phone (spec:AST-094 FR18, FR43)', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + await open(page); + // A fine pointer: this context has no coarse pointer. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const column = page + .getByRole('button', {name: 'Increment Quantity'}) + .locator('..'); + await expect(column).toHaveCSS('touch-action', 'manipulation'); + await context.close(); +}); + +test('under a fine pointer a pen presses a stepper as a mouse does: a tap steps once and focuses the field, a hold repeats and commits once (spec:AST-094 FR43)', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + await open(page); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + // Trusted pen input through the engine, paced on the wall clock. + const driver = await webInputDriver(page); + test.skip(!driver.pens, PEN_SKIP); + const input = page.getByRole('spinbutton'); + const box = await page + .getByRole('button', {name: 'Increment Quantity'}) + .boundingBox(); + if (box == null) { + throw new Error('the increment half is not laid out'); + } + const at = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + const before = Number(await input.inputValue()); + // A tap steps once, on its click, and focuses the field where the primary + // pointer is fine (`component:NumberInput` FR5). + await driver.pen([ + {...at, t: 0}, + {...at, t: 80}, + ]); + await expect(input).toHaveValue(String(before + 1)); + await expect(input).toBeFocused(); + // A hold repeats while the pen stays down and commits at the lift. + await driver.pen([ + {...at, t: 0}, + {...at, t: 1300}, + ]); + await expect + .poll(async () => Number(await input.inputValue())) + .toBeGreaterThan(before + 3); + await context.close(); +}); + +test('a vertical drag that starts on a stepper scrolls the page and steps nothing', async ({ + page, +}) => { + await open(page); + await page.evaluate(() => { + document.body.style.minHeight = '3000px'; + }); + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + const box = await page + .getByRole('button', {name: 'Increment Quantity'}) + .boundingBox(); + if (box == null) { + throw new Error('the stepper has no box'); + } + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x, y}], + }); + for (let step = 1; step <= 12; step += 1) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{x, y: y - 8 * step}], + }); + } + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await expect + .poll(async () => page.evaluate(() => scrollY)) + .toBeGreaterThan(0); + await expect(input).toHaveAttribute('aria-valuenow', '2'); +}); + +test('a tap on a stepper still steps the value', async ({page}) => { + await open(page); + const box = await page + .getByRole('button', {name: 'Increment Quantity'}) + .boundingBox(); + if (box == null) { + throw new Error('the stepper has no box'); + } + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + await expect( + page.getByRole('spinbutton', {name: 'Quantity'}), + ).toHaveAttribute('aria-valuenow', '3'); +}); + +/** Holds a finger on + for 1.6 s, longer than a tap; returns the value after. */ +async function holdPlus(page: Page): Promise { + const input = page.getByRole('spinbutton', {name: 'Quantity'}); + const box = await page + .getByRole('button', {name: 'Increment Quantity'}) + .boundingBox(); + if (box == null) { + throw new Error('the stepper has no box'); + } + const point = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [point], + }); + await page.waitForTimeout(1600); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + await page.waitForTimeout(300); + expect(await page.evaluate(() => String(getSelection()))).toBe(''); + return Number(await input.getAttribute('aria-valuenow')); +} + +test('a finger held 1.6 s selects nothing and keeps stepping', async ({ + page, +}) => { + // The steppers track the finger: a hold repeats from 0.5 s on one steady + // ramp and commits once, at the lift; the click after the lift adds + // nothing. + await open(page); + // From 2: steps at 500, 750, 975, 1177.5, 1359.75 and 1523.8 ms, and one + // more if the lift comes after 1671.4 ms. + const value = await holdPlus(page); + expect(value).toBeGreaterThanOrEqual(7); + expect(value).toBeLessThanOrEqual(9); +}); diff --git a/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts new file mode 100644 index 0000000000000..cd0eba952e257 --- /dev/null +++ b/packages/core/src/NumberInput/__tests__/NumberInputStepperHold.interaction.browser.spec.ts @@ -0,0 +1,934 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file NumberInputStepperHold.interaction.browser.spec.ts + * @input The built "With Number Steppers" NumberInput story, real touch input + * (Chromium CDP / WebKit's input command) and the interaction harness + * @output Interaction proofs for the steppers under a finger and a mouse + * (motion/uikit/stepper.ts): a tap steps once at the lift, paints only + * while the finger is down, and the engine's click after it adds nothing, + * a light tap trembling on the edge too; a hold shows a step at 0.5 s, then + * on one steady ramp, stops at the bound and commits once, at the release; + * a slide onto the other half carries the paint and the repeat; trembling + * on the edge adds no step; leaving the column pauses the repeat and coming + * back resumes it, never with a step at once; a scroll that starts on a + * stepper scrolls and steps nothing; a mouse holds like a finger; a step + * made late by a blocked main thread runs once, with no burst; storyboards + * of a tap, a hold, a slide, leaving and coming back, and a hold in a + * scrolling page + * @position NumberInput's `*.interaction.browser.spec.ts`, shaped like the + * harness exemplar (BottomSheetDetent). The paths run under a finger (the + * phone profile, as Android Chrome is); one runs with a mouse. Times are + * read in the page, on its own clock: the value the field shows, sampled + * every frame; the committed value (aria-valuenow, what assistive + * technology hears); and the press-paint mutations. + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + blockMainThread, + openStory, + Storyboard, + touchDriver, + withStorybook, + writeEvidence, + type ClipBox, + type Point, + type TouchDriver, + type TouchFrame, + type TouchScript, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +const STORY = 'core-numberinput--with-number-steppers'; +/** How far a step may land from its beat, ms (a timer's jitter plus a render). */ +const SLACK = 45; +/** UIStepper's first step of a hold, iOS 26.2, ms after touch-down. */ +const UIKIT_FIRST_STEP = 514; +/** + * A hold's steps, ms after the press is delivered (motion/uikit/stepper.ts): + * 500, then 250 ms, each interval 0.9 of the one before. + */ +const SCHEDULE = [ + 500, 750, 975, 1177.5, 1359.75, 1523.775, 1671.3975, 1804.25775, +]; + +interface Log { + /** The value the field shows, sampled every frame. */ + shown: {t: number; value: number}[]; + /** The committed value (aria-valuenow): what assistive technology hears. */ + commits: {t: number; value: number}[]; + paints: { + t: number; + half: 'increment' | 'decrement'; + from: string | null; + paint: string | null; + }[]; + touches: {t: number; type: string}[]; +} + +/** The page time of the log's first `type` touch event; fails if it has none. */ +function touchTime(log: Log, type: string): number { + const touch = log.touches.find(entry => entry.type === type); + if (touch == null) { + throw new Error(`the log has no ${type}`); + } + return touch.t; +} + +/** The last item of a list that must not be empty. */ +function last(items: ReadonlyArray): T { + const item = items.at(-1); + if (item === undefined) { + throw new Error('the list is empty'); + } + return item; +} + +interface Stepper { + driver: TouchDriver; + plus: Point; + minus: Point; + /** The column's top edge, CSS px. */ + top: number; + /** The column's bottom edge, CSS px. */ + bottom: number; + clip: ClipBox; +} + +/** + * Records the shown value every frame, every commit, press paint, touch and + * pointer press, in epoch ms on the page clock. + */ +function installLog(): void { + const now = () => performance.timeOrigin + performance.now(); + const log: Log = {shown: [], commits: [], paints: [], touches: []}; + (window as unknown as {stepperLog: Log}).stepperLog = log; + const input = document.querySelector('[role="spinbutton"]'); + const plus = document.querySelector('[aria-label^="Increment"]'); + const minus = document.querySelector('[aria-label^="Decrement"]'); + if (input == null || plus == null || minus == null) { + throw new Error('no stepper'); + } + if (!(input instanceof HTMLInputElement)) { + throw new Error('no input'); + } + let lastShown = input.value; + const sample = () => { + if (input.value !== lastShown) { + lastShown = input.value; + log.shown.push({t: now(), value: Number(input.value)}); + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + new MutationObserver(() => { + log.commits.push({ + t: now(), + value: Number(input.getAttribute('aria-valuenow')), + }); + }).observe(input, {attributes: true, attributeFilter: ['aria-valuenow']}); + const paints = new MutationObserver(records => { + for (const record of records) { + const target = record.target as Element; + log.paints.push({ + t: now(), + half: target === plus ? 'increment' : 'decrement', + from: record.oldValue, + paint: target.getAttribute('data-astryx-press'), + }); + } + }); + for (const half of [plus, minus]) { + paints.observe(half, { + attributes: true, + attributeOldValue: true, + attributeFilter: ['data-astryx-press'], + }); + } + for (const type of [ + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + 'pointerdown', + 'pointerup', + 'click', + ]) { + document.addEventListener(type, () => log.touches.push({t: now(), type}), { + capture: true, + passive: true, + }); + } +} + +const readLog = async (page: Page): Promise => + page.evaluate(() => (window as unknown as {stepperLog: Log}).stepperLog); + +/** Opens the story and reads where the steppers are. */ +async function openStepper(page: Page): Promise { + await openStory(page, storybook(), STORY); + await page.getByRole('spinbutton', {name: 'Quantity'}).waitFor(); + const driver = await touchDriver(page); + const box = async (name: string) => { + const rect = await page.getByRole('button', {name}).boundingBox(); + if (rect == null) { + throw new Error(`no ${name} button`); + } + return rect; + }; + const plusBox = await box('Increment Quantity'); + const minusBox = await box('Decrement Quantity'); + const input = await page + .getByRole('spinbutton', {name: 'Quantity'}) + .boundingBox(); + if (input == null) { + throw new Error('no field'); + } + await page.evaluate(installLog); + // The field with its label above it. + const label = await page.getByText('Quantity', {exact: true}).boundingBox(); + const left = Math.max(0, Math.floor(input.x - 12)); + const top = Math.max(0, Math.floor((label?.y ?? plusBox.y) - 8)); + return { + driver, + plus: {x: plusBox.x + plusBox.width / 2, y: plusBox.y + plusBox.height / 2}, + minus: { + x: minusBox.x + minusBox.width / 2, + y: minusBox.y + minusBox.height / 2, + }, + top: plusBox.y, + bottom: minusBox.y + minusBox.height, + clip: { + x: left, + y: top, + width: Math.ceil(plusBox.x + plusBox.width + 12) - left, + height: Math.ceil(minusBox.y + minusBox.height + 12) - top, + }, + }; +} + +/** + * Turns the page's own panning off. Until a press is delivered the steppers + * claim nothing, so a drag the page can pan is the browser's and cancels the + * press (the scroll test below); once delivered, the press keeps its touch + * (keepsTouch, the slide test on a scrolling page). With panning off the + * browser feeds every touch to the page, which a finger outside the column + * needs to be seen at all. + */ +const keepTouchInPage = async (page: Page) => + page.evaluate(() => { + document.documentElement.style.touchAction = 'none'; + }); + +/** Makes the page itself a scroll view, as a long form on a phone is. */ +const makePageScroll = async (page: Page) => + page.evaluate(() => { + document.body.style.minHeight = '3000px'; + }); + +/** A finger held still at `at` for `ms`. */ +const hold = (at: Point, ms: number): TouchScript => [ + {t: 0, fingers: [{id: 0, ...at}]}, + {t: ms, fingers: []}, +]; + +/** + * A tap steps on the browser's click after its lift (`spec:AST-094` FR11). + * These proofs script the touch (its hold, its tremble), and Playwright's + * WebKit (Linux build) synthesizes no click after a scripted touch on any + * element, a plain button included (measured against one); its own tap path + * does. So a scripted proof that needs the click runs in Chromium and on the + * iOS simulator. + */ +const WEBKIT_TAP_CLICK_SKIP = + "Playwright's WebKit synthesizes no click after a scripted touch on any element, a plain button included (measured), and a tap steps on its click (spec:AST-094 FR11); proven in Chromium and on the iOS simulator"; + +/** Moves from `from` to `to` between `t0` and `t1` at 60 Hz. */ +function moves(from: Point, to: Point, t0: number, t1: number): TouchFrame[] { + const count = Math.max(1, Math.round(((t1 - t0) / 1000) * 60)); + return Array.from({length: count}, (_, i) => { + const p = (i + 1) / count; + return { + t: t0 + (t1 - t0) * p, + fingers: [ + { + id: 0, + x: from.x + (to.x - from.x) * p, + y: from.y + (to.y - from.y) * p, + }, + ], + }; + }); +} + +/** The first press's down time: its touchstart, or a mouse's pointerdown. */ +function downTime(log: Log): number { + const down = + log.touches.find(touch => touch.type === 'touchstart') ?? + log.touches.find(touch => touch.type === 'pointerdown'); + if (down == null) { + throw new Error('no press logged'); + } + return down.t; +} + +/** Shown step times and values, relative to the first press. */ +function stepsOf(log: Log): {t: number; value: number}[] { + const down = downTime(log); + return log.shown.map(({t, value}) => ({t: t - down, value})); +} + +/** The commits: exactly one, at the release, with `value`. */ +function expectOneCommitAtRelease(log: Log, value: number): void { + expect(log.commits.map(commit => commit.value)).toEqual([value]); + const release = last( + log.touches.filter( + touch => touch.type === 'touchend' || touch.type === 'pointerup', + ), + ).t; + expect(log.commits[0].t).toBeGreaterThanOrEqual(release - 1); + expect(log.commits[0].t - release).toBeLessThan(SLACK); +} + +/** + * Checks each step against its own beat: the first `schedule[0]` after the + * press was delivered (its first paint, written when the hold is armed), and + * each later one its interval after the step before. The repeat arms every + * beat from the moment its step ran (stepperTracking.ts), so a beat that a + * loaded machine runs late moves the beats after it. Timed from touch-down, + * the input's dispatch delay and every earlier beat's lateness would count + * against each later step. + */ +function expectSchedule(log: Log, times: number[], schedule: number[]): void { + expect(times, JSON.stringify(times)).toHaveLength(schedule.length); + const down = downTime(log); + const delivered = (log.paints[0]?.t ?? down) - down; + schedule.forEach((at, i) => { + const due = + i === 0 ? delivered + at : times[i - 1] + (at - schedule[i - 1]); + expect( + Math.abs(times[i] - due), + `step ${i + 1} at ${times[i]}, due ${Math.round(due)}: ${JSON.stringify(times)}`, + ).toBeLessThanOrEqual(SLACK); + }); +} + +test("a tap steps once, on the browser's click after the lift, and paints only while the finger is down", async ({ + page, +}) => { + const {driver, plus} = await openStepper(page); + test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP); + await driver.play(hold(plus, 80)); + await page.waitForTimeout(700); + const log = await readLog(page); + const down = touchTime(log, 'touchstart'); + const up = touchTime(log, 'touchend'); + // One step, on the click the engine sends after the lift (`spec:AST-094` + // FR11), not at touch-down or at the lift itself. + expect(log.shown.map(v => v.value)).toEqual([3]); + expectOneCommitAtRelease(log, 3); + const click = touchTime(log, 'click'); + expect(click).toBeGreaterThanOrEqual(up); + expect(log.commits[0].t).toBeGreaterThanOrEqual(click - 1); + // The touched half lights in the touch-down frame and clears at the lift, + // with no fade (UIStepper's fill switches in one frame both ways). + expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([ + ['increment', 'on'], + ['increment', null], + ]); + expect(log.paints[0].t - down).toBeLessThan(20); + expect(log.paints[1].t - up).toBeLessThan(20); +}); + +test('a light tap trembling on the top edge steps exactly once', async ({ + page, +}) => { + const {driver, plus, top} = await openStepper(page); + test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP); + const at = (dy: number) => ({x: plus.x, y: top + dy}); + await driver.play([ + {t: 0, fingers: [{id: 0, ...at(1)}]}, + {t: 20, fingers: [{id: 0, ...at(-2)}]}, + {t: 40, fingers: [{id: 0, ...at(1)}]}, + {t: 60, fingers: [{id: 0, ...at(-3)}]}, + {t: 80, fingers: []}, + ]); + await page.waitForTimeout(700); + const log = await readLog(page); + expect(log.shown.map(v => v.value)).toEqual([3]); + expectOneCommitAtRelease(log, 3); +}); + +test('a hold shows a step at 0.5 s, then on one steady ramp, stops at the bound and commits once at the release', async ({ + page, +}, testInfo) => { + const {driver, plus} = await openStepper(page); + await driver.play(hold(plus, 2400)); + await page.waitForTimeout(600); + const log = await readLog(page); + const steps = stepsOf(log); + // The story counts 0 to 10 from 2: eight steps reach the maximum, and the + // finger held on past it steps no more. + expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6, 7, 8, 9, 10]); + const times = steps.map(s => Math.round(s.t)); + expectSchedule(log, times, SCHEDULE); + testInfo.annotations.push({ + type: 'uikit-parity', + description: `first step at ${times[0]} ms; UIStepper's at ${UIKIT_FIRST_STEP} ms`, + }); + await writeEvidence( + 'traces/numberinput-stepper-hold', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {steps: times, schedule: SCHEDULE, log}, + null, + 1, + ), + }, + testInfo, + ); + // Nothing was committed, or told to assistive technology, until the lift. + expectOneCommitAtRelease(log, 10); + // The lift after a repeat adds nothing, and the paint clears with it. + expect(log.paints.at(-1)).toMatchObject({half: 'increment', paint: null}); +}); + +test('a slide onto the other half carries the paint and the repeat', async ({ + page, +}) => { + const {driver, plus, minus} = await openStepper(page); + await keepTouchInPage(page); + await driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + ...moves(plus, minus, 800, 900), + {t: 1450, fingers: []}, + ]); + await page.waitForTimeout(600); + const log = await readLog(page); + const steps = stepsOf(log); + // + at 500 and 750 ms, then − on the same ramp: 975, 1177.5, 1359.75 ms. + expect(steps.map(s => s.value)).toEqual([3, 4, 3, 2, 1]); + expectSchedule( + log, + steps.map(s => Math.round(s.t)), + SCHEDULE.slice(0, 5), + ); + expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([ + ['increment', 'on'], + ['increment', null], + ['decrement', 'on'], + ['decrement', null], + ]); + expectOneCommitAtRelease(log, 1); +}); + +test('a finger trembling on the edge during a hold adds no step', async ({ + page, +}) => { + const {driver, plus, top} = await openStepper(page); + await keepTouchInPage(page); + // Across the column's top edge and back every frame from 800 to 1000 ms, + // within the press's tracking area. + const tremble = Array.from({length: 12}, (_, i) => ({ + t: 800 + i * 17, + fingers: [{id: 0, x: plus.x, y: top + (i % 2 === 0 ? -6 : 4)}], + })); + await driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + ...tremble, + {t: 1270, fingers: []}, + ]); + await page.waitForTimeout(600); + const log = await readLog(page); + const steps = stepsOf(log); + // The steady hold's steps, on the steady hold's times. + expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6]); + expectSchedule( + log, + steps.map(s => Math.round(s.t)), + SCHEDULE.slice(0, 4), + ); + expectOneCommitAtRelease(log, 6); +}); + +test('on a page that scrolls, a delivered press keeps its touch: a slide up onto + carries the repeat', async ({ + page, +}) => { + const {driver, plus, minus, top} = await openStepper(page); + test.skip( + !driver.drivesNativeGestures, + 'injected touches do not pan this engine natively; the hand-off is proven on the Android emulator', + ); + await makePageScroll(page); + const start = Number( + await page + .getByRole('spinbutton', {name: 'Quantity'}) + .getAttribute('aria-valuenow'), + ); + // A press on − delivered after the content-touch delay (150 ms in a scroll + // view), then slid up onto + (about 20 px, past the browser's slop, the way + // the page scrolls) before any long press: a pan the browser would take, + // which a tracking UIControl keeps. Held on + past its first step (+650 ms). + const onPlus = {x: plus.x, y: (plus.y + top) / 2}; + await driver.play([ + {t: 0, fingers: [{id: 0, ...minus}]}, + ...moves(minus, onPlus, 300, 400), + {t: 1300, fingers: []}, + ]); + await page.waitForTimeout(300); + // The page did not take the slide as a pan... + expect(await page.evaluate(() => scrollY)).toBe(0); + // ...and the repeat went on, on +. + const log = await readLog(page); + const steps = stepsOf(log); + expect(steps.length).toBeGreaterThan(0); + expect(steps.map(step => step.value)).toEqual( + steps.map((_, i) => start + 1 + i), + ); + expectOneCommitAtRelease(log, last(steps).value); +}); + +test('leaving the column pauses the repeat, and coming back resumes it with no step at once', async ({ + page, +}) => { + const {driver, plus, bottom} = await openStepper(page); + await keepTouchInPage(page); + // Below the column, past the press's 70 pt tracking area. + const below = {x: plus.x, y: bottom + 100}; + await driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + {t: 800, fingers: [{id: 0, ...below}]}, + {t: 2700, fingers: [{id: 0, ...plus}]}, + {t: 3000, fingers: []}, + ]); + await page.waitForTimeout(600); + const log = await readLog(page); + const steps = stepsOf(log); + const down = downTime(log); + const movesAt = log.touches + .filter(touch => touch.type === 'touchmove') + .map(touch => touch.t - down); + const out = movesAt.find(t => t > 700); + const back = movesAt.find(t => t > 2600); + if (out == null || back == null) { + throw new Error(`moves not logged: ${JSON.stringify(movesAt)}`); + } + expect(steps.map(s => s.value)).toEqual([3, 4, 5]); + expectSchedule( + log, + steps.slice(0, 2).map(s => Math.round(s.t)), + [500, 750], + ); + // Nothing while outside; back inside, the rest of the beat that was + // running when the finger left (armed 225 ms after the second step) runs + // first: no step at once. + expect(steps[2].t - back).toBeGreaterThan(40); + const beat = steps[1].t + (SCHEDULE[2] - SCHEDULE[1]); + expect(Math.abs(steps[2].t - (back + (beat - out)))).toBeLessThanOrEqual( + SLACK, + ); + // The paint goes off when the finger leaves and comes back with it. + expect(log.paints.map(({paint}) => paint)).toEqual(['on', null, 'on', null]); + expectOneCommitAtRelease(log, 5); +}); + +test('under a finger each half keeps its own size, with no touch-target growth', async ({ + page, +}) => { + await openStepper(page); + const hits = await page.evaluate(() => { + const column = document.querySelector( + '[aria-label^="Increment"]', + )?.parentElement; + if (column == null) { + throw new Error('no stepper column'); + } + const box = column.getBoundingClientRect(); + const midX = (box.left + box.right) / 2; + const midY = (box.top + box.bottom) / 2; + const on = (x: number, y: number) => { + const element = document.elementFromPoint(x, y); + return element != null && column.contains(element); + }; + return { + inside: on(midX, midY), + above: on(midX, box.top - 2), + below: on(midX, box.bottom + 2), + pastTheEnd: on(box.right + 2, midY), + reach: getComputedStyle(column, '::before').content, + }; + }); + expect(hits).toEqual({ + inside: true, + above: false, + below: false, + pastTheEnd: false, + reach: 'none', + }); +}); + +test('a scroll that starts on a stepper scrolls the page and steps nothing', async ({ + page, +}) => { + const {driver, plus} = await openStepper(page); + test.skip( + !driver.drivesNativeGestures, + 'injected touches do not scroll this engine natively; the scroll hand-off is proven on the iOS simulator', + ); + await makePageScroll(page); + await driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + ...moves(plus, {x: plus.x, y: plus.y - 160}, 30, 230), + {t: 260, fingers: []}, + ]); + await expect + .poll(async () => page.evaluate(() => scrollY)) + .toBeGreaterThan(0); + await page.waitForTimeout(700); + const log = await readLog(page); + expect(log.shown).toEqual([]); + expect(log.commits).toEqual([]); + // The page is a scroll view, so the press waits for the content-touch + // delay; a finger that moves before it is a scroll and paints nothing. + expect(log.paints).toEqual([]); +}); + +test('inside a scrolling page, a held press reaches the stepper after the content-touch delay', async ({ + page, +}) => { + const {driver, plus} = await openStepper(page); + await makePageScroll(page); + await driver.play(hold(plus, 1000)); + await page.waitForTimeout(600); + const log = await readLog(page); + const down = touchTime(log, 'touchstart'); + // UIScrollView delivers a still finger after 150 ms; in one, UIStepper's + // first step came at +653 ms. The ramp then runs from the delivery. + expect(log.paints[0]).toMatchObject({half: 'increment', paint: 'on'}); + expect(Math.abs(log.paints[0].t - down - 150)).toBeLessThanOrEqual(SLACK); + expectSchedule( + log, + stepsOf(log).map(s => Math.round(s.t)), + [500, 750], + ); + expectOneCommitAtRelease(log, 4); +}); + +test('inside a scrolling page, a quick tap steps on its click and flashes the press', async ({ + page, +}) => { + const {driver, plus} = await openStepper(page); + test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP); + await makePageScroll(page); + await driver.play(hold(plus, 60)); + await page.waitForTimeout(600); + const log = await readLog(page); + const up = touchTime(log, 'touchend'); + expect(log.shown.map(v => v.value)).toEqual([3]); + expectOneCommitAtRelease(log, 3); + expect(log.commits[0].t).toBeGreaterThanOrEqual(touchTime(log, 'click') - 1); + // Quicker than the delay: the press shows at the lift for UIKit's 100 ms + // flash. + expect(log.paints.map(p => p.paint)).toEqual(['on', null]); + expect(log.paints[0].t - up).toBeLessThan(20); + expect(Math.abs(log.paints[1].t - log.paints[0].t - 100)).toBeLessThanOrEqual( + SLACK, + ); +}); + +test('with a mouse, a held press repeats like a finger and commits once, at the release', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 402, height: 714}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + const {plus} = await openStepper(page); + // A mouse: this context has no coarse pointer. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page.mouse.move(plus.x, plus.y); + await page.mouse.down(); + await page.waitForTimeout(1230); + await page.mouse.up(); + await page.waitForTimeout(400); + const log = await readLog(page); + const steps = stepsOf(log); + expect(steps.map(s => s.value)).toEqual([3, 4, 5, 6]); + expectSchedule( + log, + steps.map(s => Math.round(s.t)), + SCHEDULE.slice(0, 4), + ); + // One commit, at the release; the click after it adds nothing. + expectOneCommitAtRelease(log, 6); + expect(log.paints.map(({half, paint}) => [half, paint])).toEqual([ + ['increment', 'on'], + ['increment', null], + ]); + // A mouse press moves focus to the field, as a click on a spin button does. + await expect(page.getByRole('spinbutton', {name: 'Quantity'})).toBeFocused(); + await context.close(); +}); + +test('on a touch screen a mouse click steps once and a held mouse repeats: the press tracker is its one feeder', async ({ + page, +}) => { + const {plus} = await openStepper(page); + // A touch device with a mouse or trackpad: the primary pointer is coarse. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + const valueNow = async () => + page + .getByRole('spinbutton', {name: 'Quantity'}) + .getAttribute('aria-valuenow'); + await page.mouse.click(plus.x, plus.y); + await expect.poll(valueNow).toBe('3'); + await page.mouse.move(plus.x, plus.y); + await page.mouse.down(); + await page.waitForTimeout(1230); + await page.mouse.up(); + await expect.poll(valueNow).toBe('7'); +}); + +test('after a scroll takes the touch, a click with no pointer still steps', async ({ + page, +}) => { + // A screen reader's activation or a programmatic click() has detail 0 and + // no pointerdown. No click follows a touch a scroll took, so that press must + // leave nothing behind that would swallow the next activation. + const {driver, plus} = await openStepper(page); + test.skip( + !driver.drivesNativeGestures, + 'injected touches do not scroll this engine natively; the cancel path is covered in jsdom', + ); + await makePageScroll(page); + await driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + ...moves(plus, {x: plus.x, y: plus.y - 160}, 30, 230), + {t: 260, fingers: []}, + ]); + await expect + .poll(async () => page.evaluate(() => scrollY)) + .toBeGreaterThan(0); + await page.waitForTimeout(700); + expect((await readLog(page)).commits).toEqual([]); + await page.evaluate(() => { + const increment = document.querySelector('button[aria-label^="Increment"]'); + if (!(increment instanceof HTMLElement)) { + throw new Error('no increment button'); + } + increment.click(); + }); + await page.waitForTimeout(200); + expect((await readLog(page)).commits.map(({value}) => value)).toEqual([3]); +}); + +test('a step made late by a blocked main thread runs once, and the cadence resumes from it', async ({ + page, +}, testInfo) => { + const {driver, plus} = await openStepper(page); + const playing = driver.play(hold(plus, 2800)); + await expect + .poll(async () => (await readLog(page)).shown.length, {intervals: [20]}) + .toBe(2); + // Hold the main thread past the 975 ms beat. + const block = await blockMainThread(page, 700); + await playing; + await page.waitForTimeout(600); + const log = await readLog(page); + const after = log.shown.filter(v => v.t > block.from); + await writeEvidence( + 'busy-main-thread/numberinput-stepper-hold', + { + [`${testInfo.project.name}.json`]: JSON.stringify({block, log}, null, 1), + }, + testInfo, + ); + // The beat the block swallowed runs once, right after it; the next one is a + // full interval later (202.5 ms after a third step: no catch-up burst). + expect(after.length).toBeGreaterThanOrEqual(2); + expect(after[0].t - block.to).toBeLessThan(SLACK); + expect(after[1].t - after[0].t).toBeGreaterThan(202.5 - SLACK); + expect(after[1].t - after[0].t).toBeLessThan(202.5 + SLACK); +}); + +test( + 'storyboard: a tap, a hold, a slide, leaving and coming back, and a hold in a scrolling page', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + // The value the field shows (a press's value while it lasts). + const value = async () => + Number( + await page.getByRole('spinbutton', {name: 'Quantity'}).inputValue(), + ); + const paint = async () => + page.evaluate(() => + Array.from(document.querySelectorAll('[aria-label$="Quantity"]')) + .filter(el => el.tagName === 'BUTTON') + .map(el => el.getAttribute('data-astryx-press') ?? '-') + .join(' '), + ); + const board = (name: string, clip: ClipBox) => + new Storyboard(page, {name, clip, columns: 6, cellScale: 1}); + const shoot = async (b: Storyboard, label: string, t: number) => + b.capture(label, t, {value: await value(), paint: await paint()}); + /** Starts one take's clock on the page: the moment of its first touch. */ + const armTouchClock = async () => + page.evaluate(() => { + const take = window as unknown as {takeDown?: number | null}; + take.takeDown = null; + document.addEventListener( + 'touchstart', + () => { + take.takeDown ??= performance.now(); + }, + {capture: true, passive: true}, + ); + }); + /** + * Waits on the page's own clock until `at` ms after the take's first + * touch, so the host's load cannot move a moment onto the next step. + */ + const untilPage = async (at: number) => + page.evaluate( + async after => + new Promise(resolve => { + const take = window as unknown as {takeDown?: number | null}; + const tick = () => { + if ( + take.takeDown != null && + performance.now() - take.takeDown >= after + ) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + tick(); + }), + at, + ); + /** Plays `script` and photographs the field at each of `moments` (ms). */ + async function filmed( + b: Storyboard, + driver: TouchDriver, + script: TouchScript, + moments: [number, string][], + ) { + await shoot(b, 'before', -1); + await armTouchClock(); + const playing = driver.play(script); + for (const [at, label] of moments) { + await untilPage(at); + await shoot(b, label, at); + } + await playing; + await page.waitForTimeout(150); + await shoot(b, 'lifted', last(script).t + 150); + } + + // Each board starts from the story as it loads: 2, counting 0 to 10. The + // moments sit between the hold's steps (500, 750, 975, 1177.5, 1359.75, + // 1523.8 … ms). + let stepper = await openStepper(page); + const tap = board('numberinput-stepper-tap', stepper.clip); + await filmed(tap, stepper.driver, hold(stepper.plus, 250), [ + [100, 'finger down on +'], + ]); + stepper = await openStepper(page); + const held = board('numberinput-stepper-hold', stepper.clip); + await filmed(held, stepper.driver, hold(stepper.plus, 2400), [ + [200, 'held, before 0.5 s'], + [620, 'first step at 0.5 s'], + [860, 'the next 250 ms later'], + [1440, 'the ramp quickens'], + [2200, 'stopped at the maximum'], + ]); + stepper = await openStepper(page); + await keepTouchInPage(page); + const slide = board('numberinput-stepper-slide', stepper.clip); + await filmed( + slide, + stepper.driver, + [ + {t: 0, fingers: [{id: 0, ...stepper.plus}]}, + ...moves(stepper.plus, stepper.minus, 780, 840), + {t: 1250, fingers: []}, + ], + [ + [620, 'held on +'], + [900, 'slid onto −'], + [1080, '− steps on the beat'], + ], + ); + stepper = await openStepper(page); + await keepTouchInPage(page); + const below = {x: stepper.plus.x, y: stepper.bottom + 100}; + const leave = board('numberinput-stepper-leave', stepper.clip); + await filmed( + leave, + stepper.driver, + [ + {t: 0, fingers: [{id: 0, ...stepper.plus}]}, + {t: 760, fingers: [{id: 0, ...below}]}, + {t: 2000, fingers: [{id: 0, ...stepper.plus}]}, + {t: 2360, fingers: []}, + ], + [ + [620, 'held on +'], + [1200, 'finger off the stepper'], + [1900, 'nothing steps outside'], + [2080, 'back on +: no step at once'], + [2300, 'the paused beat resumes'], + ], + ); + stepper = await openStepper(page); + await makePageScroll(page); + const scroller = board('numberinput-stepper-scroller-hold', stepper.clip); + await filmed(scroller, stepper.driver, hold(stepper.plus, 1000), [ + [60, 'held, before the delay'], + [300, 'reached at 150 ms'], + [780, 'first step at 0.65 s'], + ]); + for (const b of [tap, held, slide, leave, scroller]) { + await b.write(testInfo); + } + // The traces are the assertion; the sheets are the evidence. + const values = (b: Storyboard) => b.frames.map(f => f.data.value as number); + const paints = (b: Storyboard) => b.frames.map(f => f.data.paint as string); + // A tap steps on the browser's click (FR11): WebKit sends none after a + // scripted touch sequence, so there it steps nothing. + expect(values(tap)).toEqual( + stepper.driver.engine === 'webkit' ? [2, 2, 2] : [2, 2, 3], + ); + expect(paints(tap)).toEqual(['- -', 'on -', '- -']); + expect(values(held)).toEqual([2, 2, 3, 4, 7, 10, 10]); + expect(paints(held).slice(1, 6)).toEqual(Array(5).fill('on -')); + expect(paints(held).at(-1)).toBe('- -'); + // + steps at 500 and 750 ms, the finger slides at 0.8 s, − steps at 975 + // and 1177.5 ms. + expect(values(slide)).toEqual([2, 3, 4, 3, 2]); + expect(paints(slide)).toEqual(['- -', 'on -', '- on', '- on', '- -']); + // Out at 760 ms with 215 ms of the 975 ms beat left; back at 2 s, so the + // next step comes at 2215 ms. + expect(values(leave)).toEqual([2, 3, 4, 4, 4, 5, 5]); + expect(paints(leave)).toEqual([ + '- -', + 'on -', + '- -', + '- -', + 'on -', + 'on -', + '- -', + ]); + expect(values(scroller)).toEqual([2, 2, 2, 3, 4]); + expect(paints(scroller)).toEqual(['- -', '- -', 'on -', 'on -', '- -']); + }, +); diff --git a/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts b/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts new file mode 100644 index 0000000000000..f45af236eef7b --- /dev/null +++ b/packages/core/src/NumberInput/__tests__/NumberInputStepperLook.interaction.browser.spec.ts @@ -0,0 +1,736 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file NumberInputStepperLook.interaction.browser.spec.ts + * @input The built "With Number Steppers" NumberInput story on a phone-sized + * touch screen (a coarse pointer, as on iOS and Android) and with a mouse, + * real touch input and the interaction harness + * @output Proofs for the steppers' touch presentation, against UIStepper's + * measured shape (motion/uikit/stepper.ts `look`): one 94 px wide capsule + * beside the field at its inline end, at the field size's element height + * (md: UIKit's 32), − at the inline start and + at the inline end + * (mirrored in a right-to-left page), a divider 4 px from the top and the + * bottom, 13pt glyphs with 1.67pt strokes; at the maximum the + + * glyph takes the divider colour and the + half does nothing; the touched + * half darkens in the touch-down frame and only that half; taps and a + * slide across the divider step the right half; with a mouse the column + * stays in the field; storyboards of the control + * @position NumberInput's touch-presentation proofs, beside + * NumberInputStepperHold.interaction.browser.spec.ts (the press timing) + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + frames, + Storyboard, + touchDriver, + withStorybook, + type Point, + type TouchFrame, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); +const STORY = 'core-numberinput--with-number-steppers'; +const STATUS_STORY = 'core-numberinput--error-status'; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +interface Layout { + field: Box; + control: Box; + minus: Box; + plus: Box; + divider: {width: string; height: string; top: string; left: string}; + glyphs: {w: number; h: number; stroke: string | null}[]; + chevrons: string[]; +} + +/** + * A tap steps on the browser's click after its lift (`spec:AST-094` FR11). + * The steppers cancel a touch's pointerdown, since Chrome on Android focuses + * a held stepper at its long press otherwise (measured), and WebKit's Linux + * build then sends no click after a tap, unlike iOS Safari and Chrome + * (measured; it clicks a plain button). That engine is outside + * `spec:AST-013`'s support, so a proof that needs the click runs in Chromium + * and on the iOS simulator. + */ +const WEBKIT_TAP_CLICK_SKIP = + "The steppers cancel a touch's pointerdown (Chrome on Android focuses a held stepper otherwise), and WebKit's Linux build then sends no click after a tap, unlike iOS Safari and Chrome (measured); outside spec:AST-013's support, so the tap's click is proven in Chromium and on the iOS simulator"; + +/** Opens the story: under a finger in the phone profile, or with a mouse in a desktop context. */ +async function open( + page: Page, + globals: Record = {}, + args: Record = {}, + {story = STORY}: {story?: string} = {}, +): Promise { + const params = new URLSearchParams({id: story, viewMode: 'story'}); + const join = (pairs: Record) => + Object.entries(pairs) + .map(([k, v]) => `${k}:${v}`) + .join(';'); + if (Object.keys(globals).length > 0) { + params.set('globals', join(globals)); + } + if (Object.keys(args).length > 0) { + params.set('args', join(args)); + } + await page.goto(`${storybook()}/iframe.html?${params.toString()}`); + await page.getByRole('spinbutton').first().waitFor(); + await frames(page, 2); +} + +/** + * Sets the open story's args through Storybook's preview channel: URL args + * keep only what the story's argTypes declare, and `width` is not one. + */ +async function setArgs( + page: Page, + story: string, + args: Record, +): Promise { + await page.evaluate( + ([id, json]) => { + const channel = ( + window as unknown as { + __STORYBOOK_ADDONS_CHANNEL__?: { + emit: (event: string, payload: unknown) => void; + }; + } + ).__STORYBOOK_ADDONS_CHANNEL__; + if (channel == null) { + throw new Error('no Storybook preview channel'); + } + channel.emit('updateStoryArgs', { + storyId: id, + updatedArgs: JSON.parse(json) as unknown, + }); + }, + [story, JSON.stringify(args)], + ); + await frames(page, 3); + await frames(page, 2); +} + +interface Boxes { + fieldBox: {left: number; right: number}; + inputBox: {left: number; right: number}; + input: {width: number}; + steppers: {left: number; right: number; position: string}; + status: {left: number; right: number} | null; +} + +/** The Field box, the input box, the input, the steppers and an attached status. */ +const readBoxes = async (page: Page): Promise => + page.evaluate(() => { + const edges = (el: Element) => { + const r = el.getBoundingClientRect(); + return {left: r.left, right: r.right}; + }; + const input = document.querySelector('[role="spinbutton"]'); + const plus = document.querySelector('[aria-label^="Increment"]'); + if (input == null || plus == null) { + throw new Error('no stepper field'); + } + const steppers = plus.parentElement as HTMLElement; + const inputBox = input.parentElement as HTMLElement; + let fieldBox: Element | null = inputBox; + while (fieldBox != null && fieldBox.querySelector('label') == null) { + fieldBox = fieldBox.parentElement; + } + if (fieldBox == null) { + throw new Error('no Field box'); + } + const status = (input.getAttribute('aria-describedby') ?? '') + .split(/\s+/) + .map(id => (id === '' ? null : document.getElementById(id))) + .find(el => el != null && el.closest('label') == null); + return { + fieldBox: edges(fieldBox), + inputBox: edges(inputBox), + input: {width: input.getBoundingClientRect().width}, + steppers: { + ...edges(steppers), + position: getComputedStyle(steppers).position, + }, + status: status == null ? null : edges(status), + }; + }); + +const readLayout = async (page: Page): Promise => + page.evaluate(() => { + const box = (el: Element) => { + const r = el.getBoundingClientRect(); + return {x: r.x, y: r.y, w: r.width, h: r.height}; + }; + const plus = document.querySelector('[aria-label^="Increment"]'); + const minus = document.querySelector('[aria-label^="Decrement"]'); + if (plus == null || minus == null) { + throw new Error('no steppers'); + } + const control = plus.parentElement as HTMLElement; + const field = control.parentElement as HTMLElement; + const after = getComputedStyle(control, '::after'); + return { + field: box(field), + control: box(control), + minus: box(minus), + plus: box(plus), + divider: { + width: after.width, + height: after.height, + top: after.top, + left: after.left, + }, + glyphs: [minus, plus].map(half => { + // Each half's glyph: a ::before box masked with UIKit's vector glyph. + const glyph = getComputedStyle(half, '::before'); + const drawn = glyph.content !== 'none' && glyph.content !== 'normal'; + const mask = + glyph.getPropertyValue('mask-image') || + glyph.getPropertyValue('-webkit-mask-image'); + const stroke = /stroke-width(?:=|%3D)(?:'|%27)([\d.]+)/.exec(mask); + return { + w: drawn ? Number.parseFloat(glyph.width) : 0, + h: drawn ? Number.parseFloat(glyph.height) : 0, + stroke: drawn ? (stroke?.[1] ?? null) : null, + }; + }), + chevrons: [minus, plus].map( + half => getComputedStyle(half.firstElementChild as Element).display, + ), + }; + }); + +const center = (b: Box): Point => ({x: b.x + b.w / 2, y: b.y + b.h / 2}); + +const pressed = async (page: Page) => + page.evaluate(() => + ['Decrement', 'Increment'].map(name => { + const half = document.querySelector(`[aria-label^="${name}"]`); + return half == null ? null : getComputedStyle(half).backgroundImage; + }), + ); + +/** Moves from `from` to `to` between `t0` and `t1` at 60 Hz. */ +function moves(from: Point, to: Point, t0: number, t1: number): TouchFrame[] { + const count = Math.max(1, Math.round(((t1 - t0) / 1000) * 60)); + return Array.from({length: count}, (_, i) => { + const p = (i + 1) / count; + return { + t: t0 + (t1 - t0) * p, + fingers: [ + { + id: 0, + x: from.x + (to.x - from.x) * p, + y: from.y + (to.y - from.y) * p, + }, + ], + }; + }); +} + +/** + * Turns the page's own panning off. Chromium's touch emulation takes even a + * sideways drag on a page that cannot pan sideways, cancelling the press; + * Mobile Safari keeps such a touch on the page (the simulator evidence). With + * panning off, the slide across the divider is seen in either engine. + */ +const keepTouchInPage = async (page: Page) => + page.evaluate(() => { + document.documentElement.style.touchAction = 'none'; + }); + +/** The committed value (aria-valuenow). */ +const valueOf = async (page: Page) => + Number( + await page + .getByRole('spinbutton', {name: 'Quantity'}) + .getAttribute('aria-valuenow'), + ); + +/** The value the field shows: a press's own while it lasts. */ +const shownOf = async (page: Page) => + Number(await page.getByRole('spinbutton', {name: 'Quantity'}).inputValue()); + +test('under a finger the steppers are one 94 × 32 capsule beside the field', async ({ + page, +}) => { + await open(page); + const layout = await readLayout(page); + expect(layout.control.w).toBeCloseTo(94, 0); + expect(layout.control.h).toBeCloseTo(32, 0); + // 8px past the field's inline end, centred on it. + expect(layout.control.x - (layout.field.x + layout.field.w)).toBeCloseTo( + 8, + 0, + ); + expect(layout.control.y + layout.control.h / 2).toBeCloseTo( + layout.field.y + layout.field.h / 2, + 0, + ); + // − on the left, + on the right, 47px each. + expect(layout.minus.x).toBeCloseTo(layout.control.x, 0); + expect(layout.plus.x).toBeCloseTo(layout.control.x + 47, 0); + expect(layout.minus.w).toBeCloseTo(47, 0); + expect(layout.plus.w).toBeCloseTo(47, 0); + expect(layout.divider).toEqual({ + width: '1px', + height: '24px', + top: '4px', + left: '46.5px', + }); + expect(layout.glyphs).toEqual([ + {w: 13, h: 13, stroke: '1.67'}, + {w: 13, h: 13, stroke: '1.67'}, + ]); + expect(layout.chevrons).toEqual(['none', 'none']); +}); + +test('in a right-to-left page the capsule sits at the inline end and mirrors: + at the inline end (left), − at the inline start', async ({ + page, +}) => { + await open(page, {direction: 'rtl'}); + const layout = await readLayout(page); + expect(layout.field.x - (layout.control.x + layout.control.w)).toBeCloseTo( + 8, + 0, + ); + expect(layout.plus.x).toBeLessThan(layout.minus.x); + expect(layout.plus.x).toBeCloseTo(layout.control.x, 0); +}); + +test('the touched half darkens in the touch-down frame, and only that half', async ({ + page, +}) => { + await open(page); + const layout = await readLayout(page); + const driver = await touchDriver(page); + const states: (string | null)[][] = [await pressed(page)]; + await driver.play( + [ + {t: 0, fingers: [{id: 0, ...center(layout.plus)}]}, + {t: 200, fingers: []}, + ], + { + onEvent: async step => { + await frames(page, 1); + states.push([step.type, ...(await pressed(page))]); + }, + }, + ); + const [rest, down, up] = states; + expect(rest).toEqual(['none', 'none']); + expect(down[0]).toBe('touchStart'); + expect(down[1]).toBe('none'); + expect(down[2]).not.toBe('none'); + expect(up).toEqual(['touchEnd', 'none', 'none']); +}); + +test('a tap on the left half steps down, on the right half up', async ({ + page, +}) => { + await open(page); + const layout = await readLayout(page); + const driver = await touchDriver(page); + test.skip(driver.engine === 'webkit', WEBKIT_TAP_CLICK_SKIP); + await driver.tap(center(layout.minus)); + await expect.poll(async () => valueOf(page)).toBe(1); + await driver.tap(center(layout.plus)); + await expect.poll(async () => valueOf(page)).toBe(2); +}); + +test('a slide across the divider carries the repeat to the other half', async ({ + page, +}) => { + await open(page); + await keepTouchInPage(page); + const layout = await readLayout(page); + const driver = await touchDriver(page); + const minus = center(layout.minus); + const plus = center(layout.plus); + const seen: number[] = []; + const watching = (async () => { + for (let i = 0; i < 40; i += 1) { + const value = await shownOf(page); + if (seen.at(-1) !== value) { + seen.push(value); + } + await page.waitForTimeout(40); + } + })(); + await driver.play([ + {t: 0, fingers: [{id: 0, ...minus}]}, + ...moves(minus, plus, 600, 700), + {t: 1100, fingers: []}, + ]); + await watching; + // − at 0.5 s (2 → 1), then + on the same ramp at 750 and 975 ms; one + // commit, at the lift. + expect(seen).toEqual([2, 1, 2, 3]); + expect(await valueOf(page)).toBe(3); +}); + +test('under a finger each half of the capsule keeps its own size, 47 × 32, with no touch-target growth', async ({ + page, +}) => { + await open(page); + const layout = await readLayout(page); + expect(layout.plus.w).toBeCloseTo(47, 0); + expect(layout.plus.h).toBeCloseTo(32, 0); + const hits = await page.evaluate( + ({control}) => { + const capsule = document.querySelector( + '[aria-label^="Increment"]', + )?.parentElement; + const at = (x: number, y: number) => { + const element = document.elementFromPoint(x, y); + return element != null && capsule?.contains(element) === true; + }; + const x = control.x + control.w * 0.75; + return { + inside: at(x, control.y + control.h / 2), + above: at(x, control.y - 2), + below: at(x, control.y + control.h + 2), + }; + }, + {control: layout.control}, + ); + expect(hits).toEqual({inside: true, above: false, below: false}); +}); + +for (const direction of ['ltr', 'rtl'] as const) { + test(`a pressed half keeps the capsule's rounded outer corners (${direction})`, async ({ + page, + }) => { + await open(page, {direction}); + const radii = await page.evaluate(() => { + const read = (selector: string) => { + const half = document.querySelector(selector); + if (half == null) { + throw new Error(`no ${selector}`); + } + const style = getComputedStyle(half); + return [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius, + ].join(' '); + }; + return { + minus: read('[aria-label^="Decrement"]'), + plus: read('[aria-label^="Increment"]'), + }; + }); + // Each half rounds its outer corners: − at the inline start, + at the + // inline end, so they swap sides in a right-to-left page (the browser + // clamps 9999px to half the height: a capsule). + const start = '9999px 0px 0px 9999px'; + const end = '0px 9999px 9999px 0px'; + expect(radii).toEqual( + direction === 'ltr' + ? {minus: start, plus: end} + : {minus: end, plus: start}, + ); + }); +} + +test("at each field size the capsule takes the size's element height (28, 32, 36), 94 wide, centred on the field", async ({ + page, +}) => { + // UIStepper is 32pt tall whatever surrounds it; the field keeps its own + // height (28, 32, 36). + const heights = {sm: 28, md: 32, lg: 36} as const; + for (const [size, height] of Object.entries(heights)) { + await open(page, {}, {size}); + const layout = await readLayout(page); + expect(layout.field.h, size).toBeCloseTo(height, 0); + expect(layout.control.w, size).toBeCloseTo(94, 0); + expect(layout.control.h, size).toBeCloseTo(height, 0); + expect(layout.control.y + layout.control.h / 2, size).toBeCloseTo( + layout.field.y + layout.field.h / 2, + 0, + ); + expect( + layout.control.x - (layout.field.x + layout.field.w), + size, + ).toBeCloseTo(8, 0); + } +}); + +test("under forced colors the glyphs, the divider and the capsule's edge stay visible", async ({ + page, +}) => { + // Windows High Contrast and the like: backgrounds turn Canvas. + await page.emulateMedia({forcedColors: 'active'}); + await open(page, {}, {value: '10'}); + const colors = await page.evaluate(() => { + const control = document.querySelector('[aria-label^="Increment"]') + ?.parentElement as HTMLElement; + const plus = control.querySelector('[aria-label^="Increment"]') as Element; + const minus = control.querySelector('[aria-label^="Decrement"]') as Element; + const probe = document.createElement('div'); + probe.style.backgroundColor = 'Canvas'; + document.body.append(probe); + const canvas = getComputedStyle(probe).backgroundColor; + probe.remove(); + return { + canvas, + minusGlyph: getComputedStyle(minus, '::before').backgroundColor, + plusGlyph: getComputedStyle(plus, '::before').backgroundColor, + divider: getComputedStyle(control, '::after').backgroundColor, + edge: getComputedStyle(control).outlineStyle, + }; + }); + expect(colors.minusGlyph).not.toBe(colors.canvas); + expect(colors.divider).not.toBe(colors.canvas); + // + is at its bound: GrayText, apart from the live glyph's CanvasText. + expect(colors.plusGlyph).not.toBe(colors.canvas); + expect(colors.plusGlyph).not.toBe(colors.minusGlyph); + expect(colors.edge).toBe('solid'); +}); + +test('at the maximum the + glyph takes the divider colour and the + half does nothing', async ({ + page, +}) => { + await open(page, {}, {value: '10'}); + const layout = await readLayout(page); + const colors = await page.evaluate(() => { + const control = document.querySelector('[aria-label^="Increment"]') + ?.parentElement as HTMLElement; + return { + plus: getComputedStyle( + control.querySelector('[aria-label^="Increment"]') as Element, + ).color, + minus: getComputedStyle( + control.querySelector('[aria-label^="Decrement"]') as Element, + ).color, + divider: getComputedStyle(control, '::after').backgroundColor, + }; + }); + expect(colors.plus).toBe(colors.divider); + expect(colors.minus).not.toBe(colors.divider); + const driver = await touchDriver(page); + await driver.tap(center(layout.plus)); + await page.waitForTimeout(300); + expect(await valueOf(page)).toBe(10); + expect(await pressed(page)).toEqual(['none', 'none']); +}); + +test('in a field too narrow for the control the steppers stay in its box', async ({ + page, +}) => { + // A quantity field sized to 96 px (the checkout-wizard template) cannot hold + // the control (94 px and a gap) beside a usable box: the column stays inside. + await open(page); + for (const width of [96, '96px']) { + await setArgs(page, STORY, {width}); + // In the box, with no positioning of its own (no touch-target growth). + await expect + .poll(async () => (await readBoxes(page)).steppers.position) + .toBe('static'); + const boxes = await readBoxes(page); + expect(boxes.input.width).toBeGreaterThanOrEqual(40); + expect(boxes.steppers.right).toBeLessThanOrEqual( + boxes.fieldBox.right + 0.5, + ); + } + // Wide enough again, the control is back beside the box. + await setArgs(page, STORY, {width: 240}); + await expect + .poll(async () => (await readBoxes(page)).steppers.position) + .toBe('absolute'); +}); + +test('an attached status ends where the input box ends, clear of the control', async ({ + page, +}) => { + await open(page, {}, {}, {story: STATUS_STORY}); + await setArgs(page, STATUS_STORY, {hasNumberSteppers: true}); + const boxes = await readBoxes(page); + expect(boxes.steppers.position).toBe('absolute'); + if (boxes.status == null) { + throw new Error('no attached status'); + } + expect(Math.abs(boxes.status.right - boxes.inputBox.right)).toBeLessThan(1); + expect(boxes.steppers.left).toBeGreaterThan(boxes.status.right); + expect(boxes.steppers.right).toBeLessThanOrEqual(boxes.fieldBox.right + 0.5); +}); + +test('with a mouse every base style survives the touch styles (computed)', async ({ + browser, +}) => { + // Without the touch key the touch styles must change nothing: each default is + // a value, never null, which would erase a style merged before it. + const context = await browser.newContext({ + viewport: {width: 402, height: 714}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + await open(page); + // A mouse: this context has no coarse pointer. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const computed = await page.evaluate(() => { + const plus = document.querySelector( + 'button[aria-label^="Increment"]', + ) as HTMLElement; + const column = plus.parentElement as HTMLElement; + const wrapper = column.parentElement as HTMLElement; + const icons = [plus, plus.nextElementSibling as HTMLElement].map(half => { + const svg = half.querySelector('svg'); + return getComputedStyle(svg?.parentElement as Element).display; + }); + const css = (el: Element, prop: string) => + getComputedStyle(el).getPropertyValue(prop); + return { + icons, + columnPosition: css(column, 'position'), + columnBackground: css(column, 'background-color'), + afterContent: getComputedStyle(column, '::after').content, + wrapperMargin: css(wrapper, 'margin-inline-end'), + }; + }); + expect(computed).toEqual({ + // Icon's own inline-flex, blockified as the button's flex item (an erased + // display would fall back to inline and compute as block). + icons: ['flex', 'flex'], + columnPosition: 'static', + columnBackground: 'rgba(0, 0, 0, 0)', + afterContent: 'none', + wrapperMargin: '0px', + }); + await context.close(); +}); + +test('with a mouse the column stays inside the field', async ({browser}) => { + const context = await browser.newContext({ + viewport: {width: 402, height: 714}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + await open(page); + // A mouse: this context has no coarse pointer. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const layout = await readLayout(page); + expect(layout.control.w).toBeCloseTo(16, 0); + expect(layout.control.x + layout.control.w).toBeCloseTo( + layout.field.x + layout.field.w - 1, + 0, + ); + expect(layout.plus.y).toBeLessThan(layout.minus.y); + expect(layout.glyphs.map(g => g.w)).toEqual([0, 0]); + await context.close(); +}); + +/** Starts one take's clock on the page: the moment of its first touch. */ +const armTouchClock = async (page: Page) => + page.evaluate(() => { + const take = window as unknown as {takeDown?: number | null}; + take.takeDown = null; + document.addEventListener( + 'touchstart', + () => { + take.takeDown ??= performance.now(); + }, + {capture: true, passive: true}, + ); + }); + +/** + * Waits on the page's own clock until `at` ms after the take's first touch, + * so the host's load cannot move a moment onto the next step. + */ +const untilPage = async (page: Page, at: number) => + page.evaluate( + async after => + new Promise(resolve => { + const take = window as unknown as {takeDown?: number | null}; + const tick = () => { + if ( + take.takeDown != null && + performance.now() - take.takeDown >= after + ) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + tick(); + }), + at, + ); + +test( + 'storyboard: the touch control at rest, pressed, held and slid', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + for (const mode of ['light', 'dark'] as const) { + await open(page, {colorMode: mode}); + await keepTouchInPage(page); + const layout = await readLayout(page); + const clip = { + x: Math.floor(layout.field.x - 8), + y: Math.floor(layout.field.y - 36), + width: Math.ceil( + layout.control.x + layout.control.w + 8 - (layout.field.x - 8), + ), + height: Math.ceil(layout.field.h + 44), + }; + const board = new Storyboard(page, { + name: `numberinput-stepper-control-${mode}`, + clip, + columns: 5, + cellScale: 1, + }); + const driver = await touchDriver(page); + const data = async () => ({ + value: await shownOf(page), + pressed: (await pressed(page)).map(image => + image === 'none' ? '-' : 'on', + ), + }); + await board.capture('rest', -1, await data()); + const minus = center(layout.minus); + const plus = center(layout.plus); + await armTouchClock(page); + const playing = driver.play([ + {t: 0, fingers: [{id: 0, ...plus}]}, + ...moves(plus, minus, 780, 840), + {t: 1250, fingers: []}, + ]); + for (const [at, label] of [ + [150, '+ pressed'], + [620, '+ steps at 0.5 s'], + [900, 'slid onto −'], + [1080, '− steps on the beat'], + ] as const) { + await untilPage(page, at); + await board.capture(label, at, await data()); + } + await playing; + await page.waitForTimeout(150); + await board.capture('lifted', 1400, await data()); + await board.write(testInfo); + const values = board.frames.map(f => Number(f.data.value)); + const paints = board.frames.map(f => + (f.data.pressed as string[]).join(' '), + ); + // + steps at 500 and 750 ms, the finger slides at 0.8 s, − steps at 975 + // and 1177.5 ms. + expect(values).toEqual([2, 2, 3, 4, 3, 2]); + expect(paints).toEqual(['- -', '- on', '- on', 'on -', 'on -', '- -']); + } + }, +); diff --git a/packages/core/src/NumberInput/stepperTracking.test.ts b/packages/core/src/NumberInput/stepperTracking.test.ts new file mode 100644 index 0000000000000..24d1ef2b00d6d --- /dev/null +++ b/packages/core/src/NumberInput/stepperTracking.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file stepperTracking.test.ts + * @input vitest, StepperTracking, and UIStepper's measured delay + * @output The stepper's tap and autorepeat rules on explicit times: taps step + * at the lift (a tap trembling on the edge too), the hold's one steady ramp, + * slides, leaving and coming back (a pause, never a step at once), edge + * tremble during a hold, bounds, a busy main thread, cancellation + * @position Unit tests for NumberInput/stepperTracking.ts (no DOM, no real + * time) + */ + +import {describe, expect, it} from 'vitest'; +import type {GestureClock} from '../gestures'; +import {stepper} from '../motion/uikit/stepper'; +import { + halfAt, + repeatInterval, + StepperTracking, + type StepperHalf, +} from './stepperTracking'; + +/** A clock whose timers fire only when the test moves time. */ +function manualClock() { + let now = 0; + let nextId = 1; + const timers = new Map void}>(); + const clock: GestureClock = { + now: () => now, + setTimeout: (run, ms) => { + const id = nextId++; + timers.set(id, {at: now + ms, run}); + return id; + }, + clearTimeout: id => { + timers.delete(id as number); + }, + }; + const nextDue = (upTo: number) => { + let due: [number, {at: number; run: () => void}] | null = null; + for (const entry of timers) { + if (entry[1].at <= upTo && (due == null || entry[1].at < due[1].at)) { + due = entry; + } + } + return due; + }; + return { + clock, + /** Moves time to `to`, running each timer at its own deadline. */ + advanceTo(to: number) { + for (let due = nextDue(to); due != null; due = nextDue(to)) { + timers.delete(due[0]); + now = due[1].at; + due[1].run(); + } + now = to; + }, + /** A blocked main thread until `to`: then every timer due runs once, late. */ + stallTo(to: number) { + now = to; + for (let due = nextDue(to); due != null; due = nextDue(to)) { + timers.delete(due[0]); + due[1].run(); + } + }, + get now() { + return now; + }, + }; +} + +function setup(canStep: (half: StepperHalf) => boolean = () => true) { + const time = manualClock(); + const steps: {half: StepperHalf; at: number}[] = []; + const tracking = new StepperTracking({ + clock: time.clock, + step: half => { + if (!canStep(half)) { + return false; + } + steps.push({half, at: time.now}); + return true; + }, + }); + /** A finger lands on `half` at time 0 and the press reaches the control at `deliverAt`. */ + const press = (half: StepperHalf, deliverAt = 0) => { + tracking.begin(half); + if (deliverAt === 0) { + tracking.deliver(0); + } else { + time.advanceTo(deliverAt); + tracking.deliver(deliverAt); + } + }; + return {time, steps, tracking, press}; +} + +const at = (steps: {at: number}[]) => steps.map(step => step.at); + +/** The times the ramp steps at, from the delivery: 500, then 250 ms shrinking by 0.9 to 50. */ +function rampTimes(count: number, from = 0): number[] { + const times = [from + stepper.autorepeat.delayMs]; + while (times.length < count) { + times.push(times.at(-1)! + repeatInterval(times.length)); + } + return times; +} + +const expectTimes = (actual: number[], expected: number[]) => { + expect(actual, JSON.stringify(actual)).toHaveLength(expected.length); + expected.forEach((time, i) => expect(actual[i]).toBeCloseTo(time, 6)); +}; + +describe('StepperTracking: taps', () => { + it('a tap steps nothing at the lift: end names the half its click steps', () => { + // A control activates by the browser's click on a lift inside + // (`spec:AST-094` FR11): the lift only says which half that click steps. + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(60); + expect(tracking.end('increment')).toBe('increment'); + expect(steps).toEqual([]); + time.advanceTo(3000); + expect(steps).toEqual([]); + }); + + it('a light tap trembling across the edge names its half, once', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + for (const t of [10, 20, 30, 40, 50]) { + time.advanceTo(t); + tracking.move(null); + time.advanceTo(t + 4); + tracking.move('increment'); + } + time.advanceTo(70); + expect(tracking.end('increment')).toBe('increment'); + time.advanceTo(3000); + expect(steps).toEqual([]); + }); + + it('a finger that slid from − onto + before any repeat names +', () => { + const {time, steps, tracking, press} = setup(); + press('decrement'); + time.advanceTo(200); + tracking.move('increment'); + time.advanceTo(350); + expect(tracking.end('increment')).toBe('increment'); + expect(steps).toEqual([]); + }); + + it('a lift outside the control names nothing and steps nothing', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(120); + tracking.move(null); + expect(tracking.end(null)).toBeNull(); + time.advanceTo(2000); + expect(steps).toEqual([]); + }); + + it('a tap quicker than a scroll view’s delay still names its half', () => { + const {time, steps, tracking} = setup(); + tracking.begin('increment'); + time.advanceTo(60); + expect(tracking.end('increment')).toBe('increment'); + expect(steps).toEqual([]); + }); + + it('a lift with no press names nothing', () => { + const {tracking} = setup(); + expect(tracking.end('increment')).toBeNull(); + }); +}); + +describe('StepperTracking: holding a half', () => { + it('steps at UIStepper’s 0.5 s, then on one steady ramp down to 50 ms', () => { + const {time, steps, press} = setup(); + press('increment'); + time.advanceTo(1200); + // 500, then 250, 225 and 202.5 ms apart. + expectTimes(at(steps), [500, 750, 975, 1177.5]); + time.advanceTo(4000); + expectTimes(at(steps), rampTimes(steps.length)); + expect(steps.every(step => step.half === 'increment')).toBe(true); + }); + + it('the ramp has no jump: each interval is 0.9 of the last until 50 ms, then stays', () => { + const {time, steps, press} = setup(); + press('increment'); + time.advanceTo(6000); + const gaps = at(steps) + .slice(1) + .map((t, i) => t - at(steps)[i]); + expect(gaps[0]).toBeCloseTo(250, 6); + for (let i = 1; i < gaps.length; i += 1) { + const expected = Math.max(50, gaps[i - 1] * 0.9); + expect(gaps[i], `gap ${i + 1}`).toBeCloseTo(expected, 6); + } + expect(Math.min(...gaps)).toBeCloseTo(50, 6); + // The ramp reaches its floor within three seconds of the press. + expect( + at(steps).find((t, i) => i > 0 && t - at(steps)[i - 1] === 50), + ).toBeLessThan(3000); + }); + + it('a lift after a repeat names nothing and adds no step', () => { + // Its click is no step either: the press stepped already. + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(600); + expect(tracking.end('increment')).toBeNull(); + time.advanceTo(3000); + expect(at(steps)).toEqual([500]); + }); + + it('inside a scroll view the delay counts from the delayed delivery', () => { + const {time, steps, press} = setup(); + // UIKit's first step in a scroll view: +653 ms. + press('increment', 150); + time.advanceTo(1000); + expect(at(steps)).toEqual([650, 900]); + }); + + it('nothing repeats before the press reaches the control', () => { + const {time, steps, tracking} = setup(); + tracking.begin('increment'); + time.advanceTo(2000); + expect(steps).toEqual([]); + }); + + it('repeatInterval is 250 ms, then 0.9 of the last, never under 50 ms', () => { + const intervals = [0, 1, 2, 3, 10, 16, 17, 80].map(repeatInterval); + expectTimes(intervals, [ + 250, + 250, + 225, + 202.5, + 250 * 0.9 ** 9, + 250 * 0.9 ** 15, + 50, + 50, + ]); + }); +}); + +describe('StepperTracking: moving the finger', () => { + it('a slide onto the other half keeps the running cadence', () => { + const {time, steps, tracking, press} = setup(); + press('decrement'); + time.advanceTo(800); + tracking.move('increment'); + time.advanceTo(1200); + expect(steps.map(step => step.half)).toEqual([ + 'decrement', + 'decrement', + 'increment', + 'increment', + ]); + expectTimes(at(steps), [500, 750, 975, 1177.5]); + }); + + it('leaving the control pauses the cadence', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(800); + tracking.move(null); + time.advanceTo(6000); + expect(at(steps)).toEqual([500, 750]); + }); + + it('coming back resumes the rest of the paused interval, never a step at once', () => { + const {time, steps, tracking, press} = setup(); + // Left at 800 with 175 ms of the 975 interval still to run. + press('increment'); + time.advanceTo(800); + tracking.move(null); + time.advanceTo(5000); + tracking.move('increment'); + expect(at(steps)).toEqual([500, 750]); + time.advanceTo(5400); + expectTimes(at(steps), [500, 750, 5175, 5377.5]); + }); + + it('trembling on the edge during a hold adds no step and does not speed the ramp', () => { + const steady = setup(); + steady.press('increment'); + steady.time.advanceTo(2000); + + const trembling = setup(); + trembling.press('increment'); + // Out for 2 ms at a time, ten times, between 800 and 900. + for (let t = 800; t < 900; t += 10) { + trembling.time.advanceTo(t); + trembling.tracking.move(null); + trembling.time.advanceTo(t + 2); + trembling.tracking.move('increment'); + } + trembling.time.advanceTo(2000 + 20); + // The same steps, each later by the 20 ms spent outside. + expectTimes( + at(trembling.steps), + at(steady.steps).map(t => (t < 800 ? t : t + 20)), + ); + }); + + it('coming back before the first repeat keeps the rest of the delay', () => { + const early = setup(); + early.press('increment'); + early.time.advanceTo(200); + early.tracking.move(null); + early.time.advanceTo(300); + early.tracking.move('increment'); + early.time.advanceTo(900); + expect(at(early.steps)).toEqual([600, 850]); + + const late = setup(); + late.press('increment'); + late.time.advanceTo(200); + late.tracking.move(null); + late.time.advanceTo(700); + late.tracking.move('increment'); + late.time.advanceTo(1300); + expect(at(late.steps)).toEqual([1000, 1250]); + }); + + it('a half at its bound does not step; the other half steps on the next beat', () => { + const time = manualClock(); + let value = 8; + const steps: {half: StepperHalf; at: number}[] = []; + const tracking = new StepperTracking({ + clock: time.clock, + step: half => { + const next = half === 'increment' ? value + 1 : value - 1; + if (next < 0 || next > 10) { + return false; + } + value = next; + steps.push({half, at: time.now}); + return true; + }, + }); + tracking.begin('increment'); + tracking.deliver(0); + time.advanceTo(1300); + expect(at(steps)).toEqual([500, 750]); + expect(value).toBe(10); + // The clock ran on at the bound (975, 1200, 1425 …): − steps on the next beat. + tracking.move('decrement'); + time.advanceTo(1500); + expect(steps.at(-1)).toEqual({half: 'decrement', at: 1425}); + expect(value).toBe(9); + }); +}); + +describe('StepperTracking: a busy main thread and cancellation', () => { + it('a step that runs late runs once, and the cadence resumes from it', () => { + const {time, steps, press} = setup(); + press('increment'); + time.advanceTo(1000); + // The main thread is blocked from 1000 to 1700: the 1177.5 step runs late, + // once, and the next one is a full interval after it. + time.stallTo(1700); + time.advanceTo(1900); + expectTimes(at(steps), [500, 750, 975, 1700, 1882.25]); + }); + + it('a cancel steps nothing and stops the clock', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(300); + tracking.cancel(); + time.advanceTo(2000); + expect(steps).toEqual([]); + expect(tracking.isTracking).toBe(false); + }); + + it('a cancel after a repeat stops the clock', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(800); + tracking.cancel(); + time.advanceTo(3000); + expect(at(steps)).toEqual([500, 750]); + }); + + it('a new press starts from nothing', () => { + const {time, steps, tracking, press} = setup(); + press('increment'); + time.advanceTo(3600); + tracking.end('increment'); + const before = steps.length; + const startedAt = time.now; + tracking.begin('increment'); + tracking.deliver(startedAt); + time.advanceTo(startedAt + 800); + expect(at(steps.slice(before))).toEqual([startedAt + 500, startedAt + 750]); + }); +}); + +describe('halfAt', () => { + it('splits a column at the middle', () => { + const column = {increment: {x: 8, y: 8}, decrement: {x: 8, y: 24}}; + expect(halfAt(column, {x: 8, y: 2})).toBe('increment'); + expect(halfAt(column, {x: 12, y: 15})).toBe('increment'); + expect(halfAt(column, {x: 4, y: 17})).toBe('decrement'); + }); + + it('splits the touch control at its divider: − on the left, + on the right', () => { + const control = {decrement: {x: 23.5, y: 16}, increment: {x: 70.5, y: 16}}; + expect(halfAt(control, {x: 1, y: 30})).toBe('decrement'); + expect(halfAt(control, {x: 46, y: 2})).toBe('decrement'); + expect(halfAt(control, {x: 48, y: 2})).toBe('increment'); + expect(halfAt(control, {x: 93, y: 16})).toBe('increment'); + }); + + it('answers the increment half before any layout was read', () => { + expect(halfAt(null, {x: 0, y: 0})).toBe('increment'); + }); +}); diff --git a/packages/core/src/NumberInput/stepperTracking.ts b/packages/core/src/NumberInput/stepperTracking.ts new file mode 100644 index 0000000000000..5108a90e01b3b --- /dev/null +++ b/packages/core/src/NumberInput/stepperTracking.ts @@ -0,0 +1,226 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file stepperTracking.ts + * @input The half of a number stepper under a pointer over time, and a clock + * @output Exports StepperTracking (the stepper's tap and autorepeat rules on + * explicit times), repeatInterval, halfAt (which half a point is on) and + * the StepperHalf type + * @position Pure half of NumberInput's stepper press: no DOM. The press layer + * (NumberInputTouch.tsx) feeds it from the gesture system's PressTracker, + * for every pointer; tested by stepperTracking.test.ts with a manual clock + * + * The rules (motion/uikit/stepper.ts): + * + * - A tap steps nothing at its lift: a control activates by the browser's + * click on a lift inside (`spec:AST-094` FR11). The lift names the half + * that click steps, the one under the pointer then: a pointer that slid + * from − onto + before anything repeated names +. A lift outside the + * control names nothing. + * - A held press steps `delayMs` after it reached the control (UIStepper's + * measured delay), then keeps stepping on one steady ramp: after + * `firstIntervalMs`, then each interval `acceleration` times the one + * before, never under `minIntervalMs`. Each interval is timed from the + * step before it, so a step that runs late (a busy main thread) runs once, + * never as a burst. A lift after a repeat names nothing, so its click adds + * nothing. + * - Sliding onto the other half keeps the running cadence; the next step + * lands on the new half. + * - Leaving the control pauses the cadence and coming back resumes it: the + * next step comes once the rest of the paused interval has passed, never + * at once, so a pointer trembling at the edge adds no step and does not + * speed the ramp. + * - A half at its bound does not step; the clock runs on, so sliding to the + * other half steps it on the next beat. + * + * SYNC: When modified, update stepperTracking.test.ts and the press layer + * in NumberInputTouch.tsx. + */ + +import {browserClock, type GestureClock} from '../gestures'; +import {stepper} from '../motion/uikit/stepper'; + +export type StepperHalf = 'increment' | 'decrement'; + +export interface StepperTrackingOptions { + /** + * Step the value toward `half`. Returns false when that half cannot step + * (its bound is reached, or the field is disabled or read-only). + */ + step: (half: StepperHalf) => boolean; + /** The clock timers run on: `performance.now()`, the base of `event.timeStamp`. */ + clock?: GestureClock; +} + +interface Point { + readonly x: number; + readonly y: number; +} + +/** + * The half a point is on: the one whose centre is nearer, so the same rule + * serves halves stacked in a column and halves side by side. The increment + * half wins a tie; with no centres read, the point is on it. + */ +export function halfAt( + centers: {readonly increment: Point; readonly decrement: Point} | null, + point: Point, +): StepperHalf { + if (centers == null) { + return 'increment'; + } + const distance = (center: Point) => + (center.x - point.x) ** 2 + (center.y - point.y) ** 2; + return distance(centers.decrement) < distance(centers.increment) + ? 'decrement' + : 'increment'; +} + +/** The interval before the next repeat, once `stepsMade` steps were made. */ +export function repeatInterval(stepsMade: number): number { + const {firstIntervalMs, acceleration, minIntervalMs} = stepper.autorepeat; + return Math.max( + minIntervalMs, + firstIntervalMs * acceleration ** Math.max(0, stepsMade - 1), + ); +} + +export class StepperTracking { + /** Whether a press is tracked. */ + isTracking = false; + /** Whether the press has reached the control (the repeat clock runs). */ + isDelivered = false; + /** The half under the pointer; null while it is outside the control. */ + half: StepperHalf | null = null; + /** Steps made by this press (taps and repeats). */ + steps = 0; + + private readonly options: StepperTrackingOptions; + private readonly clock: GestureClock; + /** When the armed repeat fires; null while none is armed. */ + private deadline: number | null = null; + /** What was left of the interval when the pointer left the control. */ + private remaining: number | null = null; + private timer: unknown = null; + + constructor(options: StepperTrackingOptions) { + this.options = options; + this.clock = options.clock ?? browserClock; + } + + /** A pointer landed on `half`. */ + begin(half: StepperHalf): void { + this.reset(); + this.isTracking = true; + this.half = half; + } + + /** + * The press reached the control at `time`: at touch-down outside a scroll + * view, after its content-touch delay inside one. Starts the repeat clock. + */ + deliver(time: number): void { + if (!this.isTracking || this.isDelivered) { + return; + } + this.isDelivered = true; + const deadline = time + stepper.autorepeat.delayMs; + if (this.half == null) { + this.remaining = Math.max(0, deadline - this.clock.now()); + } else { + this.arm(deadline); + } + } + + /** The pointer moved; `half` is null when it is outside the control. */ + move(half: StepperHalf | null): void { + if (!this.isTracking) { + return; + } + const wasOutside = this.half == null; + this.half = half; + if (!this.isDelivered || wasOutside === (half == null)) { + // A slide between the halves keeps the running cadence. + return; + } + if (half == null) { + // Leaving the control pauses the cadence. + this.remaining = + this.deadline == null + ? null + : Math.max(0, this.deadline - this.clock.now()); + this.disarm(); + } else { + // Coming back resumes it, with the rest of the paused interval. + this.arm( + this.clock.now() + (this.remaining ?? repeatInterval(this.steps)), + ); + this.remaining = null; + } + } + + /** + * The pointer lifted over `half` (null: outside the control). Steps + * nothing: returns the half the browser's click after a tap steps, or null + * after a repeat, for a lift outside, or with no press. + */ + end(half: StepperHalf | null): StepperHalf | null { + if (!this.isTracking) { + return null; + } + const isTap = this.steps === 0; + this.reset(); + return isTap ? half : null; + } + + /** The press was taken away (a scroll, the system): nothing more steps. */ + cancel(): void { + this.reset(); + } + + /** Drop the press and its timer (the control unmounted). */ + dispose(): void { + this.reset(); + } + + private repeat(): void { + const half = this.half; + if (half != null && this.options.step(half)) { + this.steps += 1; + } + // Timed from after this step's work, so a late step runs once. + this.arm(this.clock.now() + repeatInterval(this.steps)); + } + + private arm(deadline: number): void { + this.disarm(); + this.deadline = deadline; + this.timer = this.clock.setTimeout( + () => { + this.timer = null; + this.deadline = null; + if (this.isTracking && this.half != null) { + this.repeat(); + } + }, + Math.max(0, deadline - this.clock.now()), + ); + } + + private disarm(): void { + if (this.timer != null) { + this.clock.clearTimeout(this.timer); + this.timer = null; + } + this.deadline = null; + } + + private reset(): void { + this.disarm(); + this.isTracking = false; + this.isDelivered = false; + this.half = null; + this.steps = 0; + this.remaining = null; + } +} diff --git a/packages/core/src/RadioList/RadioList.doc.mjs b/packages/core/src/RadioList/RadioList.doc.mjs index adce60803934c..c4b912e086929 100644 --- a/packages/core/src/RadioList/RadioList.doc.mjs +++ b/packages/core/src/RadioList/RadioList.doc.mjs @@ -33,7 +33,7 @@ export const docs = { ], }, description: - 'Radio group container with field integration for label, description, and status.', + 'Radio group container with field integration for label, description, and status. Under a coarse pointer each option’s target reaches the touch target (`--size-touch-target`, 44 px) through an invisible hit area that stops at half the gap to each neighbour, so a middle option reaches 32 px at the default gap; rows keep their size and spacing. That hit area is the row’s `::after`: decorate a row with a child rather than its `::after`.', props: [ { name: 'label', diff --git a/packages/core/src/RadioList/RadioListItem.tsx b/packages/core/src/RadioList/RadioListItem.tsx index 683f7dbee9280..cdcadcf8b3025 100644 --- a/packages/core/src/RadioList/RadioListItem.tsx +++ b/packages/core/src/RadioList/RadioListItem.tsx @@ -4,7 +4,7 @@ /** * @file RadioListItem.tsx - * @input Uses React use, useId, useRef, RadioListContext, Item + * @input Uses React use, useId, useRef, RadioListContext, Item, useTouchHitArea * @output Exports RadioListItem component, RadioListItemProps * @position Core implementation; consumed by index.ts, tested by RadioList.test.tsx * @@ -28,9 +28,13 @@ import {mergeProps, isRenderable, rtlStyles} from '../utils'; import {indicatorScope} from '../Indicator/indicator.markers.stylex'; import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useMergedRefs} from '../hooks/useMergedRefs'; +import {useTouchHitArea} from '../utils/touchHitArea'; +import {touchHitAreaStyles} from '../utils/touchHitArea.stylex'; import {useIndicator} from '../Indicator'; import {Item, type ItemAlign} from '../Item'; import {themeProps} from '../utils/themeProps'; +import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; // The touch press's paint, declared by the shared overlay styles on the // element the controller writes to (`pressedAlpha`) at the press's strength, @@ -148,6 +152,10 @@ const rowStyles = stylex.create({ // strength still lands on the row, which is where the layer over the // circle reads it. backgroundImage: 'none', + // ...and the row's touch press layer (Item's `cell`): no box. + '::before': { + content: 'none', + }, }, // The mouse press (a finger's lives on `data-astryx-press`): the row is // pressed, but not through a nested link or button, which keep their own @@ -273,6 +281,14 @@ export function RadioListItem({ const labelID = useId(); const descriptionID = useId(); const isDisabled = context.isDisabled || isItemDisabled; + // Under a finger an option's target reaches the touch target through an + // invisible hit area that stops at half the gap to each neighbour, and the + // row keeps its size (spec:AST-094 FR42). A tap on the area lands on the + // row, which hands it to the radio. + const hasHitArea = !isDisabled; + const rowRef = useRef(null); + const mergedRowRef = useMergedRefs(ref, rowRef); + useTouchHitArea(rowRef, hasHitArea); // When the whole group is disabled with a disabledMessage, radios stay // focusable via aria-disabled (instead of native `disabled`) so the group's // reason tooltip is keyboard-discoverable. Per-item disabling is unaffected @@ -372,7 +388,7 @@ export function RadioListItem({ return ( 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/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts index 352f450af397d..86ab56ed6ea21 100644 --- a/packages/core/src/__tests__/pressState.ts +++ b/packages/core/src/__tests__/pressState.ts @@ -19,7 +19,8 @@ * The touch arms are asserted the same way: the paint reads the press's * strength (`--astryx-press-alpha`, see utils/interactionOverlay.stylex.ts) * and the element the controller writes to runs the release animation on the - * machine's own clock. + * machine's own clock; a list row's compositor layer (utils/rowPressLayer.ts) + * runs its own release on the row's `::before`, which these checks leave out. * * SYNC: When modified, update this header. */ @@ -179,7 +180,18 @@ export function declaresPressPaint(el: Element): boolean { /** `.2s`, `200ms`, `0.2s` → 200. */ export function durationToMs(value: string): number { - const match = value.trim().match(/^([\d.]+)(ms|s)$/); + const text = value.trim(); + // StyleX folds a constant cap into `calc(
    ', + ); + 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('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 `