diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..95611b3f4619c --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. The `{stylexOptions}` form now passes StyleX `treeshakeCompensation: true` by default (before, its babel pass ran with StyleX's default, `false`), as the plugin form already does; pass `treeshakeCompensation: false` in `stylexOptions` to restore the old behavior. + +@vjeux diff --git a/.changeset/click-guard.md b/.changeset/click-guard.md new file mode 100644 index 0000000000000..7376b26fef796 --- /dev/null +++ b/.changeset/click-guard.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted, swallowing it for 400ms or until the next press. Keyboard and assistive-technology clicks (`detail` 0) always pass. + +@vjeux diff --git a/.changeset/gesture-arena-moving-cancel.md b/.changeset/gesture-arena-moving-cancel.md new file mode 100644 index 0000000000000..5b7e52cad131a --- /dev/null +++ b/.changeset/gesture-arena-moving-cancel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a gesture that cancels itself on a finger's move (a long press dragged past its tolerance) keeps that finger claimed until it lifts, so the page never takes the drag over midway; a resting finger is still given back when its gesture is cancelled. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..1930e34496d7e --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-hook-fixes.md b/.changeset/gesture-hook-fixes.md new file mode 100644 index 0000000000000..87f08a0948e31 --- /dev/null +++ b/.changeset/gesture-hook-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture hooks call nothing back after unmount; `usePressTracker` brakes with the host by default and keeps one tracker, so an option change or a new ref never drops a press in progress; the recognizer's arena plumbing is `protected`. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-fixes.md b/.changeset/gesture-host-fixes.md new file mode 100644 index 0000000000000..2fc0915c974a6 --- /dev/null +++ b/.changeset/gesture-host-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture host fixes from review. A page scroll under a still finger keeps the press and its click; watchers and touches nothing can claim get passive listeners only; a claimed pen holds only its own stylus touches; an element's own `touch-action` is never widened; slotted content scrolls with its shadow-tree scroller. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-owner.md b/.changeset/gesture-host-owner.md new file mode 100644 index 0000000000000..9ff3bb2d7aded --- /dev/null +++ b/.changeset/gesture-host-owner.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host becomes the one document-level touch owner: the finger count, touch gates, and a kept press for continuous-tracking controls, whose element gets `touch-action: manipulation` where the pointer is coarse. A scroll lock's page hold and the finger-scroll watch keep their own listeners and ask the host only about the touches it owns. A second finger fails a one-finger pan that has not claimed, so the page can pinch. A scroll-edge pan holds a lone finger from the first move that pulls away from an edge every scroller under it is at, so those moves are cancelled before it begins; with a second finger anywhere on the screen the page keeps its pinch. Where the primary pointer is fine, the host attaches nothing and registers no listener, following the pointer when it changes; a recognizer that reads each event's pointer itself opts out with `perEventPointer`, and a press tracker with it paints a finger's press feedback only, with no control event and no mouse or pen press. A press tracker whose hold acts (a stepper's hold-repeat) can bring a selection lock for its presses, as a recognizer can. Internal, for the touch components that adopt them; not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..eb9f2fec42db2 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md index 12eb02fc42391..908da1b367575 100644 --- a/.changeset/gesture-pan.md +++ b/.changeset/gesture-pan.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-press-tracking.md b/.changeset/gesture-press-tracking.md new file mode 100644 index 0000000000000..7a6fb875d74c1 --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks. `paintPress` in `utils/pressFeedback.ts` writes `data-astryx-press` for every press it tracks; the action-group tracker writes its groups' presses itself. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-readme.md b/.changeset/gesture-readme.md new file mode 100644 index 0000000000000..9a6293b811b68 --- /dev/null +++ b/.changeset/gesture-readme.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's README for component authors, with its browser proof in Chromium and WebKit. + +@vjeux diff --git a/.changeset/gesture-recognizer-core.md b/.changeset/gesture-recognizer-core.md index 0b0bfc389235f..28caef0d5579c 100644 --- a/.changeset/gesture-recognizer-core.md +++ b/.changeset/gesture-recognizer-core.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-recognizer-fixes.md b/.changeset/gesture-recognizer-fixes.md new file mode 100644 index 0000000000000..9e6754a78d733 --- /dev/null +++ b/.changeset/gesture-recognizer-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture recognizer and arena fixes from review; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (`spec:AST-100`: UIKit's measured blend, a scrolling surface's release through the pan's `scrollVelocity()`, and FR2's release rules). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..ffeadde679ef5 --- /dev/null +++ b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: swipe, pinch, rotation, screen-edge pan and hover gesture recognizers. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-tap-long-press.md b/.changeset/gesture-tap-long-press.md index 9ef786da65842..f1efef8d29014 100644 --- a/.changeset/gesture-tap-long-press.md +++ b/.changeset/gesture-tap-long-press.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point; no component uses them yet. +[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-watcher.md b/.changeset/gesture-watcher.md new file mode 100644 index 0000000000000..bec7cd286e49d --- /dev/null +++ b/.changeset/gesture-watcher.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a recognizer created with `claimsTouches: false` is a watcher: passive listeners only, simultaneous with every recognizer, and it follows a touch the browser takes for a scroll to the lift. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/input-stream.md b/.changeset/input-stream.md new file mode 100644 index 0000000000000..d34b681ddf710 --- /dev/null +++ b/.changeset/input-stream.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The touch press feedback and the gesture recognizers now hear page-wide input through one shared stream on the window's capture phase, so an app's window-capture `stopPropagation`, or a document-capture `stopImmediatePropagation` registered before Core, no longer hides a touch from them. A recognizer that must claim a touch mid-gesture still needs the touch to reach its element. Each event type's listener is passive unless a subscriber cancels it (the gesture host's `touchend` and `contextmenu` while a claiming recognizer or press tracker is attached, the page hold's `touchmove` while it holds, and the click guard's `click` once it first arms), so a page of pressables registers no listener that can hold a scroll. + +@vjeux diff --git a/.changeset/long-press-one-source.md b/.changeset/long-press-one-source.md new file mode 100644 index 0000000000000..a73cf7b961b6d --- /dev/null +++ b/.changeset/long-press-one-source.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useLongPress` (ContextMenu's long press, and the held finger that opens a menu from its trigger) takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer. No behavior changes. + +@vjeux diff --git a/.changeset/menu-trigger-drift.md b/.changeset/menu-trigger-drift.md new file mode 100644 index 0000000000000..98ca1acc9096b --- /dev/null +++ b/.changeset/menu-trigger-drift.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A finger held on a DropdownMenu trigger may drift up to 13px on each axis and still open the menu under every primary pointer, a phone's, a tablet's and a touchscreen laptop's alike, as AST-094 FR29 allows; it cancelled past 10px. No menu, row or trigger reads the pointer query. + +@vjeux diff --git a/.changeset/presented-hit-testing.md b/.changeset/presented-hit-testing.md new file mode 100644 index 0000000000000..e27c857277aad --- /dev/null +++ b/.changeset/presented-hit-testing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] On iOS, a touch on an element the compositor animator is moving reaches what is drawn: the animator reports each moving element's offset from its layout box, and the gesture host sends the touch, press bounds and the tap's click there instead of to the layout position iOS WebKit hit-tests. + +@vjeux diff --git a/.changeset/reduced-motion-record.md b/.changeset/reduced-motion-record.md new file mode 100644 index 0000000000000..2ec44f4c1f9c8 --- /dev/null +++ b/.changeset/reduced-motion-record.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Reduce Motion for the touch presentations follows design:reduced-motion beyond the animator too. `reducedMotionFade()` gives code beside the animator the animator's own fade (an opacity-only 200 ms cross-fade on a coarse pointer or after a touch release, at once on any other pointer), `animator.reducedMotion()` reports which form a change takes for feedback that plays no fade of its own, and `reducedMotion.stylex.ts` holds the fade's constants for StyleX transitions. The fade's length is not a theme motion role. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock-gestures.md b/.changeset/touch-selection-lock-gestures.md new file mode 100644 index 0000000000000..d1f3ab7c2b7fc --- /dev/null +++ b/.changeset/touch-selection-lock-gestures.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host applies a recognizer's own `selectionLock` to that recognizer's contact and imports no lock itself; a long press brings one only when it has a hold (a `minimumPressDuration` above 0), and `selectionLock: null` opts out. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock.md b/.changeset/touch-selection-lock.md new file mode 100644 index 0000000000000..e5285afc1f8ad --- /dev/null +++ b/.changeset/touch-selection-lock.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Where the primary pointer is fine (a mouse, a trackpad, a touchscreen laptop, a desktop pen tablet), the press feedback controller no longer registers a touch listener: it reads the primary pointer at the first finger or pen press and keeps its `touchcancel` listener only while that pointer is coarse. + +@vjeux diff --git a/.changeset/uikit-constants-table.md b/.changeset/uikit-constants-table.md index 01fd41a182b0b..2c063cdb50bc7 100644 --- a/.changeset/uikit-constants-table.md +++ b/.changeset/uikit-constants-table.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point; no component reads it yet. +[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-animator-pseudo-element.md b/.changeset/uikit-motion-animator-pseudo-element.md new file mode 100644 index 0000000000000..7ccb10bab35da --- /dev/null +++ b/.changeset/uikit-motion-animator-pseudo-element.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator drives a pseudo-element such as a dialog's ::backdrop: a static filling animation holds its value after a curve ends and after `set`, a custom property on its host promotes it, and a Reduce Motion dip borrows and hands back the pseudo-element's own opacity. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md index d489e3022e6db..6b23ce1985186 100644 --- a/.changeset/uikit-motion-animator.md +++ b/.changeset/uikit-motion-animator.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet. +[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md index 7af3d73a92a98..cbb9399568857 100644 --- a/.changeset/uikit-motion-math.md +++ b/.changeset/uikit-motion-math.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..b234a4e987752 --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: useAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/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/.changeset/wheel-interaction-proofs.md b/.changeset/wheel-interaction-proofs.md new file mode 100644 index 0000000000000..b08e9308c9f8e --- /dev/null +++ b/.changeset/wheel-interaction-proofs.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: browser proofs for the touch picker wheel in Chromium and WebKit, which a finger gets on every engine. A flick coasts on UIKit's curve onto the row it projects and commits on arrival. A finger landing on a coasting wheel stops it, and it then eases onto the nearest row. A storyboard films a flick and its coast. In Chromium, the coast also keeps advancing every frame while the main thread is blocked, and does no Layout or Paint per frame. Under reduced motion, a flick still coasts as momentum with its rows flat, and a tap cross-fades onto its row, animating opacity alone for 200 ms or less. Opening the month and year wheels costs about as much for 200 years as for two, with the same rows in sight tipping. Under a mouse, a drag still scrolls today's column onto a row. + +@vjeux diff --git a/.changeset/wheel-listbox-hook.md b/.changeset/wheel-listbox-hook.md new file mode 100644 index 0000000000000..9d411c44ba09e --- /dev/null +++ b/.changeset/wheel-listbox-hook.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the Wheel's two presentations, today's scrolling column under a mouse and the picker wheel under a finger, share one listbox hook for their ids, ARIA attributes, Arrow, Page, Home and End keys, and row clicks. Each keeps its own moves, so no behavior changes: today's column still steps from the row under its band, and the picker wheel from the row it is heading to. + +@vjeux diff --git a/.changeset/wheel-mouse-drag-commit.md b/.changeset/wheel-mouse-drag-commit.md new file mode 100644 index 0000000000000..a16b25f506271 --- /dev/null +++ b/.changeset/wheel-mouse-drag-commit.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Dragging a picker wheel with a mouse now commits the value when the button is released, as a finger's drag does. Before, the wheel committed every row it passed while the button was still down, so in DateInput the calendar behind the month and year wheels changed under the hand. This applies to the wheels in the TimeInput, DateTimeInput and DateInput bottom sheets. Touch, keys and a click on a row are unchanged. + +@vjeux diff --git a/.changeset/wheel-nearest-valid.md b/.changeset/wheel-nearest-valid.md new file mode 100644 index 0000000000000..a64108b8b3b2c --- /dev/null +++ b/.changeset/wheel-nearest-valid.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A picker wheel that stops on a value outside the bounds now settles on the nearest value inside them. Before, it bounced back to the value it started from. This applies to the wheels in the TimeInput, DateTimeInput and DateInput bottom sheets. For example, with `min="09:00"`, dragging the hour wheel from 10 to 7 settles on 9, as the iOS time picker does. A stop on a value inside the bounds is unchanged. Opening a sheet on a value outside the bounds commits nothing. When a bound changes a wheel's value while the sheet is open, as when the meridiem wheel turns past the maximum, that wheel turns onto the new value instead of jumping there. Under reduced motion, the wheel's own scrolls (this travel, and the step from an arrow key or a click on a row) no longer glide: on a touch screen, or after a touch drag, they cross-fade, the rows landing while they are faded out; otherwise they land at once. + +@vjeux diff --git a/.changeset/wheel-player.md b/.changeset/wheel-player.md new file mode 100644 index 0000000000000..d9f457d83098e --- /dev/null +++ b/.changeset/wheel-player.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a player for the touch picker wheel bakes each motion into per-row Web Animations that run on the compositor, reads the offset on screen at any instant, stops a motion where it is, ticks as rows reach the band, and lands a wheel without travel for reduced motion (an opacity-only cross-fade). Nothing public changes: it is not exported from the package entry point. + +@vjeux diff --git a/.changeset/wheel-short-loop.md b/.changeset/wheel-short-loop.md new file mode 100644 index 0000000000000..2329d1b1bc162 --- /dev/null +++ b/.changeset/wheel-short-loop.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: under a finger, a picker wheel can loop (`isLooping`): its last row is followed by its first, as UIDatePicker's minutes and hours are, and a list with fewer rows than its column shows repeats down the whole column, as UIPickerView's looping table repeats around its cylinder. The columns that loop say so in their own changesets. A drag or a fling runs on through the seam, a key steps in its own direction across it, and a tap on a copy turns the wheel onto that copy, committing nothing when it holds the same value. A rest on a disabled row beside the seam turns the shorter way round onto the nearest enabled row, and a key never turns the long way round onto one. Each value is still listed once for assistive technology. + +@vjeux diff --git a/.changeset/wheel-uikit-picker-values.md b/.changeset/wheel-uikit-picker-values.md new file mode 100644 index 0000000000000..5d8378d0d8d71 --- /dev/null +++ b/.changeset/wheel-uikit-picker-values.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIPickerView's values measured on iOS 26.2 join the touch motion tables as internal constants: the wheel's geometry (row pitch, cylinder radius, perspective, the selection band, the band row's magnification), the coast's stop speed and velocity floor, the snap at rest, the tap spin and the selection tick. The pure math a wheel moves and draws with comes with them. It projects the row a release rests on as UIScrollView sums its deceleration, coasts with the rate re-solved to stop on that row, snaps, bounces at an end, rubber-bands, spins to a tapped row, and turns each row onto the cylinder. It is checked against UIKit's recorded frames. Nothing public changes: none of it is exported from the package entry point, and themes do not set these values. + +@vjeux diff --git a/.changeset/wheel-uikit-picker.md b/.changeset/wheel-uikit-picker.md new file mode 100644 index 0000000000000..de1da8a8a9a27 --- /dev/null +++ b/.changeset/wheel-uikit-picker.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a finger, on every touch device, the picker wheels in the TimeInput, DateTimeInput and DateInput bottom sheets now move like UIPickerView, and keep today's look: 28 px rows that tip and fade toward the edges of the column, with the row under the band in semibold accent text on its plate. A flick coasts at UIKit's deceleration onto the row UIKit's projection picks. A finger landing on a moving wheel stops it. A tap on a row turns it onto the band. Past the first or last row, a drag rubber-bands and the wheel bounces back. As on iOS, the value commits when the wheel comes to rest, and the coast keeps running while the page's main thread is busy. Arrow keys and clicks on a row still commit at once. Under reduced motion a flick still coasts, as scrolling does, with the rows fading rather than tipping; a tap, a key or a slow release cross-fades onto its row. `presentation="popover"` keeps the previous surface; no value keeps the native-scroll column under a finger. With a mouse the wheels keep today's column. + +@vjeux diff --git a/apps/storybook/stories/DateInput.stories.tsx b/apps/storybook/stories/DateInput.stories.tsx index bb4bf75d56585..2ff84391e8bba 100644 --- a/apps/storybook/stories/DateInput.stories.tsx +++ b/apps/storybook/stories/DateInput.stories.tsx @@ -832,3 +832,39 @@ export const StylingParts: Story = { ), }; + +/** + * The bottom sheet with two centuries of years: the year wheel has 200 rows. + * Under a finger, opening the month and year wheels costs about the same as + * for two years: each year is drawn once, and only the rows in sight tip. + */ +export const BottomSheetWideRange: Story = { + render: args => { + const [value, setValue] = useState( + '2026-03-15' as ISODateString, + ); + return ; + }, + args: { + label: 'Date of birth', + presentation: 'bottom-sheet', + min: '1900-01-01' as ISODateString, + max: '2099-12-31' as ISODateString, + }, +}; + +/** The same sheet with two years: the year wheel has 2 rows. */ +export const BottomSheetNarrowRange: Story = { + render: args => { + const [value, setValue] = useState( + '2026-03-15' as ISODateString, + ); + return ; + }, + args: { + label: 'Date of birth', + presentation: 'bottom-sheet', + min: '2026-01-01' as ISODateString, + max: '2027-12-31' as ISODateString, + }, +}; diff --git a/apps/storybook/stories/TimeInput.stories.tsx b/apps/storybook/stories/TimeInput.stories.tsx index eaa0b16fd3aa3..842e390fcffa1 100644 --- a/apps/storybook/stories/TimeInput.stories.tsx +++ b/apps/storybook/stories/TimeInput.stories.tsx @@ -224,6 +224,27 @@ export const WithMinMax: Story = { }, }; +/** + * The bottom-sheet wheels with bounds: hours outside 9 AM - 5 PM stay on the + * wheel, greyed out, and a wheel that stops on one goes on to the nearest + * hour inside the bounds. + */ +export const BottomSheetWithMinMax: Story = { + render: args => { + const [value, setValue] = useState( + '10:00' as ISOTimeString, + ); + return ; + }, + args: { + label: 'Appointment time', + presentation: 'bottom-sheet', + min: '09:00' as ISOTimeString, + max: '17:00' as ISOTimeString, + description: 'Business hours: 9 AM - 5 PM', + }, +}; + export const WithIncrement: Story = { render: args => { const [value, setValue] = useState( diff --git a/apps/storybook/stories/WheelProofs.stories.tsx b/apps/storybook/stories/WheelProofs.stories.tsx new file mode 100644 index 0000000000000..19d5521d2a044 --- /dev/null +++ b/apps/storybook/stories/WheelProofs.stories.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file WheelProofs.stories.tsx + * @input Uses React and the internal Wheel (packages/core/src/DateInput) + * @output The Wheel alone, for its browser proofs: a 60-row minute column at + * 00, its committed minute shown in a read-only field + * @position A checked-in reproduction path for + * packages/core/src/DateInput/__tests__/Wheel.interaction.browser.spec.ts. + * The proofs test the Wheel itself, so they open a column that stays a plain + * Wheel whatever rules the sheets that use it add (looping, bounds, a date + * that moves with the month). + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {useState} from 'react'; +import {Wheel} from '../../../packages/core/src/DateInput/Wheel'; + +const MINUTES = Array.from({length: 60}, (_, minute) => ({ + value: minute, + label: String(minute).padStart(2, '0'), +})); + +const meta = { + title: 'Internal/Wheel', + tags: ['no-visual', '!autodocs'], + parameters: {layout: 'centered'}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * A finger gets the touch column, a mouse today's column; either commits into + * the field above it. + */ +export const Minutes: Story = { + render: () => { + const [minute, setMinute] = useState(0); + return ( +
+ + +
+ ); + }, +}; diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 4c67256a261b7..27e054e9beb71 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -10,9 +10,10 @@ pixels per CSS px, touch, mobile viewport rules), in Chromium and WebKit. The harness proves its own instruments in [`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts). -The busy-main-thread proof has a negative control beside its positive check -(a rAF animation that freezes under a blocked main thread); other self-tests -check an instrument against a known answer. +The busy-main-thread proof and the rendering-trace counts have a negative +control beside their positive checks (a rAF animation that freezes under a +blocked main thread, a `top` animation that lays out every frame); other +self-tests check an instrument against a known answer. ## Running @@ -34,6 +35,12 @@ under a rootful docker daemon the container runs as your user so its node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] ``` +WebKit composites through Mesa's software GL there, which compiles each shader +the first time a page draws it (450–700 ms for the first frame of a blurred or +newly animated layer). The script keeps Mesa's shader cache on the host +(`~/.cache/astryx-webkit-shaders/`, or `ASTRYX_WEBKIT_SHADER_CACHE`), so +only the first run on a machine pays for them. + Evidence (storyboards, curve traces) is written to `test-results/interaction-evidence/` (override with `ASTRYX_INTERACTION_EVIDENCE`) and attached to the test report. It is never @@ -41,17 +48,18 @@ committed: a pull request links or attaches it. ## Instruments -| Module | What it gives a spec | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | -| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | -| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `frameStats`. | -| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | -| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | -| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | -| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event): PNG contact sheet + JSON trace. | -| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | -| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | +| Module | What it gives a spec | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | +| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | +| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `sampleRects` (per-rAF rects), `frameStats`. | +| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | +| `trace.ts` | `traceRendering(page, body)`: main-thread counts of UpdateLayoutTree / Layout / PrePaint / Paint / Layerize / UpdateLayer / ScrollLayer / HitTest, `countBetween`, long tasks; `installLongTaskLog` / `readLongTasks`; `startCompositorTrace`: when the compositor presents. | +| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | +| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | +| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event), `castStoryboard` (real screencast frames): PNG contact sheet + JSON trace. | +| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | +| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | ## The proofs @@ -65,6 +73,14 @@ dispatch latency, and the events go through the compositor's gesture detection the limits below. Read positions a frame after a move: Chromium delivers `pointermove` aligned to animation frames. +**Stories, not Storybook.** `openStory` turns Storybook's highlight module off +on every page it opens, and checks that its root element is absent. Left on, +the module observes `#storybook-root` and, on every insertion, reads the +computed style of every element on the page inside the inserting task (117 +reads and two forced style recalculations for one exit copy in a Button story), +so a proof would measure Storybook as well as the component. +`openStory(page, origin, id, args, {storybookHighlight: true})` keeps it on. + **Curve sampling.** Pause every running animation (CSS transitions and animations are Web Animations too), seek to a time, read `getComputedStyle`: the presented value at that exact time, independent of frame timing. Finished @@ -138,7 +154,8 @@ every frame, so one late frame is not "1 of 1 moved"). Pass `maxMedianFrameMs` (20 at 60 Hz) only in a proof that claims display rate. `verdict.failures` names each miss with its numbers, and `summary` carries the raw numbers for the report. Frames are judged in presentation order: a loaded -machine can deliver them out of order. +machine can deliver them out of order. `castStoryboard` turns the frames of the +block into a filmstrip. A just-started animation can reach the screen after the block began (50–100 ms later in WebKit on a loaded machine). When the cast saw the element at rest @@ -155,8 +172,11 @@ two steps per frame, which never fails it. Move a proof's element at least two steps a frame: the self-tests move 560 px in 1,600 ms, about 4.8 px a frame. On a loaded machine Chromium's screencast can skip frames the compositor -still presents. Pass when the compositor presented each frame (epoch ms on the -page clock, from a trace of `Display::FrameDisplayed`) as `presented`. A gap between two moved +still presents. `startCompositorTrace(page)`, started with the screencast and +stopped after the block, records when the compositor presented each frame +(`Display::FrameDisplayed`); pass it as `presented`. The record is +browser-wide (its frames carry no page identity), so it throws when another +page is open as it starts or stops, or opened while it ran. A gap between two moved frames with nothing delivered inside it is then measured between the compositor's own frames: the frames it presented there count as presented (`recorderSkipped`, in the summary), and a gap it did not present through is @@ -165,8 +185,41 @@ delivered frame that did not move, is judged from the screencast, so a frozen element fails whatever else the compositor draws (a rAF animation's block presents no compositor frame at all). With the record, the 90% counts the compositor's frames from the first to the last moved frame; the gaps before and -after are the stall check's. Without a record (WebKit has no such trace), the -screencast is judged alone. +after are the stall check's. WebKit has no such trace: +`stop()` resolves to `[]` and the screencast is judged alone. + +**No Layout / Paint.** `traceRendering` records a Chromium trace around the +interaction; `countBetween` counts the steady middle of an animation (its +first and last frames may commit). A transform/opacity animation does no +Layout or Paint there; a layout-property animation does one per frame. The +harness's own animation recorder stands down while the trace runs, since its +frame loop would make every frame a main-thread one, restyling each running +animation's target and laying out an anchored one: it reads only the element a +CSS animation or transition event names, once, when that animation starts, and +catches up afterwards. A `waitForRest` awaited inside the traced body stands +down too: it waits on timers for the scheduled ends of the element's curves +(looking `frames` times a frame apart before settling on none, so an +animation the action starts a frame or two later is waited for) and polls no +style until the trace ends (in the anchored fixture's steady middle, +24 Layouts before, 0 now). Its one read of the element's animations, as it is +called, restyles the element (2 Layouts and 2 style recalculations in the +anchored fixture when called 200 ms into its animation), so await it right +after the action that starts the motion, before the window a proof counts. So do `waitForAnimation` and a watch's +`started()`: they take the element's animations from the recorder's log, +judge a started one by its own times, and reach the element through a handle, +since each `locator.evaluate` makes a main-thread frame (there too, 24 +Layouts before, 0 now). That holds for script animations and CSS +transitions. On a CSS `@keyframes` animation (and, unwatched, a cancelled CSS +transition), `waitForAnimation` and `started()` read the animation's timing, +which flushes its style and adds Layouts to the trace: wait on one with +`waitForRest`, or after the trace. Taking a watch (`watchAnimations`) inside +a trace reads the watched element's animations, which restyles it (and lays +out an anchored one), so a proof takes its traced watch before the window it +counts. The trace's `longTasks` are the page's own: only a +task the harness marked as its own read (`waitForAnimation` describing +keyframes, `blockMainThread` without `during`) is in `devtoolsTasks` instead, +and only when no page script ran inside it. A test's own `page.evaluate` stays a +long task, whether it calls page code directly or sets it off. **Storyboards.** Deterministic: frames come from seeking held animations or from stepping scripted input, never from wall-clock capture. The JSON trace @@ -176,14 +229,14 @@ backstop, a cleanup render) would do so behind a held storyboard's back: `installTimerGate` before the animation starts and `freezeTimers` while photographing. The page can still cancel a held callback, a held frame callback gets the time it actually runs, and the harness's own waits (its frame waits, -`blockMainThread` with `during`) are not held. +`blockMainThread` with `during`, `sampleRects`) are not held. An animation that repeats forever does not set a storyboard's default length; with nothing else to film, pass `times`. A touch filmstrip keeps the scripted event `timeStamp`s (Chromium), but a handler that reads `performance.now()` or `Date.now()` sees one event per photograph, about 100 ms apart, so a clock-timed release flings slower when filmed than when played: a storyboard cannot show such a gesture; record it in real time instead, with -`startScreencast`. Chromium can return an empty copy of +`startScreencast` and `castStoryboard`. Chromium can return an empty copy of the page's surface right after it changes ("Unable to capture screenshot"); a storyboard takes that photograph again two frames later, up to three attempts in all, and lists each retake under `recaptures` in the trace. @@ -200,6 +253,8 @@ in all, and lists each retake under `recaptures` in the trace. | Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence | | Curve sampling, storyboards, input log | yes | yes | | Screencast frame evidence (busy main thread) | yes | yes (WebKit's own screencast; frames keep coming while the main thread is blocked) | +| Rendering trace, long tasks | yes | no (Playwright exposes neither) | +| Compositor presentation record | yes | no: `stop()` resolves to `[]`; the busy verdict judges the screencast alone | What WebKit cannot show here — native scroll hand-off, flings, a finger lifting while another stays down, finger tracking under a busy main thread, and diff --git a/internal/interaction-harness/scripts/webkit-container.mjs b/internal/interaction-harness/scripts/webkit-container.mjs index cb20fd44aa725..a077eae415ed5 100644 --- a/internal/interaction-harness/scripts/webkit-container.mjs +++ b/internal/interaction-harness/scripts/webkit-container.mjs @@ -16,10 +16,19 @@ * docker daemon the container runs as the invoking user (rootless podman and * rootless docker already map it), so the `test-results/` it writes into the * mounted checkout stay the host user's and the next host run can clear them. + * + * WebKit composites through Mesa's software GL in the image, which compiles + * each shader variant the first time a page draws it: 450–700 ms for the + * first frame of a blurred or newly animated layer, long enough for a short + * animation to end before it is ever presented. Mesa caches what it compiles, + * and the cache is kept on the host (`~/.cache/astryx-webkit-shaders/`, + * or `ASTRYX_WEBKIT_SHADER_CACHE`), so a run starts with every shader an + * earlier run compiled instead of paying for them in its first tests. */ import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import {createRequire} from 'node:module'; +import os from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -42,6 +51,21 @@ if (engine == null) { process.exit(1); } const args = process.argv.slice(2); +// Where the container sees the host's shader cache (see the header). +const containerShaderCache = '/astryx-shader-cache'; +// Resolved to an absolute path: a bare relative value would be mounted as a +// named volume rather than a host directory. An empty value means the default. +const shaderCache = process.env.ASTRYX_WEBKIT_SHADER_CACHE + ? path.resolve(process.env.ASTRYX_WEBKIT_SHADER_CACHE) + : path.join( + os.homedir(), + '.cache', + 'astryx-webkit-shaders', + image.replace(/[^\w.-]+/g, '_'), + ); +fs.mkdirSync(shaderCache, {recursive: true}); +// The harness self-test reads this marker inside the container. +fs.writeFileSync(path.join(shaderCache, 'ASTRYX-SHADER-CACHE'), `${image}\n`); const env = [ 'ASTRYX_STORYBOOK_DIR', 'ASTRYX_INTERACTION_EVIDENCE', @@ -79,6 +103,10 @@ const command = [ '--ipc=host', '-v', `${root}:${root}`, + '-v', + `${shaderCache}:${containerShaderCache}`, + '-e', + `MESA_SHADER_CACHE_DIR=${containerShaderCache}`, '-w', root, ...env, diff --git a/internal/interaction-harness/src/busy.skip.fixture.ts b/internal/interaction-harness/src/busy.skip.fixture.ts new file mode 100644 index 0000000000000..409886f12e6f8 --- /dev/null +++ b/internal/interaction-harness/src/busy.skip.fixture.ts @@ -0,0 +1,125 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file busy.skip.fixture.ts + * @input One recorded busy-main-thread run in headless Chromium under + * parallel load: a compositor coast through a 566 ms block + * @output `frames`: the screencast frames the marker was tracked in, as + * [ms after the block started, y in CSS px, or null where the marker was not + * found]; `presented`: when the compositor presented each frame + * (`Display::FrameDisplayed`), ms after the block started; `block`: the + * block's span on the same clock. The screencast skipped frames at about + * +366 and +466 ms while the compositor kept presenting every 16.7 ms. + * @position Fixture for busy.test.ts + */ + +export const block = {from: 0, to: 566.1}; + +export const frames: ReadonlyArray = [ + [-1145.8, null], + [-1114, null], + [-1063.9, null], + [-1031.1, null], + [-996.6, null], + [-964.3, null], + [-930.7, null], + [-896.7, null], + [-864, null], + [-830.6, null], + [-797.8, null], + [-762.7, null], + [-731.3, null], + [-686.5, null], + [-680.8, null], + [-651, null], + [-634.1, null], + [-620.7, null], + [-615.8, null], + [-514.4, null], + [-495, null], + [-472.4, null], + [-465.3, null], + [-448.5, null], + [-430.3, null], + [-414.8, null], + [-398.3, null], + [-379.5, null], + [-364.2, null], + [-348.3, null], + [-330.6, null], + [-315, null], + [-297.6, null], + [-281.9, null], + [-265.1, null], + [-247.6, null], + [-230.8, null], + [-215.3, null], + [-198.6, null], + [-181, null], + [-165.3, null], + [-147.7, null], + [-132, null], + [-115.4, null], + [-98.5, null], + [-81.2, 504], + [-65.2, 500], + [-48.5, 496], + [-30.9, 491], + [-15.2, 487], + [1.3, 484], + [32.8, 480], + [49.8, 476], + [66.3, 473], + [82.8, 469], + [99.7, 466], + [116.1, 463], + [132.9, 460], + [149.5, 457], + [166.3, 454], + [182.9, 451], + [199.4, 448], + [216.2, 446], + [232.8, 443], + [249.4, 441], + [266.1, 438], + [282.7, 436], + [299.5, 434], + [316, 432], + [332.7, 430], + [349.5, 428], + [366.1, 426], + [433.1, 418], + [449.7, 417], + [466.2, 415], + [516.2, 411], + [532.8, 409], + [549.5, 408], + [566.2, 407], + [650.6, 401], + [666.2, 400], + [682.9, 399], + [699.6, 398], + [716.3, 397], + [816.1, 391], + [832.8, 391], + [849.5, 390], + [866.2, 389], + [882.9, 388], + [899.5, 388], + [916.1, 387], + [1033.8, 383], + [1050.1, 383], + [1066.7, 382], + [1083.3, 382], + [1099.5, 381], + [1117.3, 381], + [1134.2, 380], + [1149.5, 380], +]; + +export const presented: readonly number[] = [ + -98.7, -81.3, -65.3, -48.7, -31.1, -15.3, 1.3, 32.5, 49.6, 66.1, 82.7, 99.6, + 115.9, 132.6, 149.3, 166.1, 182.6, 199.3, 216, 232.6, 249.3, 266, 282.6, + 299.2, 315.8, 332.6, 349.3, 366, 381.5, 398.3, 415.1, 432.9, 449.6, 466.1, + 481.5, 498.3, 516, 532.6, 549.4, 566.1, 581.7, 598.4, 615, 631.6, 650.3, 666, +]; diff --git a/internal/interaction-harness/src/busy.test.ts b/internal/interaction-harness/src/busy.test.ts index 372860125fe6a..44a2446ecca18 100644 --- a/internal/interaction-harness/src/busy.test.ts +++ b/internal/interaction-harness/src/busy.test.ts @@ -14,6 +14,7 @@ import {describe, expect, it} from 'vitest'; import {busyWindowProof} from './busy'; +import * as skip from './busy.skip.fixture'; import {inPresentationOrder, type TrackedFrame} from './frames'; const FRAME = 1000 / 60; @@ -361,6 +362,15 @@ function drawn( return draws; } +const skipFrames = (): TrackedFrame[] => + skip.frames.map( + ([t, y]) => + ({ + t, + box: y == null ? null : {x: 0, y, width: 10, height: 10}, + }) as TrackedFrame, + ); + describe('busyWindowProof with the compositor record', () => { it('with a compositor record, judges a sparse cast from its first to its last moved frame', () => { // One screencast frame in six, as on a loaded machine, with the compositor @@ -402,6 +412,29 @@ describe('busyWindowProof with the compositor record', () => { expect(clean.movedFraction).toBeLessThanOrEqual(1); }); + it('reads frames the screencast skipped while the compositor presented them as presented: a recorded run that fails without the record', () => { + const frames = skipFrames(); + const alone = busyWindowProof(frames, skip.block, {settledWithin: 1}); + expect(alone.verdict.failures.join('\n')).toMatch( + /longest stall 6\d\.\d ms = 4 intervals > 3/, + ); + // Every frame the screencast delivered moved: only the stall remains, + // which the record resolves. + expect(alone.verdict.failures.join('\n')).not.toMatch( + /expected frames moved/, + ); + const proof = busyWindowProof(frames, skip.block, { + settledWithin: 1, + presented: skip.presented, + }); + expect(proof.verdict.failures).toEqual([]); + expect(proof.recorderSkipped).toBe(5); + expect(proof.longestStallFrames).toBeLessThanOrEqual(2); + expect(proof.summary).toMatch( + /the screencast skipped 5 frames the compositor presented \(at \+3\d\d ms, \+4\d\d ms\)/, + ); + }); + it('counts a gap the compositor drew through as skipped, and one it did not draw through as a stall', () => { const k = at(1300); const gap: [number, number] = [ diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index 20c3d86a1184e..55fd9d670d52a 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -67,6 +67,8 @@ export async function blockMainThread( const minProgress = Math.max(minProgressMs, ONE_FRAME_MS); if (during == null) { return page.evaluate(duration => { + // The harness's own work: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const from = performance.timeOrigin + performance.now(); const end = performance.now() + duration; let spins = 0; @@ -252,8 +254,8 @@ export interface BusyProof extends FrameStats { * no step and read as not moved, which the proof notes (never a failure). * * A loaded machine's screencast can skip frames the compositor still presents - * (Chromium). Given the compositor's record (`presented`: when it presented - * each frame), a gap between two moved frames with no frame + * (Chromium). Given the compositor's record (`presented`, from + * `startCompositorTrace`), a gap between two moved frames with no frame * delivered inside it is measured between the compositor's own frames: it is * a stall only where the compositor presented nothing, and the frames it did * present there are neither stalls nor misses (`recorderSkipped`). A gap @@ -310,8 +312,8 @@ export function busyWindowProof( settledAt?: number; /** * When the compositor presented each frame, epoch ms on the page clock - * (Chromium's `Display::FrameDisplayed`). Empty or absent, the screencast - * is judged alone. + * (`startCompositorTrace`; Chromium). Empty or absent, the screencast is + * judged alone. */ presented?: readonly number[]; } = {}, diff --git a/internal/interaction-harness/src/curves.ts b/internal/interaction-harness/src/curves.ts index 69b9908066f62..4a36629d1f0a7 100644 --- a/internal/interaction-harness/src/curves.ts +++ b/internal/interaction-harness/src/curves.ts @@ -134,17 +134,61 @@ function animationRecorder(): void { return entry.startedAt == null && entry.animation.playState !== 'idle'; }); }; + // While a rendering trace runs (traceRendering sets + // `__harnessRenderingTrace`), the recorder stands down. Its per-frame + // callback would make every frame a main-thread frame, in which Blink + // restyles each running animation's target, a composited one included (and + // lays out an anchored one), and document.getAnimations() flushes style: + // exactly what the trace counts. So it runs no frame callback, and a CSS + // animation or transition event reads only the animations of the element it + // names, once, at that animation's start (one that ends inside the trace is + // otherwise never seen). The animate() wrapper below still records script + // animations (it reads no style), and traceRendering resumes the recorder + // once the trace ends. + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + let looping = false; const tick = () => { + if (tracing()) { + looping = false; + return; + } record(); frame(tick); }; + const loop = () => { + if (!looping) { + looping = true; + frame(tick); + } + }; // What already runs when the recorder starts (a spinner on a page the // harness touches for the first time) is seen now, before any watch arms; // first seen a frame later, it would read as started by the watched action. + // (A recorder that starts during a trace reads once here, then waits.) record(); - frame(tick); + loop(); for (const type of ['animationstart', 'transitionrun', 'transitionstart']) { - document.addEventListener(type, record, true); + document.addEventListener( + type, + event => { + if (!tracing()) { + record(); + return; + } + const target = event.target; + if (target instanceof Element) { + // A pseudo-element's animation (`::backdrop`) is in the subtree. + const pseudo = (event as AnimationEvent | TransitionEvent) + .pseudoElement; + for (const animation of target.getAnimations({subtree: !!pseudo})) { + remember(animation); + } + } + }, + true, + ); } // A script animation is recorded as it is made: WebKit can start and finish // a short one between two frames of a fresh page. @@ -159,12 +203,36 @@ function animationRecorder(): void { }; w.__harnessAnimations = { log, - record, + // A read from outside the recorder (a wait's poll) stands down while a + // trace runs, as the frame loop does: document.getAnimations() flushes + // style. CSS start events and the animate() wrapper still log what + // starts, and resume() catches up. + record: () => { + if (!tracing()) { + record(); + } + }, + // Catches up on what started during a trace, then runs its frames again. + resume: () => { + record(); + loop(); + }, // Record first, then stamp strictly after the newest entry: a step that // changed style just before has its transition created by that read, - // and it started before the arm. - arm: () => { - record(); + // and it started before the arm. While a trace runs the page-wide read + // stands down, as record() does; the watched element's own animations + // are still read, which resolves its pending style change. That read + // restyles the element (and lays out an anchored one) in the task that + // takes the watch, so a traced proof takes its watch before the window + // it counts (README: No Layout / Paint). + arm: (element?: Element) => { + if (!tracing()) { + record(); + } else if (element != null) { + for (const animation of element.getAnimations({subtree: true})) { + remember(animation); + } + } const newest = log.length === 0 ? 0 : log[log.length - 1].seenAt; armedAt = Math.max(performance.now(), newest + 0.001); return armedAt; @@ -197,10 +265,47 @@ async function installCurveEnd(page: Page): Promise { await page.evaluate(() => { const w = window as unknown as { __harnessCurveEnd?: (animation: Animation) => number; + __harnessTiming?: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; + }; + // An effect's timing as getComputedTiming() reports it. Inside a rendering + // trace it is worked out from getTiming() instead: getComputedTiming() + // updates a running animation on demand, which restyles its target in the + // next frame (and lays out an anchored one), exactly what a trace counts. + w.__harnessTiming ??= effect => { + if (effect == null) { + return null; + } + if ( + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace !== true + ) { + return effect.getComputedTiming(); + } + const timing = effect.getTiming(); + const duration = + typeof timing.duration === 'number' ? timing.duration : 0; + const iterations = Number(timing.iterations ?? 1); + const delay = Number(timing.delay ?? 0); + const endDelay = Number(timing.endDelay ?? 0); + const activeDuration = + duration === 0 || iterations === 0 ? 0 : duration * iterations; + const derived: ComputedEffectTiming = { + ...timing, + duration, + iterations, + delay, + endDelay, + activeDuration, + endTime: Math.max(delay + activeDuration + endDelay, 0), + }; + return derived; }; if (w.__harnessCurveEnd) { return; } + const timingOf = w.__harnessTiming; const timingKeys = new Set([ 'offset', 'computedOffset', @@ -216,7 +321,7 @@ async function installCurveEnd(page: Page): Promise { ); w.__harnessCurveEnd = animation => { const effect = animation.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = timingOf(effect); const delay = Number(timing?.delay ?? 0); const active = Number(timing?.activeDuration ?? 0); if ( @@ -826,6 +931,47 @@ export interface ActiveAnimation { readonly finishedAt: number | null; } +/** + * Runs `fn` on `locator`'s element: through the locator, or, `quietly`, + * through a handle to the element it matches now (none yet: `[]`). Each + * locator.evaluate makes a main-thread frame, which a trace counts; + * `elementHandles()` and a handle's evaluate make none. + */ +async function readElement( + locator: Locator, + quietly: boolean, + fn: (element: Element, arg: A) => R[], + arg: A, + timeout: number, +): Promise { + const run = fn as unknown as (element: Element, arg: unknown) => R[]; + if (!quietly) { + return locator.evaluate(run, arg as unknown, {timeout}); + } + const handles = await locator.elementHandles(); + try { + if (handles.length > 1) { + throw new Error( + `waitForAnimation: ${String(locator)} matches ${handles.length} elements`, + ); + } + return handles.length === 0 + ? [] + : await handles[0].evaluate(run, arg as unknown); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } +} + +/** The rejection for a wait whose element is not in the document. */ +function notInDocument(name: string, cause?: unknown): Error { + return new Error( + `${name}: the element is not in the document. An exit that removes ` + + 'its element is read through its container (watch the parent).', + cause === undefined ? undefined : {cause}, + ); +} + /** * Runs a locator read within the wait's own deadline. A locator waits for * its element with no timeout of its own, so one the page removed (an exit @@ -847,11 +993,7 @@ async function withinTimeout( if ((await locator.count()) > 0) { return null; } - throw new Error( - `${name}: the element is not in the document. An exit that removes ` + - 'its element is read through its container (watch the parent).', - {cause: error}, - ); + throw notInDocument(name, error); } throw error; } @@ -876,6 +1018,15 @@ async function withinTimeout( * recorder keeps one entry per `Animation` object, so an animation reversed * or replayed mid-run is misread: a watch taken before the reversal rejects, * and an unwatched wait returns at once with a curve dated before it. + * + * Started inside a `traceRendering` body, it stands down as the recorder + * does, so it adds no frame the trace counts: it takes the element's + * animations from the recorder's log, judges a started one by its own times, + * and reaches the element through a handle to what the locator matches at + * each look. That holds for script animations and CSS transitions. On a CSS + * `@keyframes` animation (and, unwatched, a cancelled CSS transition) it reads + * the animation's timing, which flushes its style and adds Layouts to the + * trace: wait on one with `waitForRest`, or after the trace. */ export async function waitForAnimation( locator: Locator, @@ -891,17 +1042,34 @@ export async function waitForAnimation( } = {}, ): Promise { const deadline = Date.now() + timeout; - await installCurveEnd(locator.page()); + const page = locator.page(); + await installCurveEnd(page); + // Inside a rendering trace the wait stands down (see the page function), + // and it reads the element through handles: each locator.evaluate makes a + // main-thread frame (a Layout of an anchored element), where a handle's + // evaluate adds none. + const traced = await page.evaluate( + () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true, + ); for (;;) { const read = await withinTimeout( 'waitForAnimation', locator, deadline, evaluateTimeout => - locator.evaluate( + readElement( + locator, + traced, (element, {minProgress, since: watchedFrom}) => { + // The harness's own read: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const w = window as unknown as { __harnessCurveEnd: (a: Animation) => number; + __harnessTiming: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; __harnessAnimations: { log: { animation: Animation; @@ -916,6 +1084,13 @@ export async function waitForAnimation( }; const curveEnd = w.__harnessCurveEnd; const recorder = w.__harnessAnimations; + // Inside a rendering trace the wait stands down as the recorder does: + // record() reads nothing, the animations come from the recorder's log + // rather than getAnimations(), and a started one runs by its own + // times, since playState flushes a CSS animation's style. + const traced = + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; recorder.record(); const describe = ( a: Animation, @@ -923,7 +1098,7 @@ export async function waitForAnimation( recordedStart: number | null, ) => { const effect = a.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = w.__harnessTiming(effect); // The recorded start stands in once the page has cancelled it. A // run going backward began at its end: its startTime is where its // current time would reach 0, a whole run later. @@ -994,24 +1169,36 @@ export async function waitForAnimation( // finished must not answer for it. Only document-timeline animations // count, as for holdAnimations: a scroll- or view-timeline one moves // only when something scrolls and never progresses on its own. + const runs = (a: Animation) => { + if (!(a.timeline instanceof DocumentTimeline)) { + return false; + } + if (!traced || a.startTime == null) { + return a.playState === 'running'; + } + const time = Number(a.currentTime ?? 0); + return a.playbackRate < 0 + ? time > 0 + : time < Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + }; + const onElement = () => + recorder.log.filter( + e => inside(e.animation) || e.ancestors.includes(element), + ); const live = ( - watchedFrom == null + watchedFrom == null && !traced ? element.getAnimations({subtree: true}) - : recorded + : (watchedFrom == null ? onElement() : recorded) .filter(e => e.finishedAt == null) .map(e => e.animation) - ).filter( - a => - a.playState === 'running' && - a.timeline instanceof DocumentTimeline, - ); + ).filter(runs); const running = live.filter(a => a.startTime != null); // Progress along the curve, on the timeline: a delay presents nothing // (going backward, the end delay plays first), a run going backward // (`reverse()`) progresses from its end, and a faster rate presents // more curve per ms. const progressed = running.filter(a => { - const timing = a.effect?.getComputedTiming(); + const timing = w.__harnessTiming(a.effect); const time = Number(a.currentTime ?? 0); const local = a.playbackRate < 0 @@ -1036,13 +1223,20 @@ export async function waitForAnimation( return e.finishedAt; } const a = e.animation; - if (a.playState !== 'finished' || a.startTime == null) { + if (a.startTime == null) { + return null; + } + const end = Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + // Traced, by its own times; otherwise by its state. + const time = Number(a.currentTime ?? 0); + const done = traced + ? a.playbackRate < 0 + ? time <= 0 + : time >= end + : a.playState === 'finished'; + if (!done) { return null; } - const end = Number( - (a.effect as KeyframeEffect | null)?.getComputedTiming() - .endTime ?? 0, - ); return a.playbackRate < 0 ? Number(a.startTime) : Number(a.startTime) + end / Math.abs(a.playbackRate || 1); @@ -1052,7 +1246,7 @@ export async function waitForAnimation( .map(e => describe(e.animation, finishedAt(e), e.startedAt)); }, {minProgress: minProgressMs, since: since ?? null}, - {timeout: evaluateTimeout}, + evaluateTimeout, ), ); const found = read ?? []; @@ -1061,6 +1255,12 @@ export async function waitForAnimation( return found; } if (Date.now() > deadline) { + // A traced read goes through handles, which find no element rather + // than wait for one: a missing element is reported as missing, as the + // untraced read reports it. + if (traced && (await locator.count()) === 0) { + throw notInDocument('waitForAnimation'); + } throw new Error( 'waitForAnimation: no animation started on the element, none running ' + 'and none recorded since the last wait. If the action is the ' + @@ -1072,13 +1272,31 @@ export async function waitForAnimation( } } -/** Marks now as the point later waits count animations from. */ -async function armRecorder(page: Page): Promise { - return page.evaluate(() => - ( - window as unknown as {__harnessAnimations: {arm: () => number}} - ).__harnessAnimations.arm(), - ); +/** + * Marks now as the point later waits count animations from. With a locator, + * its element's pending style change is resolved first, also inside a trace + * (reached through a handle, which adds no main-thread frame). + */ +async function armRecorder(page: Page, locator?: Locator): Promise { + const handles = locator == null ? [] : await locator.elementHandles(); + try { + if (handles.length === 1) { + return await handles[0].evaluate(element => + ( + window as unknown as { + __harnessAnimations: {arm: (element?: Element) => number}; + } + ).__harnessAnimations.arm(element as Element), + ); + } + return await page.evaluate(() => + ( + window as unknown as {__harnessAnimations: {arm: () => number}} + ).__harnessAnimations.arm(), + ); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } } export interface AnimationWatch { @@ -1096,7 +1314,9 @@ export interface AnimationWatch { /** * Starts watching `locator`'s element before an action, so the animation the * action starts is caught even when it ends before the test looks (a short - * entrance on a cold page): + * entrance on a cold page). Taking the watch reads the element's animations, + * which restyles it, inside a rendering trace too: take a traced watch before + * the window the proof counts: * * const watch = await watchAnimations(sheet); * await driver.play(tap(button)); @@ -1106,7 +1326,7 @@ export async function watchAnimations( locator: Locator, ): Promise { await installCurveEnd(locator.page()); - const since = await armRecorder(locator.page()); + const since = await armRecorder(locator.page(), locator); return { started: options => waitForAnimation(locator, {...options, since}), }; @@ -1125,6 +1345,19 @@ export async function watchAnimations( * forward run under a linear or cubic-bézier effect easing); under any other * easing or direction, waitForRest waits the hold out, and a hold longer than * `timeout` rejects with "still moving". + * + * While a rendering trace runs (`traceRendering`), it stands down as the + * animation recorder does: a per-frame callback would make every frame a + * main-thread frame, which the trace counts. It then waits on timers for the + * scheduled ends of the curves on the element instead (a run going backward, + * until it finishes), so motion that no animation drives (a script writing + * styles) is not seen until the trace ends. Its one read of the element's + * animations, as it is called, restyles the element (and lays out an anchored + * one) when an animation is already running there: await it right after the + * action that starts the motion, before the window a proof counts, not in its + * middle (README: No Layout / Paint). Finding no animation, it looks again `frames` times a frame + * apart before settling, so one the action starts a few frames later is + * waited for. */ export async function waitForRest( locator: Locator, @@ -1157,15 +1390,94 @@ export async function waitForRest( backdrop, ].join(' | '); }; - // The page's real frames, even while a timer gate holds its callbacks. - const frame = + // The page's real frames and timers, even while a timer gate holds + // its callbacks: a frozen gate never holds the wait itself. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame: typeof requestAnimationFrame; + timeout?: typeof setTimeout; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? requestAnimationFrame; + const timeout = gate?.timeout ?? setTimeout; + // Set by traceRendering while it traces (see the recorder above). + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + const settle = () => { + // Later waits count animations from here. ( - window as unknown as { - __harnessTimerGate?: {frame: typeof requestAnimationFrame}; - } - ).__harnessTimerGate?.frame ?? requestAnimationFrame; - let last = read(); + window as unknown as {__harnessAnimations?: {arm: () => number}} + ).__harnessAnimations?.arm(); + resolve(); + }; + let last = ''; let still = 0; + const frames = () => { + last = read(); + still = 0; + frame(tick); + }; + // While traced: one look at the animations, then a timer to the latest + // scheduled curve end. One not started yet is looked at again a frame + // later; a trace that has ended hands over to the per-frame check. + // Finding nothing settles only after `frames` looks a frame apart, as + // the per-frame check needs `frames` still frames: an animation the + // action starts a frame or two later is still waited for. + let idle = 0; + const scheduled = () => { + let wait = 0; + for (const a of element.getAnimations({subtree: true})) { + // A scroll- or view-timeline animation moves only when something + // scrolls: it has no scheduled end to wait for (holdAnimations + // skips it the same way). + if (!(a.timeline instanceof DocumentTimeline)) { + continue; + } + if ( + a.pending || + (a.playState === 'running' && a.startTime == null) + ) { + wait = Math.max(wait, 16); + } else if (a.playState === 'running') { + // The curve's time left through the playback rate: forward, to + // its (held) end; backward (`reverse()`), to local time 0, + // where it finishes, as the untraced path waits it out. + const rate = a.playbackRate || 1; + const at = Number(a.currentTime ?? 0); + const left = + rate > 0 ? (curveEnd(a) - at) / rate : at / -rate; + wait = Math.max(wait, left); + } + } + if (wait <= 0) { + idle += 1; + if (idle >= need) { + settle(); + return; + } + wait = 16; + } else { + idle = 0; + } + const room = deadline - performance.now(); + timeout( + () => { + if (performance.now() > deadline) { + reject( + new Error('waitForRest: still moving at the deadline'), + ); + } else if (tracing()) { + scheduled(); + } else { + frames(); + } + }, + Math.min(wait, Math.max(0, room)) + 1, + ); + }; const tick = () => { const now = read(); const moving = element.getAnimations({subtree: true}).some( @@ -1182,20 +1494,20 @@ export async function waitForRest( still = !moving && now === last ? still + 1 : 0; last = now; if (still >= need) { - // Later waits count animations from here. - ( - window as unknown as { - __harnessAnimations?: {arm: () => number}; - } - ).__harnessAnimations?.arm(); - resolve(); + settle(); } else if (performance.now() > deadline) { reject(new Error(`waitForRest: still moving (${now})`)); + } else if (tracing()) { + scheduled(); } else { frame(tick); } }; - frame(tick); + if (tracing()) { + scheduled(); + } else { + frames(); + } }), {ms: timeout, need: frames}, {timeout: evaluateTimeout}, diff --git a/internal/interaction-harness/src/frames.ts b/internal/interaction-harness/src/frames.ts index c61ea7e171c6f..83841d259c17f 100644 --- a/internal/interaction-harness/src/frames.ts +++ b/internal/interaction-harness/src/frames.ts @@ -7,11 +7,12 @@ * `Screencast` domain) * @output `startScreencast` (timestamped composited frames), `markElement` * (a marker outline the frames can find), `trackMarker` (per-frame marker - * box), `frameStats`. + * box), `sampleRects` (per-rAF rect sampling), `frameStats`. * @position Frame evidence for motion proofs. The screencast is captured off * the page's main thread, from what the compositor presents, so it keeps * delivering frames while a script blocks the main thread: that is what makes - * the busy-main-thread proof possible. + * the busy-main-thread proof possible. Rect sampling is the cheap path when + * the main thread is free. * * Marker tracking needs no image library: frames are decoded and scanned in a * scratch page of the same browser (createImageBitmap + canvas), so the @@ -399,6 +400,71 @@ export async function trackMarker( } } +export interface RectSample { + /** Epoch ms of the rAF callback. */ + readonly t: number; + readonly box: Box | null; + readonly transform: string; + readonly opacity: number; +} + +/** + * Samples the element's rect, transform and opacity on every animation frame + * for `durationMs`. Main-thread sampling: it stops while the main thread is + * blocked, which is the point of the screencast path above. + */ +export async function sampleRects( + locator: Locator, + durationMs: number, +): Promise { + return locator.evaluate( + (element, duration) => + new Promise< + { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] + >(resolve => { + // The harness's own waits run on the real timers: a frozen timer gate + // (story.ts) holds only the page's. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame?: typeof requestAnimationFrame; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); + const samples: { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] = []; + const end = performance.now() + duration; + const tick = (now: number) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + samples.push({ + t: performance.timeOrigin + now, + box: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + transform: style.transform, + opacity: Number(style.opacity), + }); + if (now < end) { + frame(tick); + } else { + resolve(samples); + } + }; + frame(tick); + }), + durationMs, + ); +} + export interface FrameStats { readonly frames: number; /** Frames whose tracked value differs from the frame before. */ diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index a6c73fdf88362..b1a50ebc8c9e3 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -4,12 +4,17 @@ * @file harness.interaction.browser.spec.ts * @input Fixture pages written inline (no Storybook), real Chromium/WebKit * @output Proof that each harness instrument measures what it claims (the - * busy-main-thread proof with a negative control beside its positive - * check): scripted timestamps reach the + * busy-main-thread proof and the rendering-trace counts with a negative + * control beside their positive checks): scripted timestamps reach the * page; a flick flings natively (Chromium); a second finger and a pinch * arrive as distinct pointers; the screencast shows a compositor animation * advancing through a blocked main thread while a rAF animation freezes; - * sampled curves + * the trace counts zero Layout for a transform animation and one per frame + * for a `top` animation, and none from the harness's own animation recorder + * while an anchored element's composited animation runs (a page that asks + * for a frame every frame lays it out in each); the long-task log records + * the page's own long task but not a DevTools evaluate, which the trace + * counts; sampled curves * match the oracles, individual transform properties included; a sample at * an end reports the time it was read at; holding takes only running * document-timeline animations, each at its own offset, holds each where it @@ -18,23 +23,32 @@ * animation-state waits report a running transition and its end, and judge * rest by presented values even while an animation holds its end, and a * watch taken on first contact ignores an animation already running and - * keeps the start of one the page cancels when it ends; storyboards write a + * keeps the start of one the page cancels when it ends, and the recorder + * reads only at CSS starts while a rendering trace runs and catches up + * after it; + * storyboards write a * sheet and a trace that follows the curve, an endless animation does not * set their length, and a photograph Chromium returns empty is taken again * two frames later; a touch filmstrip keeps scripted event times while a * handler clock sees its pacing; the timer gate holds a component timer * until the filmstrip is done, drops callbacks the page cancels, stamps held - * frames with the time they run, and leaves the harness its own frames. + * frames with the time they run, and leaves the harness its own frames; a + * cast filmstrip keeps exactly the presented frames of its window, labelled + * from its zero, as PNG. * @position The harness's own test. A component spec trusts these * instruments; this file is why it may. */ +import fs from 'node:fs'; +import path from 'node:path'; import {expect, test, type Page} from '@playwright/test'; import { animationStoryboard, + castStoryboard, blockMainThread, busyWindowProof, compareCurve, + countBetween, cubicBezier, curveOf, drag, @@ -43,6 +57,7 @@ import { freezeTimers, holdAnimations, installInputLog, + installLongTaskLog, installTimerGate, markElement, merge, @@ -50,10 +65,13 @@ import { PARTIAL_LIFT_SKIP, pinch, readInputLog, + readLongTasks, releaseAnimations, sampleCurve, + sampleRects, seekAnimations, springStep, + startCompositorTrace, startScreencast, Storyboard, tap, @@ -61,6 +79,7 @@ import { timeline, touchDriver, touchStoryboard, + traceRendering, trackMarker, waitForAnimation, waitForRest, @@ -68,6 +87,16 @@ import { withFinger, } from './index'; +// An element positioned against an anchor: a composited animation of it lays +// out in every main-thread frame, so it shows any per-frame main-thread work. +const ANCHORED = ` + +
`; + const FIXTURE = `
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); }); test.describe('curve sampling', () => { @@ -1143,6 +1905,91 @@ test.describe('animation state', () => { expect(started.map(a => a.durationMs)).toEqual([300]); }); + test('while a rendering trace runs the recorder reads only at CSS starts, and catches up after it', async ({ + page, + }) => { + await fixture(page); + const box = page.locator('#box'); + const watch = await watchAnimations(box); + // Counts the recorder's reads of the page's animations (each one flushes + // style): the whole document's, and one element's. Then flags a trace, as + // traceRendering does. + await page.evaluate(() => { + const w = window as unknown as { + __reads: number; + __elementReads: number; + __harnessRenderingTrace?: boolean; + }; + w.__reads = 0; + w.__elementReads = 0; + const read = document.getAnimations.bind(document); + document.getAnimations = () => { + w.__reads += 1; + return read(); + }; + const readElement = Element.prototype.getAnimations; + Element.prototype.getAnimations = function ( + this: Element, + ...args: Parameters + ) { + w.__elementReads += 1; + return readElement.apply(this, args); + }; + w.__harnessRenderingTrace = true; + }); + // During the trace: a CSS transition and a CSS animation that both end + // inside it, and a script animation still running after it. + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + await box.evaluate(el => { + const box = el as HTMLElement; + box.style.transition = 'transform 120ms linear'; + void box.offsetWidth; + box.style.transform = 'translateX(100px)'; + box.style.animation = 'harness-fade 120ms linear'; + box.animate([{scale: '1'}, {scale: '0.9'}], {duration: 600}); + }); + await page.waitForTimeout(300); + const reads = () => + page.evaluate(() => (window as unknown as {__reads: number}).__reads); + // No frame loop: no document-wide read. One element read per CSS start + // event (transitionrun, transitionstart, animationstart), not per frame. + expect(await reads()).toBe(0); + expect( + await page.evaluate( + () => (window as unknown as {__elementReads: number}).__elementReads, + ), + ).toBeLessThanOrEqual(3); + // The trace ends: traceRendering clears the flag and resumes the recorder, + // which reads again, every frame. + await page.evaluate(() => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations: {resume: () => void}; + }; + w.__harnessRenderingTrace = false; + w.__harnessAnimations.resume(); + }); + await frames(page, 3); + expect(await reads()).toBeGreaterThan(2); + // All three are reported, the two CSS ones that ended inside the trace + // with the time they finished. + await waitForRest(box); + const started = await watch.started(); + expect(started.map(a => a.kind).sort()).toEqual([ + 'Animation', + 'CSSAnimation', + 'CSSTransition', + ]); + expect( + started + .filter(a => a.kind !== 'Animation') + .map(a => a.finishedAt != null), + ).toEqual([true, true]); + }); + test('a wait answers with the animation the action started, not one that finished before it', async ({ page, }) => { @@ -1207,6 +2054,28 @@ test.describe('animation state', () => { await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); }); + test('inside a rendering trace too, a watch taken right after a style change does not report that change', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + await fixture(page); + const box = page.locator('#box'); + await waitForRest(box); + await traceRendering(page, async () => { + // The step: a transition the next style read creates. + await box.evaluate(el => { + el.style.transition = 'opacity 300ms linear'; + el.style.opacity = '0.5'; + }); + const watch = await watchAnimations(box); + await expect(watch.started({timeout: 200})).rejects.toThrow( + /waitForAnimation/, + ); + }); + await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); + }); + test('an exit whose element the page removes at its end is still reported', async ({ page, }) => { @@ -1258,6 +2127,43 @@ test.describe('animation state', () => { } }); + test('inside a rendering trace too, a wait on an element no longer in the document rejects as missing', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + await page.evaluate(() => { + const gone = document.createElement('div'); + gone.id = 'gone'; + document.body.append(gone); + }); + const gone = page.locator('#gone'); + const watch = await watchAnimations(gone); + await page.evaluate(() => document.getElementById('gone')?.remove()); + const outcomes: string[] = []; + await traceRendering(page, async () => { + for (const wait of [ + () => watch.started({timeout: 300}), + () => waitForAnimation(gone, {timeout: 300}), + ]) { + outcomes.push( + await wait().then( + () => 'resolved', + (error: Error) => error.message, + ), + ); + } + }); + expect(outcomes).toHaveLength(2); + for (const outcome of outcomes) { + expect(outcome).toMatch(/not in the document/); + } + }); + test('a watch reports only what started after it', async ({page}) => { await fixture(page); const box = page.locator('#box'); @@ -1611,7 +2517,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); - test("the harness's own waits run on the real timers: a frozen timer gate does not hold blockMainThread's block", async ({ + test("the harness's own waits run on the real timers: a frozen timer gate holds neither blockMainThread's block nor sampleRects", async ({ page, }) => { await fixture(page); @@ -1635,6 +2541,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect( await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); await thawTimers(page); }); @@ -1823,4 +2730,58 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect(state).toMatchObject({timer: false, frame: false}); expect(state.heldFor).toBeGreaterThanOrEqual(140); }); + + test('a cast filmstrip keeps exactly the presented frames of its window, labelled from its zero', async ({ + page, + }, testInfo) => { + await fixture(page); + const box = page.locator('#box'); + // JPEG frames (WebKit's are always JPEG) take the conversion path. + const cast = await startScreencast(page, {format: 'jpeg'}); + await box.evaluate(el => + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600}, + ), + ); + await page.waitForTimeout(500); + const presented = await cast.stop(); + expect(presented.length).toBeGreaterThan(8); + // A window of the middle frames, zeroed on one of them. + const zero = presented[2].t; + const from = presented[3].t; + const to = presented[presented.length - 3].t; + const inside = presented.filter(f => f.t >= from && f.t <= to); + const seen: number[] = []; + const film = await castStoryboard(page, presented, { + name: 'harness-self-test-cast', + zero, + from, + to, + data: t => { + seen.push(t); + return {t}; + }, + }); + expect(film.frames.map(f => f.t)).toEqual(inside.map(f => f.t - zero)); + expect(film.frames.map(f => f.label)).toEqual( + inside.map(f => `t=${(f.t - zero).toFixed(1)} ms`), + ); + expect(seen).toEqual(inside.map(f => f.t)); + // Every cell is a PNG, whatever the screencast delivered. + for (const frame of film.frames) { + expect([...frame.png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + } + const files = await film.write(testInfo); + expect(files['sheet.png']).toMatch(/sheet\.png$/); + // A window with no presented frame in it films nothing. + const empty = await castStoryboard(page, presented, { + name: 'harness-self-test-cast-empty', + zero, + from: to + 1, + to: to + 2, + }); + expect(empty.frames).toEqual([]); + await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); + }); }); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 2bcb127f36d88..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -21,3 +21,4 @@ export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 5a1f2237c6725..cb57d59b50be2 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -6,7 +6,8 @@ * static server over a built Storybook * @output `phone` (the touch-phone browser context every interaction proof * runs in), `withStorybook` (serve the built Storybook for a spec file), and - * `openStory` (load one story and wait until it has rendered). + * `openStory` (load one story, with Storybook's highlight module off, and + * wait until it has rendered). * @position Stories are the reproduction path, as for every other browser * check in this repository: a scenario used for verification is checked in, * not built in a one-off page. @@ -59,13 +60,47 @@ export function withStorybook(test: TestType): () => string { }; } -/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +/** Pages whose Storybook highlight module `openStory` turned off. */ +const highlightOff = new WeakSet(); + +export interface OpenStoryOptions { + /** + * Keeps Storybook's highlight module on. It is off by default: when on, it + * observes `#storybook-root` and, on every insertion, reads the computed + * style of every element on the page inside the inserting task, so a proof + * would measure Storybook as well as the component. + */ + readonly storybookHighlight?: boolean; +} + +/** + * Loads a story in the page and waits for its first render, its fonts and two + * frames. Storybook's highlight module is off (see `OpenStoryOptions`), and + * that is checked: its root element must be absent once the story renders. + */ export async function openStory( page: Page, origin: string, storyId: string, args?: Readonly>, + {storybookHighlight = false}: OpenStoryOptions = {}, ): Promise { + if (storybookHighlight) { + if (highlightOff.has(page)) { + throw new Error( + "openStory: this page already turned Storybook's highlight module off; open the story in a new page to keep it on", + ); + } + } else if (!highlightOff.has(page)) { + highlightOff.add(page); + // Storybook's highlight module returns at once when this is set before + // its preview runs. + await page.addInitScript(() => { + ( + globalThis as unknown as {__STORYBOOK_HIGHLIGHT_INITIALIZED?: boolean} + ).__STORYBOOK_HIGHLIGHT_INITIALIZED = true; + }); + } const params = new URLSearchParams({id: storyId, viewMode: 'story'}); if (args != null) { params.set( @@ -86,6 +121,14 @@ export async function openStory( requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); + if ( + !storybookHighlight && + (await page.locator('#storybook-highlights-root').count()) > 0 + ) { + throw new Error( + "openStory: Storybook's highlight module is on (#storybook-highlights-root exists), so every insertion would also read the style of every element on the page; this Storybook may no longer read __STORYBOOK_HIGHLIGHT_INITIALIZED", + ); + } } /** Resolves after `count` animation frames have been presented in the page. */ @@ -110,8 +153,9 @@ export async function frames(page: Page, count = 2): Promise { // A held callback is queued under its timer or frame id, so the page's own // clearTimeout and cancelAnimationFrame still cancel it while frozen; a held -// frame callback gets the time it actually runs at. `frame` stays the real -// requestAnimationFrame, for the harness's own waits. +// frame callback gets the time it actually runs at. `frame` and `timeout` +// stay the real requestAnimationFrame and setTimeout, for the harness's own +// waits. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); diff --git a/internal/interaction-harness/src/storyboard.ts b/internal/interaction-harness/src/storyboard.ts index 2dc22fdc8cf04..a49207c4f81c4 100644 --- a/internal/interaction-harness/src/storyboard.ts +++ b/internal/interaction-harness/src/storyboard.ts @@ -336,7 +336,8 @@ export interface TouchStoryboardOptions extends StoryboardOptions { * photograph per event, about 100 ms between moves instead of the script's * 16.7. Its release velocity is that much lower, so a flick that dismisses * when played can settle back when filmed: a storyboard cannot show such a - * gesture; record it in real time instead. + * gesture; record it in real time instead (`startScreencast`, + * `castStoryboard`). */ export async function touchStoryboard( page: Page, @@ -364,3 +365,65 @@ export async function touchStoryboard( }); return storyboard; } + +/** + * A filmstrip of screencast frames (real presented frames, e.g. the frames of + * a busy-main-thread window), labelled with their time relative to `zero`. + * Evidence for what the compositor presented, not a deterministic storyboard. + */ +export async function castStoryboard( + page: Page, + frames: readonly { + readonly t: number; + readonly data: string; + readonly format: 'png' | 'jpeg'; + }[], + // A cast is already the screen's frames: no clip is applied to them. + options: Omit & { + readonly zero: number; + readonly from?: number; + readonly to?: number; + readonly data?: (t: number) => Readonly>; + }, +): Promise { + const storyboard = new Storyboard(page, options); + for (const frame of frames) { + if (options.from != null && frame.t < options.from) { + continue; + } + if (options.to != null && frame.t > options.to) { + continue; + } + const png = + frame.format === 'png' + ? Buffer.from(frame.data, 'base64') + : await toPng(page, frame.data); + const t = frame.t - options.zero; + storyboard.frames.push({ + label: `t=${t.toFixed(1)} ms`, + t, + data: options.data?.(frame.t) ?? {}, + png, + }); + } + return storyboard; +} + +async function toPng(page: Page, jpegBase64: string): Promise { + const scratch = await page.context().newPage(); + try { + const url = await scratch.evaluate(async data => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/jpeg;base64,${data}`)).blob(), + ); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + return canvas.toDataURL('image/png'); + }, jpegBase64); + return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + } finally { + await scratch.close(); + } +} diff --git a/internal/interaction-harness/src/storybook.interaction.browser.spec.ts b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a711feb62cb84 --- /dev/null +++ b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts @@ -0,0 +1,104 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file storybook.interaction.browser.spec.ts + * @input The built Storybook (`withStorybook`) and one of its stories + * @output `openStory` turns Storybook's highlight module off, so an insertion + * in a story carries no `getComputedStyle` scan of the page; with the module + * on (the control), every insertion carries one. Both engines; Chromium's + * trace also counts the style recalculations the scan forces + * @position Self-test for story.ts; it needs the built Storybook, as every + * component proof does + */ + +import {expect, test, type Page} from '@playwright/test'; +import {openStory, withStorybook} from './story'; +import {countBetween, traceRendering} from './trace'; + +const storybook = withStorybook(test); + +/** Counts the page's `getComputedStyle` calls, from before Storybook loads. */ +function countStyleReads(): void { + const w = window as unknown as {__styleReads: number}; + w.__styleReads = 0; + const read = window.getComputedStyle.bind(window); + window.getComputedStyle = ((element: Element, pseudo?: string | null) => { + w.__styleReads += 1; + return read(element, pseudo); + }) as typeof window.getComputedStyle; +} + +/** + * Inserts an exit copy of the story's first element into `#storybook-root`, + * as an exit animation's copy is, lets the insertion's mutation microtasks run, + * and reports what ran between its marks. + */ +async function insertCopy(page: Page) { + return page.evaluate(async () => { + const w = window as unknown as {__styleReads: number}; + const root = document.getElementById('storybook-root') as HTMLElement; + const reads = w.__styleReads; + const from = + performance.timeOrigin + performance.mark('copy:start').startTime; + const copy = (root.firstElementChild as HTMLElement).cloneNode( + true, + ) as HTMLElement; + copy.setAttribute('aria-hidden', 'true'); + root.appendChild(copy); + // A MutationObserver's callback is a microtask the insertion queued: + // these run after it. + await Promise.resolve(); + await Promise.resolve(); + const to = performance.timeOrigin + performance.mark('copy:end').startTime; + const scanned = w.__styleReads - reads; + const elements = document.body.querySelectorAll('*').length; + copy.remove(); + return {from, to, scanned, elements}; + }); +} + +test("openStory turns Storybook's highlight module off: an insertion in a story carries no getComputedStyle scan of the page", async ({ + page, + browserName, +}) => { + // The insertion's reads in every engine, and in Chromium (whose trace the + // harness reads) the style recalculations between its marks. + const measure = async (p: Page) => { + if (browserName !== 'chromium') { + return {copy: await insertCopy(p), styleRecalc: null}; + } + let copy = {from: 0, to: 0, scanned: 0, elements: 0}; + const trace = await traceRendering(p, async () => { + copy = await insertCopy(p); + }); + return { + copy, + styleRecalc: + countBetween(trace, copy.from, copy.to).UpdateLayoutTree ?? 0, + }; + }; + // The harness default. + await page.addInitScript(countStyleReads); + await openStory(page, storybook(), 'core-button--primary'); + expect(await page.locator('#storybook-highlights-root').count()).toBe(0); + const off = await measure(page); + expect(off.copy.scanned).toBe(0); + if (off.styleRecalc != null) { + expect(off.styleRecalc).toBe(0); + } + + // Control: Storybook's module kept on reads the style of every element on + // the page inside the inserting task. + const control = await page.context().newPage(); + await control.addInitScript(countStyleReads); + await openStory(control, storybook(), 'core-button--primary', undefined, { + storybookHighlight: true, + }); + expect(await control.locator('#storybook-highlights-root').count()).toBe(1); + const on = await measure(control); + expect(on.copy.scanned).toBeGreaterThanOrEqual(on.copy.elements); + if (on.styleRecalc != null) { + expect(on.styleRecalc).toBeGreaterThan(0); + } + await control.close(); +}); diff --git a/internal/interaction-harness/src/trace.ts b/internal/interaction-harness/src/trace.ts new file mode 100644 index 0000000000000..ce6a3d853d2cd --- /dev/null +++ b/internal/interaction-harness/src/trace.ts @@ -0,0 +1,466 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file trace.ts + * @input Uses a Playwright `Page` (Chromium: CDP `Tracing`) and the page's + * PerformanceObserver for long tasks + * @output `traceRendering` — counts of the main thread's rendering work + * (Layout, Paint, UpdateLayoutTree, PrePaint, …) between two marks — and + * `installLongTaskLog` / `readLongTasks`. + * @position The "no Layout/Paint during a transform/opacity animation" proof. + * A compositor animation costs the main thread nothing per frame; one that + * animates a layout property, or writes styles from JavaScript each frame, + * shows a Layout (and usually a Paint) per frame. Chromium only: WebKit + * exposes no comparable trace to Playwright. + * + * Counting is scoped to the renderer main thread that ran the page's own + * `performance.mark` calls, and to the time between the start and end marks, + * so other pages, the browser process and setup work are excluded. + * + * SYNC: Keep RENDERING_EVENTS equal to the README's list. + */ + +import type {CDPSession, Page} from '@playwright/test'; + +/** + * The mark a harness function puts on its own page-side work + * (`console.timeStamp('harness-read')` at the start of the function it + * evaluates in the page), so a trace can tell the harness's reads from page + * code a test's own evaluate runs. + */ +export const HARNESS_READ = 'harness-read'; + +/** Trace events that run page script: a listener, a timer, a frame callback. */ +const PAGE_SCRIPT_EVENTS = new Set([ + 'EventDispatch', + 'FunctionCall', + 'TimerFire', + 'FireAnimationFrame', + 'FireIdleCallback', +]); + +/** Main-thread trace events that are rendering work, by their trace names. */ +export const RENDERING_EVENTS = [ + 'UpdateLayoutTree', // style recalculation + 'Layout', + 'PrePaint', + 'Paint', + 'Layerize', + 'UpdateLayer', + 'ScrollLayer', + 'HitTest', +] as const; + +export interface RenderingTrace { + /** Count per trace event name inside the window, on the page's main thread. */ + readonly counts: Readonly>; + /** Shorthand for the four that matter most. */ + readonly layout: number; + readonly paint: number; + readonly styleRecalc: number; + readonly prePaint: number; + /** Window length, ms. */ + readonly durationMs: number; + /** + * The page's own main-thread tasks longer than 50 ms inside the window (long + * tasks), epoch ms. A task that ran a test's own DevTools command (a + * `page.evaluate`) is one, whether it calls page code directly or sets it + * off; only the harness's own marked reads are in `devtoolsTasks`. + */ + readonly longTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** + * Main-thread tasks longer than 50 ms inside the window that ran a DevTools + * command the harness marked as its own read (`HARNESS_READ`) and no page + * script, epoch ms: `waitForAnimation` describing every animation's + * keyframes, or `blockMainThread(page, ms)` without `during`. + */ + readonly devtoolsTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** Every counted rendering event with its epoch-ms time, for sub-window counts. */ + readonly events: readonly {readonly name: string; readonly t: number}[]; + /** The window, epoch ms on the page's clock. */ + readonly window: {readonly from: number; readonly to: number}; +} + +/** Counts rendering events by name inside `[from, to]` (epoch ms). */ +export function countBetween( + trace: RenderingTrace, + from: number, + to: number, +): Record { + const counts: Record = {}; + for (const e of trace.events) { + if (e.t >= from && e.t <= to) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + } + } + return counts; +} + +interface TraceEvent { + name: string; + cat: string; + ph: string; + ts: number; + dur?: number; + pid: number; + tid: number; + args?: Record; +} + +/** + * Traces the page while `body` runs and counts the rendering work its main + * thread did between a start mark (placed just before `body`) and an end mark + * (just after). The harness's animation recorder, `waitForRest`, + * `waitForAnimation` and a watch's `started()` stand down meanwhile, so a body + * may wait with any of them and add no frame of its own, with one gap: on a + * CSS `@keyframes` animation (and, for an unwatched `waitForAnimation`, a + * cancelled CSS transition) the wait reads its timing, which flushes that + * animation's style, so wait on such an animation with `waitForRest`, or + * after the trace. Taking a watch, or calling `waitForRest` while an + * animation already runs on the element, reads its animations once, which + * restyles it: do either before the window a proof counts. + */ +export async function traceRendering( + page: Page, + body: () => Promise, +): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'blink.user_timing', + 'toplevel', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-${Math.random().toString(36).slice(2, 8)}`; + const mark = (name: string) => + page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + name, + ); + // The harness's animation recorder (curves.ts) runs a callback every frame, + // which makes each frame a main-thread one where Blink restyles every running + // animation's target (and lays out an anchored one), and it reads the page's + // animations, which flushes style: exactly what this counts. It stands down + // while this flag is set, a recorder the body itself starts included, and + // resumes once the trace ends. + const standDown = (on: boolean) => + page.evaluate(value => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations?: {resume?: () => void}; + }; + w.__harnessRenderingTrace = value; + if (!value) { + w.__harnessAnimations?.resume?.(); + } + }, on); + await standDown(true); + let fromEpoch: number; + let toEpoch: number; + try { + fromEpoch = await mark(`${tag}:start`); + await body(); + toEpoch = await mark(`${tag}:end`); + } finally { + // Its catch-up read lands after the end mark, outside the counted window. + // A failure here would hide the body's own error, which is the one to see. + await standDown(false).catch(() => {}); + } + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + + const start = events.find(e => e.name === `${tag}:start`); + const end = events.find(e => e.name === `${tag}:end`); + if (start == null || end == null) { + throw new Error('traceRendering: the window marks were not traced'); + } + const onMain = (e: TraceEvent) => + e.pid === start.pid && + e.tid === start.tid && + e.ts >= start.ts && + e.ts <= end.ts; + // Trace time (µs, monotonic) -> epoch ms, anchored on the start mark. + const toEpochMs = (ts: number) => fromEpoch + (ts - start.ts) / 1000; + const counts: Record = {}; + const counted: {name: string; t: number}[] = []; + for (const e of events) { + if (!onMain(e) || e.ph === 'E') { + continue; + } + if ((RENDERING_EVENTS as readonly string[]).includes(e.name)) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + counted.push({name: e.name, t: toEpochMs(e.ts)}); + } + } + // A task that ran a DevTools command received it over the DevTools + // session's pipe; page script it set off shows as its listener, timer or + // frame callback. + const main = events.filter(e => onMain(e) && e.ph !== 'E'); + const longTasks: {start: number; durationMs: number}[] = []; + const devtoolsTasks: {start: number; durationMs: number}[] = []; + for (const task of main) { + if (task.name !== 'RunTask' || (task.dur ?? 0) <= 50_000) { + continue; + } + const end = task.ts + (task.dur ?? 0); + const inside = main.filter( + e => e !== task && e.ts >= task.ts && e.ts <= end, + ); + const devtools = inside.some( + e => + e.name === 'Receive mojo message' && + ( + e.args?.chrome_mojo_event_info as + {mojo_interface_tag?: unknown} | undefined + )?.mojo_interface_tag === 'blink.mojom.DevToolsSession', + ); + // Only the harness's own reads are set apart: a test's evaluate may call + // page code directly, which leaves no listener or timer in the trace. + const harnessRead = inside.some( + e => + e.name === 'TimeStamp' && + (e.args?.data as {message?: unknown} | undefined)?.message === + HARNESS_READ, + ); + const pageScript = inside.some(e => PAGE_SCRIPT_EVENTS.has(e.name)); + (devtools && harnessRead && !pageScript ? devtoolsTasks : longTasks).push({ + start: toEpochMs(task.ts), + durationMs: (task.dur ?? 0) / 1000, + }); + } + return { + counts, + layout: counts.Layout ?? 0, + paint: counts.Paint ?? 0, + styleRecalc: counts.UpdateLayoutTree ?? 0, + prePaint: counts.PrePaint ?? 0, + durationMs: (end.ts - start.ts) / 1000, + longTasks, + devtoolsTasks, + events: counted, + window: {from: fromEpoch, to: toEpoch}, + }; +} + +/** + * The browser's open pages. The compositor record is browser-wide: its + * `Display::FrameDisplayed` events carry no page identity. + */ +function openPages(page: Page): Page[] { + const browser = page.context().browser(); + return browser == null + ? page.context().pages() + : browser.contexts().flatMap(context => context.pages()); +} + +/** + * Watches for a page that opens while the record runs, in any context (it + * may close again before the record stops). Returns whether one opened. + */ +async function watchForOtherPages( + page: Page, +): Promise<{stop(): Promise}> { + let opened = false; + const onPage = (other: Page) => { + if (other !== page) { + opened = true; + } + }; + // The page's own context, through Playwright (a persistent context has no + // browser to ask). + page.context().on('page', onPage); + // Every context, through the browser's target discovery: a page in a + // context created during the record is a new target too. + let targets: CDPSession | null = null; + const browser = page.context().browser(); + if (browser != null) { + try { + targets = await browser.newBrowserCDPSession(); + const {targetInfos} = await targets.send('Target.getTargets'); + const known = new Set(targetInfos.map(info => info.targetId)); + targets.on('Target.targetCreated', ({targetInfo}) => { + if (targetInfo.type === 'page' && !known.has(targetInfo.targetId)) { + opened = true; + } + }); + await targets.send('Target.setDiscoverTargets', {discover: true}); + } catch { + targets = null; + } + } + return { + async stop() { + page.context().off('page', onPage); + await targets?.detach().catch(() => {}); + return opened; + }, + }; +} + +function assertOnlyPage(page: Page, when: string): void { + if (openPages(page).some(other => other !== page && !other.isClosed())) { + throw new Error( + `startCompositorTrace: another page is open ${when}; the compositor record is browser-wide (its frames carry no page identity), so another page's frames would be credited to this one`, + ); + } +} + +/** + * Records when the compositor presents each frame, for `busyWindowProof`'s + * `presented`: a Chromium trace of `Display::FrameDisplayed`, on the page + * clock. Start it with the screencast, before the block, and stop it after. + * Engines without a CDP trace (WebKit) resolve `stop()` to [], and the + * verdict then judges the screencast alone. It runs its own trace, so not + * inside `traceRendering`. The record is browser-wide, so it throws when + * another page is open as it starts or stops, or opened while it ran. + */ +export async function startCompositorTrace( + page: Page, +): Promise<{stop(): Promise}> { + let cdp: CDPSession; + try { + cdp = await page.context().newCDPSession(page); + } catch { + return {stop: async () => []}; + } + try { + assertOnlyPage(page, 'as the trace starts'); + } catch (error) { + await cdp.detach().catch(() => {}); + throw error; + } + const others = await watchForOtherPages(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'disabled-by-default-display.framedisplayed', + 'blink.user_timing', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-compositor-${Math.random().toString(36).slice(2, 8)}`; + const epoch = await page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + tag, + ); + return { + async stop() { + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + if (await others.stop()) { + throw new Error( + "startCompositorTrace: another page opened during the record; the compositor record is browser-wide (its frames carry no page identity), so that page's frames would be credited to this one", + ); + } + assertOnlyPage(page, 'as the trace stops'); + const mark = events.find(e => e.name === tag); + if (mark == null) { + throw new Error('startCompositorTrace: the clock mark was not traced'); + } + // Trace time (µs, monotonic) -> epoch ms, anchored on the mark. + return events + .filter(e => e.name === 'Display::FrameDisplayed') + .map(e => epoch + (e.ts - mark.ts) / 1000) + .sort((a, b) => a - b); + }, + }; +} + +const LONG_TASK_SCRIPT = `(() => { + if (window.__harnessLongTasks) return; + const tasks = []; + const log = {tasks, supported: false}; + window.__harnessLongTasks = log; + if (typeof PerformanceObserver === 'undefined' || !(PerformanceObserver.supportedEntryTypes || []).includes('longtask')) return; + try { + new PerformanceObserver(list => { + for (const entry of list.getEntries()) tasks.push({start: performance.timeOrigin + entry.startTime, durationMs: entry.duration, name: entry.name}); + }).observe({type: 'longtask', buffered: true}); + log.supported = true; + } catch {} +})()`; + +/** + * Records every long task (> 50 ms) the page runs (install before navigation; + * Chromium), through the Long Tasks API. That sees the tasks of the page's own + * event loop: a timer, an event handler, `blockMainThread(page, ms, {during})`. + * A DevTools `page.evaluate`, such as `blockMainThread(page, ms)` without + * `during`, is not one of them and is never recorded here; `traceRendering` + * reports it in `devtoolsTasks`, apart from the page's `longTasks`. + */ +export async function installLongTaskLog(page: Page): Promise { + await page.addInitScript(LONG_TASK_SCRIPT); +} + +export interface LongTask { + /** Epoch ms. */ + readonly start: number; + readonly durationMs: number; + readonly name: string; +} + +/** + * Long tasks recorded since navigation, optionally only those overlapping a + * window. Fails closed: it throws when the log never ran on this document + * (installed after the page navigated) or the engine has no Long Tasks API + * (WebKit), so an empty answer is evidence of no long task, never of no log. + */ +export async function readLongTasks( + page: Page, + range?: {from: number; to: number}, +): Promise { + const log = await page.evaluate( + () => + ( + window as unknown as { + __harnessLongTasks?: {tasks: LongTask[]; supported: boolean}; + } + ).__harnessLongTasks ?? null, + ); + if (log == null) { + throw new Error( + 'readLongTasks: the long-task log is not running on this document; call installLongTaskLog(page) before navigating', + ); + } + if (!log.supported) { + throw new Error( + 'readLongTasks: this engine has no Long Tasks API, so the log records nothing', + ); + } + const tasks = log.tasks; + return range == null + ? tasks + : tasks.filter( + t => t.start < range.to && t.start + t.durationMs > range.from, + ); +} diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/core/package.json b/packages/core/package.json index 295c3d98433c4..3bba9ae4f24ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -864,6 +864,7 @@ "devDependencies": { "@astryxdesign/a11y-spec": "workspace:*", "@astryxdesign/cli": "workspace:*", + "@astryxdesign/interaction-harness": "workspace:*", "@babel/cli": "catalog:", "@babel/core": "catalog:", "@babel/preset-react": "catalog:", diff --git a/packages/core/src/DateInput/DateInput.spec.md b/packages/core/src/DateInput/DateInput.spec.md index 42c8b3a1f8530..8efb012ad503c 100644 --- a/packages/core/src/DateInput/DateInput.spec.md +++ b/packages/core/src/DateInput/DateInput.spec.md @@ -17,6 +17,8 @@ verified_by: packages/core/src/DateInput/DateInputTouch.test.tsx, packages/core/src/DateInput/NativeDateField.test.tsx, packages/core/src/DateInput/Presentation.test.tsx, + packages/core/src/DateInput/Wheel.test.tsx, + packages/core/src/DateInput/__tests__/WheelRest.a11y.chromium.spec.ts, packages/core/src/__tests__/BusyOwnerAnnouncement.test.tsx, packages/core/src/theme/themingTargets.test.ts, scripts/check-knowledge.mjs, @@ -85,6 +87,10 @@ Consumer migration instructions belong in consumer docs and release notes. focus handoff between those controls and the selected picker surface. - DateInput-specific composition of Field, Calendar, Popover, BottomSheet, and the browser/OS date control. +- The shared bottom-sheet picker wheel's mechanics (`Wheel`): how a sheet wheel + rests, commits, steps and turns wherever it is rendered, and DateInput's own + month and year wheels. The time wheels in the TimeInput and DateTimeInput + sheets belong to those records. **Does not own / non-goals** @@ -135,6 +141,7 @@ approved policy. | FR10 | Standalone mode MUST compose Field; admitted InputGroup mode MUST remove competing Field chrome while preserving name, description, value, keyboard, focus, and editing semantics. | `family:input-fields/FR3`; current source and InputGroup tests | Settled shared rule and verified adoption | | FR11 | Clear MUST emit `undefined` through the ordinary change pipeline. The pointer and bottom-sheet surfaces restore field focus using their shipped timing; the native surface MUST NOT reclaim focus because doing so reopens the OS picker. Implementation gap: when the field becomes disabled or busy while its clear control holds focus, the control unmounts and focus falls to the document body; `spec:AST-085/FR4` requires focus to stay or move to a destination this record names. | Current source and focused clear tests; `spec:AST-085/FR4` | Verified shipped behavior | | FR12 | No surface renders native `disabled`. A disabled field MUST stay focusable with `aria-disabled`, block typing, picking, clearing, and opening, describe its disabled reason when one is given, and take disabled paint. While busy (`isLoading` or a pending Action) the pointer and bottom-sheet surfaces expose `aria-disabled` and `aria-busy`, stay focusable, and block new commits; the native surface does the same for `isLoading`, and during a pending Action stays editable so the OS picker remains attached but rejects additional commits until the Action settles. A busy field that is not disabled keeps its ordinary paint and Spinner, shows no hover ring while commits are blocked, and keeps its keyboard focus ring. | `spec:AST-085/FR1–FR6`; `family:input-fields/FR4–FR6`; `family:buttons/FR4`; current source and focused tests | Settled shared rule and verified adoption | +| FR13 | A bottom-sheet picker wheel that comes to rest on a disabled row (outside `min`/`max`) MUST continue to the nearest enabled row, a tie going toward the committed row, and select it there. A wheel resting on its committed row MUST NOT select or commit, even when that row is outside the bounds, so opening a bounded sheet never changes an untouched value: a committed value outside the bounds stays, and an empty field stays empty. A shown wheel whose value changes from outside, as when a bound clamps the time after another wheel turned, MUST turn onto the new row rather than jump to it. | DEC-1; UIDatePicker on the iOS 26.2 simulator; wheel unit and real-browser tests | Settled by DEC-1 | ### Allowed variation @@ -289,22 +296,39 @@ clear-glyph target of its own. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -| FR1, FR5–FR8, FR10–FR12; AR1–AR4 | `DateInput.test.tsx` | empty, valued, typing, invalid, constrained, open, disabled, busy, clear, status, grouped | Value, callbacks, ARIA, disabled guard, focus, or shared-field composition drifts | `audit:DateInput/pointer-field` | -| FR2–FR4 | `Presentation.test.tsx`, `NativeDateField.test.tsx`, `DateInputTouch.test.tsx` | every presentation, omitted props, every deprecated mapping, dual props, fine/coarse, touchscreen laptop | A value resolves to the wrong surface, width affects modality, or legacy behavior wins incorrectly | `audit:DateInput/presentation` | -| FR5–FR7; AR1, AR3–AR5 | `NativeDateField.test.tsx` | empty, valued, focused, constrained, disabled, clear, formatted, locale | Native commit, overlay, label, constraint, or focus reconciliation drifts | `audit:DateInput/native` | -| FR1, FR4–FR7, FR9, FR11–FR12; AR1, AR4, AR6; PR1–PR2 | `DateInputTouch.test.tsx` | closed/open, month grid, selection correction, wheels, bounds, keyboard, swipe, mouse drag, clear, busy | Touch value parity, bounded rendering, focus, semantics, or gesture ownership drifts | `audit:DateInput/touch` | -| FR2–FR3, FR6 | `DateInput.doc.mjs`, Storybook controls, `scripts/check-knowledge.mjs` | five presentation values, deprecated prop, named formats, English and Chinese docs | Source, docs, types, or default mappings disagree | `audit:DateInput/docs-api` | -| Theming anatomy | `DateInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | root size/status/disabled, toggle state, shared clear target | Target class or state is missing, undocumented, or placed on the wrong element | `audit:DateInput/theming` | -| FR8; AR2 | `apps/storybook/stories/DateInput.stories.tsx`, component-scoped axe | open Calendar dialog plus closed field states | Important popup semantics stay unreachable to the browser audit | `audit:DateInput/a11y` | -| AR7 | `apps/storybook/rtl-audit/targets.json`, component RTL audit | calendar and clear controls in LTR and RTL | DateInput-owned controls fail to exchange logical inline order | `audit:DateInput/rtl` | -| AR8 | `DateInputTouch.test.tsx` calendar-surface suite ("mounts a window of months around the visible one, not the whole century" for the named grids, "moves keyboard focus by date, across the month boundary", "moves a week at a time with the vertical arrows", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "disables days outside min/max and refuses to commit them", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves keyboard focus by date, across the month boundary" (the real day, not the spill copy)); ArrowUp, Home/End, Page and activation keys under the Grid accessibility contract (`internal/a11y-spec`, pending) | LTR and RTL; month boundary; selected day; today | A day loses its gridcell role or selection, two days in one pane hold a Tab stop, or an arrow, Home/End, Page or activation key stops moving or picking | `audit:DateInput/accessibility` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| FR1, FR5–FR8, FR10–FR12; AR1–AR4 | `DateInput.test.tsx` | empty, valued, typing, invalid, constrained, open, disabled, busy, clear, status, grouped | Value, callbacks, ARIA, disabled guard, focus, or shared-field composition drifts | `audit:DateInput/pointer-field` | +| FR2–FR4 | `Presentation.test.tsx`, `NativeDateField.test.tsx`, `DateInputTouch.test.tsx` | every presentation, omitted props, every deprecated mapping, dual props, fine/coarse, touchscreen laptop | A value resolves to the wrong surface, width affects modality, or legacy behavior wins incorrectly | `audit:DateInput/presentation` | +| FR5–FR7; AR1, AR3–AR5 | `NativeDateField.test.tsx` | empty, valued, focused, constrained, disabled, clear, formatted, locale | Native commit, overlay, label, constraint, or focus reconciliation drifts | `audit:DateInput/native` | +| FR1, FR4–FR7, FR9, FR11–FR12; AR1, AR4, AR6; PR1–PR2 | `DateInputTouch.test.tsx` | closed/open, month grid, selection correction, wheels, bounds, keyboard, swipe, mouse drag, clear, busy | Touch value parity, bounded rendering, focus, semantics, or gesture ownership drifts | `audit:DateInput/touch` | +| FR2–FR3, FR6 | `DateInput.doc.mjs`, Storybook controls, `scripts/check-knowledge.mjs` | five presentation values, deprecated prop, named formats, English and Chinese docs | Source, docs, types, or default mappings disagree | `audit:DateInput/docs-api` | +| Theming anatomy | `DateInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | root size/status/disabled, toggle state, shared clear target | Target class or state is missing, undocumented, or placed on the wrong element | `audit:DateInput/theming` | +| FR8; AR2 | `apps/storybook/stories/DateInput.stories.tsx`, component-scoped axe | open Calendar dialog plus closed field states | Important popup semantics stay unreachable to the browser audit | `audit:DateInput/a11y` | +| AR7 | `apps/storybook/rtl-audit/targets.json`, component RTL audit | calendar and clear controls in LTR and RTL | DateInput-owned controls fail to exchange logical inline order | `audit:DateInput/rtl` | +| AR8 | `DateInputTouch.test.tsx` calendar-surface suite ("mounts a window of months around the visible one, not the whole century" for the named grids, "moves keyboard focus by date, across the month boundary", "moves a week at a time with the vertical arrows", "leaves exactly one day per month tab-reachable", "gives a spilled date no tab stop in the pane that borrows it", "disables days outside min/max and refuses to commit them", "follows the reading direction under RTL: ArrowLeft moves to the next day", "moves keyboard focus by date, across the month boundary" (the real day, not the spill copy)); ArrowUp, Home/End, Page and activation keys under the Grid accessibility contract (`internal/a11y-spec`, pending) | LTR and RTL; month boundary; selected day; today | A day loses its gridcell role or selection, two days in one pane hold a Tab stop, or an arrow, Home/End, Page or activation key stops moving or picking | `audit:DateInput/accessibility` | +| FR13 | `Wheel.test.tsx` ("stopping below the minimum travels on to the minimum and commits it there", "breaks a tie toward the committed row", "opening on a value outside the bounds parks on it without travelling or committing", "turns onto a value set while it is shown"); `PickerWheel.test.tsx` ("rests back on a committed value outside the bounds and commits nothing"); `WheelRest.a11y.chromium.spec.ts` (real touch drags below the minimum and inside the bounds; a meridiem change past the maximum turns the hour wheel, frame by frame) | below the minimum, above the maximum, on another disabled row; opened on a value outside the bounds, and rested back on it after a drag | A wheel that returns to its previous row, skips the nearest enabled row, breaks a tie away from the committed row, or commits on opening or on a rest back on its committed row fails | `audit:DateInput/touch` | ## Decision log -No component-local decision is created by this observational draft. The governing -presentation decisions remain in `spec:AST-043/DEC-1` through `DEC-3`. +The governing presentation decisions remain in `spec:AST-043/DEC-1` through +`DEC-3`. + +### DEC-1 — A wheel resting on a disabled row continues to the nearest enabled row + +**Reference:** `component:DateInput/DEC-1` +**Decider:** cixzhang, 2026-10-25 + +The bottom-sheet picker wheel rests as UIDatePicker's does: a wheel that stops on +a disabled row (outside `min`/`max`) continues to the nearest enabled row, a tie +going toward the committed row, and selects it there. It applies wherever the +wheel is rendered: DateInput's month and year wheels and the wheels of +DateTimeInput and TimeInput. Opening a bounded sheet never changes an untouched +value: a controlled value outside the bounds stays, and an empty field stays +empty. + +Rejected: returning to the previous row, which makes the person scroll again to +reach the bound. ## Open questions diff --git a/packages/core/src/DateInput/DateInputTouch.test.tsx b/packages/core/src/DateInput/DateInputTouch.test.tsx index a241025106e11..84514b37e789e 100644 --- a/packages/core/src/DateInput/DateInputTouch.test.tsx +++ b/packages/core/src/DateInput/DateInputTouch.test.tsx @@ -29,6 +29,7 @@ import { afterEach, } from 'vitest'; import { + act, render, screen, fireEvent, @@ -54,6 +55,7 @@ import { rowsIn, DEFAULT_MONTH_REACH, } from './monthGeometry'; +import {WHEEL_ROW_PITCH} from './wheelLook'; import {SWIPE_DISTANCE} from './useOwnScrollGesture'; import {DRAG_SLOP} from './usePointerDragScroll'; import {SCROLL_QUIET_MS} from './useScrollSettle'; @@ -2117,6 +2119,19 @@ describe('DateInput — a mouse can drag a wheel', () => { const title = () => document.querySelector('[data-title="month-year"]')!; + // Today's column is the fine pointer's (a finger gets the touch column): + // a desktop whose field asks for the sheet. + beforeEach(() => { + setViewport('desktop'); + }); + + function openWheels() { + renderAndOpen( + , + ); + fireEvent.click(title()); + } + /** jsdom has no layout: give the rows a height and the wheel a scrollTop. */ function withWheelLayout(fn: () => T): T { Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { @@ -2173,8 +2188,7 @@ describe('DateInput — a mouse can drag a wheel', () => { } it('scrolls the wheel when a mouse drags it', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { const el = wheel(); const track = trackable(el); @@ -2189,8 +2203,7 @@ describe('DateInput — a mouse can drag a wheel', () => { }); it('ignores movement too small to be a drag, so a click is still a click', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { const el = wheel(); const track = trackable(el); @@ -2201,8 +2214,7 @@ describe('DateInput — a mouse can drag a wheel', () => { }); it('leaves touch and pen alone — they pan natively, and better', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { for (const pointerType of ['touch', 'pen']) { const el = wheel(); @@ -2216,8 +2228,7 @@ describe('DateInput — a mouse can drag a wheel', () => { }); it('ignores a secondary button, which belongs to the context menu', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { const el = wheel(); const track = trackable(el); @@ -2234,8 +2245,7 @@ describe('DateInput — a mouse can drag a wheel', () => { * jumped a whole one. */ it('suspends snapping for the drag, and restores it after', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { const el = wheel(); trackable(el); @@ -2249,6 +2259,55 @@ describe('DateInput — a mouse can drag a wheel', () => { }); }); + /** + * Every `scrollTop` the drag assigns ends in its own `scrollend` in + * Chromium, and the hand can rest with the button still down. Neither is + * rest: the month the wheel lands on commits at the release, as it does + * when a finger lifts. + */ + it('commits nothing while a mouse drag holds the wheel, and commits at the release', () => { + vi.useFakeTimers(); + try { + // A year of months, so May is reachable (the default range ends in April). + renderAndOpen( + , + ); + fireEvent.click(title()); + withWheelLayout(() => { + const el = wheel(); + trackable(el); + // March, the committed month, sits under the band. + el.scrollTop = 2 * 28; + const selected = () => + el.querySelector('[aria-selected="true"]')?.textContent; + el.dispatchEvent(pointer('pointerdown', {y: 200})); + // Two rows on: May is under the band. + el.dispatchEvent(pointer('pointermove', {y: 144})); + fireEvent.scroll(el); + el.dispatchEvent(new Event('scrollend')); + act(() => { + vi.advanceTimersByTime(SCROLL_QUIET_MS * 2); + }); + expect(selected()).toBe('March'); + expect(title()).toHaveTextContent('March 2026'); + + el.dispatchEvent(pointer('pointerup', {y: 144})); + act(() => { + vi.advanceTimersByTime(SCROLL_QUIET_MS * 2); + }); + expect(selected()).toBe('May'); + expect(title()).toHaveTextContent('May 2026'); + }); + } finally { + vi.useRealTimers(); + } + }); + /** * BottomSheet starts its own drag-to-dismiss from a `pointerdown` on its * body and CAPTURES the pointer for it, which retargets every later pointer @@ -2256,8 +2315,7 @@ describe('DateInput — a mouse can drag a wheel', () => { * that wobbled more than a pixel selected nothing at all. */ it('keeps the press away from the sheet, which would capture the pointer', () => { - renderAndOpen(); - fireEvent.click(title()); + openWheels(); withWheelLayout(() => { const el = wheel(); trackable(el); @@ -2572,6 +2630,30 @@ describe('DateInput — scroll CSS (definition-level)', () => { expect(item).not.toContain("overflow: 'hidden'"); }); + it('keeps the published tokens module to its released names', async () => { + // @astryxdesign/core/DateInput/tokens.stylex is a public subpath: the touch + // wheel's geometry lives in an unexported module beside it. + const tokens = await import('./tokens.stylex'); + expect(Object.keys(tokens).sort()).toEqual([ + 'WEEKS_PER_PANE', + 'dateInputTouchGeometry', + 'dateInputTouchSizes', + ]); + }); + + it('sizes the touch wheels as today’s column, with the large text of the theme', () => { + // The player's row pitch and column height are today's tokens, in + // numbers: a row is `wheelItemSize`, the column six day rows. + const tokens = read('tokens.stylex.ts'); + expect(tokens).toContain(`wheelItemSize: '${WHEEL_ROW_PITCH}px'`); + expect(tokens).toContain("daySize: '44px'"); + expect(tokens).toContain('export const WEEKS_PER_PANE = 6;'); + const source = read('PickerWheel.tsx'); + expect(source).toContain('const COLUMN_HEIGHT = 6 * 44;'); + expect(source).toContain('blockSize: dateInputTouchGeometry.paneBlockSize'); + expect(source).toContain("fontSize: typeScaleVars['--text-large-size']"); + }); + it('insets the sheet content equally on every edge', () => { const source = read('TouchDateField.tsx'); // One inset, and the header/footer must not add their own on top of it — diff --git a/packages/core/src/DateInput/PickerWheel.test.tsx b/packages/core/src/DateInput/PickerWheel.test.tsx new file mode 100644 index 0000000000000..86a81de3224cd --- /dev/null +++ b/packages/core/src/DateInput/PickerWheel.test.tsx @@ -0,0 +1,1210 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file PickerWheel.test.tsx + * @input Uses Vitest, Testing Library, the touch picker column and touch events + * dispatched through the gesture system with a fake clock + * @output Proves what a wheel commits and when: a drag, a release onto a row, + * past the bounds, a fling, a tap, a finger catching a moving wheel, the + * keyboard, a click, a programmatic spin, looping, a custom rest rule + * @position Unit coverage for PickerWheel.tsx (the Wheel under a finger; + * Wheel.test.tsx covers the switch and today's column); the curves themselves are proven in + * wheelPhysics.test.ts and wheelDrum.test.ts, and on screen by the browser + * specs + */ + +import {act, fireEvent, render, screen, within} from '@testing-library/react'; +import {Profiler} from 'react'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import {timing} from '../motion/uikit'; +import {colorVars, fontWeightVars} from '../theme/tokens.stylex'; +import {PickerWheel} from './PickerWheel'; +import type {WheelOption, WheelProps} from './wheelOptions'; +import {WHEEL_ROW_PITCH, todayLook} from './wheelLook'; + +const ROW = WHEEL_ROW_PITCH; +/** The column is laid out 216 px tall, its band's centre 108 px down. */ +const CENTRE = 108; +/** The row resting on the band, as today's look draws it in that column. */ +const AT_BAND = `translateY(0px) ${todayLook(2 * CENTRE).rowFrame(0).transform}`; + +/** Hours 0-23 with 09:00-17:00 bounds: 0-8 and 18-23 are disabled. */ +const HOURS: WheelOption[] = Array.from({length: 24}, (_, hour) => ({ + value: hour, + label: String(hour).padStart(2, '0'), + isDisabled: hour < 9 || hour > 17, +})); + +const MINUTES: WheelOption[] = Array.from({length: 60}, (_, minute) => ({ + value: minute, + label: String(minute).padStart(2, '0'), +})); + +// One fake clock for the whole file: the gesture system is one per document +// and keeps its own timeline, so time must never run backwards between tests. +beforeAll(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'performance', + 'Date', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ], + }); +}); + +// The touch column draws only where the primary pointer is coarse, and only +// there does the gesture host take input (spec:AST-094 FR19): every case runs +// under a coarse primary pointer, as on a phone or a tablet. +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +}); + +afterEach(() => { + // Let every motion and gesture timer of the test run out. + vi.advanceTimersByTime(10_000); + vi.unstubAllGlobals(); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +function renderWheel(props: Partial = {}) { + const onChange = vi.fn(); + const view = render( + , + ); + const column = screen.getByRole('listbox', {name: props.label ?? 'Hour'}); + column.getBoundingClientRect = () => ({ + top: 0, + bottom: 216, + left: 0, + right: 120, + width: 120, + height: 216, + x: 0, + y: 0, + toJSON() {}, + }); + const option = (label: string): HTMLElement => + screen.getByRole('option', {name: label}); + return {...view, onChange, column, option}; +} + +/** Each finger gets its own identifier, as a new contact does. */ +let nextFinger = 1; +let finger = 0; + +/** One touch event at the fake clock's time. */ +function touch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: Element, + y: number, +) { + if (type === 'touchstart') { + finger = nextFinger++; + } + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: finger, clientX: 60, clientY: 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 that lands at `from`, moves to `to` in `steps` events `interval` + * ms apart, optionally holds still, and lifts. + */ +function drag( + target: Element, + from: number, + to: number, + {steps = 10, interval = 16, hold = 0} = {}, +) { + touch('touchstart', target, from); + for (let i = 1; i <= steps; i++) { + act(() => { + vi.advanceTimersByTime(interval); + }); + touch('touchmove', target, from + ((to - from) * i) / steps); + } + if (hold > 0) { + act(() => { + vi.advanceTimersByTime(hold); + }); + } + touch('touchend', target, to); +} + +function advance(ms: number) { + act(() => { + vi.advanceTimersByTime(ms); + }); +} + +/** + * How far below the band's centre a row is drawn: its place along the + * strip, the strip's translation, and any whole cycles a looping row moves. + */ +function distanceOf(element: Element): number { + const row = element as HTMLElement; + const strip = row.parentElement!; + const index = Array.prototype.indexOf.call(strip.children, row); + const translateY = (transform: string) => + Number(/translateY\((-?[\d.]+)px\)/.exec(transform)?.[1] ?? 0); + return ( + index * ROW + + translateY(strip.style.transform) + + translateY(row.style.transform) + ); +} + +describe('PickerWheel — at rest', () => { + it('is a listbox with one option per row, each label once', () => { + const {column} = renderWheel(); + expect(screen.getAllByRole('option')).toHaveLength(24); + // Each option is drawn once, as its own row. + expect(screen.getAllByText('10')).toHaveLength(1); + expect(column).toHaveAttribute('tabindex', '0'); + }); + + it('parks the committed row under the band, the others on the cylinder', () => { + const {option, column} = renderWheel(); + expect(option('10').style.transform).toBe(AT_BAND); + expect(distanceOf(option('11'))).toBeCloseTo(ROW, 2); + expect(distanceOf(option('09'))).toBeCloseTo(-ROW, 2); + expect(column.getAttribute('aria-activedescendant')).toBe(option('10').id); + }); + + it('points the active row at a value set from outside in the same commit', () => { + // One entry per commit: the active descendant the listbox shows then. + const commits: (string | null)[] = []; + const onRender = () => { + commits.push( + screen + .getByRole('listbox', {name: 'Hour'}) + .getAttribute('aria-activedescendant'), + ); + }; + const wheel = (value: number) => ( + + {}} + /> + + ); + const {rerender} = render(wheel(10)); + commits.length = 0; + rerender(wheel(12)); + expect(commits).toEqual([screen.getByRole('option', {name: '12'}).id]); + }); + + it('opening on a value outside the bounds commits nothing', () => { + const {onChange, column, option} = renderWheel({value: 8}); + advance(2000); + expect(onChange).not.toHaveBeenCalled(); + expect(column.getAttribute('aria-activedescendant')).toBe(option('08').id); + }); + + it('rests back on a committed value outside the bounds and commits nothing', () => { + // component:DateInput FR13: a wheel resting on its committed row commits + // nothing, even outside the bounds, as the scrolling column keeps it. + const {column, option, onChange} = renderWheel({value: 8}); + advance(2000); + // One gesture: up a row onto 09 (enabled), back down onto 08, held + // still, lifted. + const away = 10 + ROW; + touch('touchstart', column, 100); + for (let i = 1; i <= 15; i++) { + advance(40); + touch('touchmove', column, 100 - (away * i) / 15); + } + for (let i = 1; i <= 15; i++) { + advance(40); + touch('touchmove', column, 100 - away + (away * i) / 15); + } + advance(300); + touch('touchend', column, 100); + advance(2000); + expect(onChange).not.toHaveBeenCalled(); + expect(option('08').style.transform).toBe(AT_BAND); + // Back on the row it left, the band's mark is back on it too. + expect(isMarked(option('08'))).toBe(true); + }); + + it('turns onto a value set from outside while it is shown, as a bound clamping the time (component:DateInput FR13)', () => { + // jsdom runs no Web Animations: record what the wheel asks of its strip. + const strips: Keyframe[][] = []; + Element.prototype.animate = vi.fn(function animate( + this: Element, + keyframes: Keyframe[], + ) { + if (this.hasAttribute('data-wheel-strip')) { + strips.push(keyframes); + } + return {cancel: () => {}} as unknown as Animation; + }); + try { + const {rerender, option, onChange} = renderWheel(); + rerender( + , + ); + // From 10 onto 14 through the rows between: a turn, never a jump. + expect(strips).toHaveLength(1); + const ys = strips[0].map(frame => + Number(/translateY\((-?[\d.]+)px\)/.exec(String(frame.transform))?.[1]), + ); + expect(ys[0]).toBeCloseTo(-10 * ROW, 1); + expect(ys[ys.length - 1]).toBeCloseTo(-14 * ROW, 1); + expect( + ys.filter(y => y < -10 * ROW - 1 && y > -14 * ROW + 1).length, + ).toBeGreaterThanOrEqual(4); + advance(2000); + expect(option('14').style.transform).toBe(AT_BAND); + expect(isMarked(option('14'))).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(Element.prototype, 'animate'); + } + }); + + it('parks a value set while it is hidden at once when it is shown again', () => { + const {rerender, option, onChange} = renderWheel(); + const wheel = (value: number, isActive: boolean) => ( + + ); + rerender(wheel(10, false)); + rerender(wheel(14, false)); + rerender(wheel(14, true)); + expect(option('14').style.transform).toBe(AT_BAND); + }); +}); + +/** Whether a row carries the band's mark: painted, or styled in render and not overridden. */ +function isMarked(row: HTMLElement): boolean { + if (row.style.fontWeight !== '') { + return row.style.fontWeight === fontWeightVars['--font-weight-semibold']; + } + return row.className.includes('rowActive'); +} + +describe('PickerWheel — a finger', () => { + it('turns text selection off while a finger holds it (spec:AST-094 FR41)', () => { + // A held finger turns the wheel, so its pan takes the selection lock. + // Outside iOS WebKit (jsdom here) the lock refuses a selection that + // starts under the finger by cancelling `selectstart`, and writes no + // style. + const {column, option} = renderWheel(); + const refuses = () => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + option('10').dispatchEvent(event); + return event.defaultPrevented; + }; + expect(refuses()).toBe(false); + touch('touchstart', column, CENTRE); + expect(refuses()).toBe(true); + expect(column.style.getPropertyValue('user-select')).toBe(''); + advance(300); + touch('touchend', column, CENTRE); + expect(refuses()).toBe(false); + }); + + it('marks no row while a fling coasts, then the row it rests on', () => { + const {column, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + const marked = () => + screen + .getAllByRole('option') + .filter(isMarked) + .map(row => row.textContent); + expect(marked()).toEqual(['10']); + drag(column, 180, 60, {steps: 6, interval: 16}); + // The coast plays (jsdom shows its end inline, but no row is marked). + expect(marked()).toEqual([]); + advance(4000); + expect(onChange).toHaveBeenCalledTimes(1); + const rested = String(onChange.mock.calls[0][0]).padStart(2, '0'); + expect(marked()).toEqual([rested]); + }); + + it('drags the rows with it, without committing until the wheel rests', () => { + const {column, option, onChange} = renderWheel(); + touch('touchstart', column, 150); + for (let i = 1; i <= 8; i++) { + advance(16); + touch('touchmove', column, 150 - i * 6); + } + // 48 px of travel, 10 of it the pan's hysteresis: 38 px up. + expect(distanceOf(option('10'))).toBeCloseTo(-38, 1); + expect(onChange).not.toHaveBeenCalled(); + touch('touchend', column, 102); + advance(2000); + }); + + it('eases onto the nearest row after a slow release and commits it there', () => { + const {column, onChange} = renderWheel(); + // 10 px of hysteresis + 40 px: 1.25 rows, held still, lifted. + drag(column, 150, 100, {steps: 25, interval: 40, hold: 300}); + expect(onChange).not.toHaveBeenCalled(); + advance(1000); + expect(onChange).toHaveBeenCalledWith(11); + }); + + it('keeps the band’s mark on the committed row after a nudge that eases back onto it', () => { + const {column, option, onChange} = renderWheel(); + expect(isMarked(option('10'))).toBe(true); + // 10 px of hysteresis + a third of a row, held still, lifted. + drag(column, 150, 150 - (10 + ROW / 3), { + steps: 10, + interval: 40, + hold: 300, + }); + advance(2000); + expect(onChange).not.toHaveBeenCalled(); + expect(option('10').style.transform).toBe(AT_BAND); + expect(isMarked(option('10'))).toBe(true); + }); + + it('turns on to the minimum after resting below it, and commits that', () => { + const {column, option, onChange} = renderWheel(); + // Down 3 rows (+10 px of hysteresis): the wheel rests on 07, disabled. + drag(column, 60, 60 + 10 + 3 * ROW, {steps: 30, interval: 40, hold: 300}); + advance(100); + expect(onChange).not.toHaveBeenCalled(); + advance(2000); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(9); + expect(option('09').style.transform).toBe(AT_BAND); + }); + + it('flings across several rows before committing', () => { + const {column, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + drag(column, 180, 60, {steps: 6, interval: 16}); + advance(200); + expect(onChange).not.toHaveBeenCalled(); + advance(4000); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toBeGreaterThan(15); + }); + + it('rubber-bands past the last row and returns to it', () => { + const {column, option, onChange} = renderWheel({value: 17}); + // Up far past hour 23. + drag(column, 200, 20, {steps: 30, interval: 30, hold: 300}); + // Past the end, the last row has moved above the band by less than the + // finger travelled. + advance(3000); + // It rests on 23 (disabled), then turns back onto 17. + advance(2000); + expect(onChange).not.toHaveBeenCalledWith(23); + expect(option('17').style.transform).toBe(AT_BAND); + }); + + it('is stopped where it is by a finger that lands on it, and commits nothing', () => { + const {column, option, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + drag(column, 180, 60, {steps: 6, interval: 16}); + advance(150); + // jsdom runs no animations: a motion shows its end inline. + const heading = distanceOf(option('10')); + touch('touchstart', column, 100); + const caught = distanceOf(option('10')); + // Stopped where it was on screen, well short of where it was heading, + // and held there. + expect(Math.abs(caught - heading)).toBeGreaterThan(ROW); + advance(50); + expect(Math.abs(distanceOf(option('10')) - caught)).toBeLessThan(1); + touch('touchend', column, 100); + // A catch is not a tap: no turn onto the touched row, only the snap. + advance(1000); + expect(onChange).toHaveBeenCalledTimes(1); + const rested = onChange.mock.calls[0][0] as number; + expect(Math.abs(distanceOf(option(String(rested).padStart(2, '0'))))).toBe( + 0, + ); + }); + + it('turns onto a tapped row and commits it on arrival', () => { + const {column, onChange} = renderWheel(); + const below = CENTRE + ROW; + touch('touchstart', column, below); + advance(60); + touch('touchend', column, below); + expect(onChange).not.toHaveBeenCalled(); + advance(310); + expect(onChange).toHaveBeenCalledWith(11); + }); + + it('a finger’s tap turns once: its lift is cancelled, so the browser sends no click', () => { + // spec:AST-094 FR19: the tap recognizer owns a finger's tap, and the + // row's click handler is left to keys, assistive technology and a mouse. + const {column, onChange} = renderWheel(); + const below = CENTRE + ROW; + touch('touchstart', column, below); + advance(60); + const lift = new Event('touchend', {bubbles: true, cancelable: true}); + const entry = { + identifier: finger, + clientX: 60, + clientY: below, + target: column, + }; + Object.defineProperty(lift, 'changedTouches', {value: [entry]}); + Object.defineProperty(lift, 'touches', {value: []}); + Object.defineProperty(lift, 'timeStamp', {value: performance.now()}); + act(() => { + column.dispatchEvent(lift); + }); + expect(lift.defaultPrevented).toBe(true); + advance(310); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(11); + }); + + it('does nothing for a tap on the band’s row', () => { + const {column, onChange} = renderWheel(); + touch('touchstart', column, CENTRE); + advance(60); + touch('touchend', column, CENTRE); + advance(500); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('drops a drag it is hidden in, and parks the committed row when shown again', () => { + const {column, onChange, option, rerender} = renderWheel(); + const props = {label: 'Hour', options: HOURS, value: 10, onChange}; + // A finger drags the rows two rows up; the wheel is hidden before it lifts. + touch('touchstart', column, 150); + for (let i = 1; i <= 6; i++) { + advance(16); + touch('touchmove', column, 150 - 12 * i); + } + expect(distanceOf(option('10'))).not.toBeCloseTo(0, 2); + rerender(); + touch('touchend', column, 78); + advance(1000); + rerender(); + advance(100); + expect(onChange).not.toHaveBeenCalled(); + expect(distanceOf(option('10'))).toBeCloseTo(0, 4); + }); + + it('ignores fingers while hidden', () => { + const {column, option, onChange} = renderWheel({isActive: false}); + const before = option('10').style.transform; + drag(column, 150, 60, {steps: 10, interval: 16}); + advance(3000); + expect(onChange).not.toHaveBeenCalled(); + expect(option('10').style.transform).toBe(before); + }); +}); + +describe('PickerWheel — keyboard and clicks', () => { + it('commits each arrow key at once, stepping from the row it is heading to', () => { + const {column, onChange} = renderWheel(); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(11); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(12); + }); + + it('skips disabled rows and stops at the bounds', () => { + const {column, onChange} = renderWheel({value: 9}); + fireEvent.keyDown(column, {key: 'ArrowUp'}); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.keyDown(column, {key: 'End'}); + expect(onChange).toHaveBeenLastCalledWith(17); + }); + + it('keeps the band’s mark on the last row when a key has nowhere further to go', () => { + const {column, option, onChange} = renderWheel({ + options: MINUTES, + value: 59, + label: 'Minute', + }); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + advance(1000); + expect(onChange).not.toHaveBeenCalled(); + expect(option('59').style.transform).toBe(AT_BAND); + expect(isMarked(option('59'))).toBe(true); + }); + + it('never steps the long way round a loop onto the far side of a disabled stretch', () => { + // A year of months, looping, with only January and February enabled: + // up from January is December, and the nearest enabled row to it is + // January again, so the key stays where it is rather than turning + // eleven rows round to February. + const MONTHS: WheelOption[] = Array.from({length: 12}, (_, month) => ({ + value: month, + label: `M${String(month + 1).padStart(2, '0')}`, + isDisabled: month > 1, + })); + const {column, option, onChange} = renderWheel({ + options: MONTHS, + value: 0, + label: 'Month', + isLooping: true, + }); + fireEvent.keyDown(column, {key: 'ArrowUp'}); + advance(1000); + expect(onChange).not.toHaveBeenCalled(); + expect(distanceOf(option('M01'))).toBeCloseTo(0, 1); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(1); + }); + + it('takes Home and End to the first and last enabled rows, even mid-coast', () => { + const {column, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + drag(column, 180, 60, {steps: 6, interval: 16}); + advance(200); + // Still coasting, towards a row well past 10. + expect(onChange).not.toHaveBeenCalled(); + fireEvent.keyDown(column, {key: 'Home'}); + expect(onChange).toHaveBeenLastCalledWith(0); + fireEvent.keyDown(column, {key: 'End'}); + expect(onChange).toHaveBeenLastCalledWith(59); + }); + + it('commits a clicked row at once and never a disabled one', () => { + const {option, onChange} = renderWheel(); + fireEvent.click(option('07')); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(option('13')); + expect(onChange).toHaveBeenCalledWith(13); + }); +}); + +describe('PickerWheel — a mouse', () => { + /** One mouse pointer event at the fake clock's time. */ + function mouse( + type: 'pointerdown' | 'pointermove' | 'pointerup', + target: Element, + y: number, + {button = 0, buttons = 1}: {button?: number; buttons?: number} = {}, + ) { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId: 41, + pointerType: 'mouse', + isPrimary: true, + clientX: 60, + clientY: y, + button, + buttons, + }); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + act(() => { + target.dispatchEvent(event); + }); + } + + /** + * A slow drag up by `distance` px with `button` held (well under the + * release velocity floor, so the wheel rests where it is let go), then a + * release. + */ + function mouseDrag(target: Element, distance: number, button = 0) { + const buttons = button === 0 ? 1 : 2; + mouse('pointerdown', target, 150, {button, buttons}); + for (let i = 1; i <= 25; i++) { + advance(40); + mouse('pointermove', target, 150 - (distance * i) / 25, { + button: -1, + buttons, + }); + } + advance(300); + mouse('pointerup', target, 150 - distance, {button, buttons: 0}); + } + + it('turns with a primary-button drag and commits where it rests', () => { + // A mouse on a touch device (a tablet's trackpad): the primary pointer + // stays coarse. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(true); + const {column, onChange} = renderWheel(); + // 10 px of hysteresis + 40 px: 1.25 rows. + mouseDrag(column, 50); + advance(1000); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(11); + }); + + it('does nothing for a secondary-button drag', () => { + // A mouse on a touch device (a tablet's trackpad): the primary pointer + // stays coarse. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(true); + const {column, onChange} = renderWheel(); + mouseDrag(column, 50, 2); + advance(2000); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('PickerWheel — a scroll wheel', () => { + /** One wheel event over the column; returns whether it was cancelled. */ + function wheel(target: Element, deltaY: number, ctrlKey = false): boolean { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + ctrlKey, + }); + act(() => { + target.dispatchEvent(event); + }); + return event.defaultPrevented; + } + + it('turns with a scroll and rests on a row', () => { + const {column, onChange} = renderWheel(); + // Two rows down. + expect(wheel(column, 64)).toBe(true); + advance(1000); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(12); + }); + + it('leaves a ctrl-wheel, a trackpad pinch, to the page (spec:AST-094 FR10, FR43)', () => { + const {column, onChange} = renderWheel(); + expect(wheel(column, 64, true)).toBe(false); + advance(1000); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('PickerWheel — under reduced motion', () => { + const FADE_MS = timing.reducedMotionCrossFadeMs; + /** Every animation the wheel started, with its keyframes and timing. */ + let animations: {keyframes: Keyframe[]; options: KeyframeAnimationOptions}[]; + + beforeEach(() => { + animations = []; + vi.stubGlobal('matchMedia', (query: string) => ({ + // The touch column draws under a coarse primary pointer. + matches: + query === '(prefers-reduced-motion: reduce)' || + query === '(pointer: coarse)', + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + // jsdom runs no Web Animations: record what the wheel asks for. + Element.prototype.animate = vi.fn(function animate( + keyframes: Keyframe[], + options: KeyframeAnimationOptions, + ) { + animations.push({keyframes, options}); + return {cancel: () => {}} as unknown as Animation; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(Element.prototype, 'animate'); + }); + + /** + * Nothing travels, on the Reduce Motion fade (design:reduced-motion DR2): + * the drawing's opacity dips out and back in, and the rows and the strip + * hold where they are until the fade's midpoint, then step onto their + * rows, on the compositor. + */ + function expectFadesOnly() { + let fades = 0; + for (const {keyframes, options} of animations) { + const properties = new Set( + keyframes.flatMap(frame => + Object.keys(frame).filter( + key => key !== 'offset' && key !== 'easing', + ), + ), + ); + if (!properties.has('transform')) { + expect([...properties]).toEqual(['opacity']); + expect(options.duration).toBe(FADE_MS); + fades += 1; + continue; + } + // A landing: the origin held, then the destination, stepping at the + // fade's midpoint. + expect(keyframes).toHaveLength(4); + const [origin, held, landed, end] = keyframes; + expect(origin.offset).toBe(0); + expect(held).toEqual({...origin, offset: held.offset}); + expect(landed.offset).toBe(held.offset); + expect(end).toEqual({...landed, offset: 1}); + expect(Number(held.offset) * Number(options.duration)).toBeCloseTo( + FADE_MS / 2, + 6, + ); + } + expect(fades).toBeGreaterThan(0); + } + + it('coasts a fling as momentum, its rows flat, then commits it', () => { + // design:reduced-motion DR2: the momentum that continues a throw is + // direct movement, not a transition, so it plays; only the rows' tip + // gives way to their fade. + const {column, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + drag(column, 180, 60, {steps: 6, interval: 16}); + expect(animations.length).toBeGreaterThan(0); + for (const {keyframes, options} of animations) { + expect(options.duration).toBeGreaterThan(FADE_MS); + for (const frame of keyframes) { + // The strip travels; the rows stay upright and full size. + expect(String(frame.transform)).not.toMatch( + /rotateX\((?!0deg)|scale\((?!1\))/, + ); + } + } + expect(onChange).not.toHaveBeenCalled(); + advance(4000); + expect(onChange).toHaveBeenCalledTimes(1); + // The row the fling projects, as without the preference. + expect(onChange.mock.calls[0][0]).toBeGreaterThan(15); + }); + + it('fades a slow release onto its row', () => { + // A release that snaps is a transition the wheel starts: it cross-fades. + const {column, onChange} = renderWheel(); + drag(column, 60, 60 + 10 + 1.4 * ROW, {steps: 20, interval: 40, hold: 300}); + expectFadesOnly(); + advance(FADE_MS); + expect(onChange).toHaveBeenLastCalledWith(9); + }); + + it('fades for a tap and a key', () => { + const {column, onChange} = renderWheel(); + const below = CENTRE + ROW; + touch('touchstart', column, below); + advance(60); + touch('touchend', column, below); + advance(FADE_MS); + expect(onChange).toHaveBeenLastCalledWith(11); + // A key steps from the row the wheel rests on (the tap's 11). + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(12); + advance(FADE_MS); + expectFadesOnly(); + }); +}); + +describe('PickerWheel — mechanics for its owners', () => { + it('keeps a finger that lands on it from an enclosing sheet', () => { + // A sheet arms its pull-down on its body's pointerdown. + const onPointerDown = vi.fn(); + const onChange = vi.fn(); + render( +
+ +
, + ); + const column = screen.getByRole('listbox', {name: 'Minute'}); + fireEvent.pointerDown(column, { + pointerId: 7, + pointerType: 'touch', + isPrimary: true, + button: 0, + }); + expect(onPointerDown).not.toHaveBeenCalled(); + }); + + it('loops: the first row follows the last', () => { + const {column, option, onChange} = renderWheel({ + options: MINUTES, + value: 59, + label: 'Minute', + isLooping: true, + }); + // Minute 00 is drawn just below 59. + expect(distanceOf(option('00'))).toBeCloseTo(ROW, 2); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(0); + }); + + it('keeps coasting when its owner hands it a copy of the same rows', () => { + const {column, rerender, onChange} = renderWheel({ + options: MINUTES, + value: 10, + label: 'Minute', + }); + drag(column, 180, 60, {steps: 6, interval: 16}); + advance(200); + rerender( + ({...option}))} + value={10} + onChange={onChange} + />, + ); + advance(4000); + // It came to rest on a row and committed it, as without the re-render. + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toBeGreaterThan(15); + }); +}); + +describe('PickerWheel — a short looping list', () => { + /** A minute column every 15 minutes, and one every 30. */ + const QUARTERS: WheelOption[] = [0, 15, 30, 45].map(minute => ({ + value: minute, + label: String(minute).padStart(2, '0'), + })); + const HALVES = QUARTERS.filter(option => option.value % 30 === 0); + + /** Every row the strip draws, its options' copies included. */ + function stripRowsOf(column: HTMLElement): HTMLElement[] { + return [ + ...column.querySelectorAll( + '[data-wheel-strip] > [data-label]', + ), + ]; + } + + /** The labels in sight, top to bottom. */ + function shownLabels(column: HTMLElement): string[] { + return stripRowsOf(column) + .filter(row => Number(row.style.opacity) > 0) + .map(row => ({label: row.dataset.label!, distance: distanceOf(row)})) + .sort((a, b) => a.distance - b.distance) + .map(({label}) => label); + } + + /** The row drawn on the band, by its place among the strip's rows. */ + function bandRow(column: HTMLElement): number { + return stripRowsOf(column).findIndex( + row => Math.abs(distanceOf(row)) < 1e-6, + ); + } + + it('fills the column with copies, as UIPickerView’s looping table does', () => { + const {column} = renderWheel({ + options: QUARTERS, + value: 45, + label: 'Minute', + isLooping: true, + }); + // At :45 the list runs on both ways down today's column, eleven rows in + // sight (UIKit's cylinder shows the same run, nine rows). + expect(shownLabels(column)).toEqual([ + '30', + '45', + '00', + '15', + '30', + '45', + '00', + '15', + '30', + '45', + '00', + ]); + // One option per value for assistive technology; the copies are hidden. + expect(within(column).getAllByRole('option')).toHaveLength(4); + for (const copy of column.querySelectorAll('[data-wheel-copy]')) { + expect(copy).toHaveAttribute('aria-hidden', 'true'); + } + }); + + it('turns onto a tapped copy, and commits nothing when it holds the same value', () => { + const {column, onChange} = renderWheel({ + options: HALVES, + value: 30, + label: 'Minute', + isLooping: true, + }); + expect(shownLabels(column)).toEqual([ + '00', + '30', + '00', + '30', + '00', + '30', + '00', + '30', + '00', + '30', + '00', + ]); + const rows = stripRowsOf(column).length; + const start = bandRow(column); + // Two rows below the band: the next copy of 30. + const twoBelow = CENTRE + 2 * ROW; + touch('touchstart', column, twoBelow); + advance(60); + touch('touchend', column, twoBelow); + advance(500); + expect((bandRow(column) - start + rows) % rows).toBe(2); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('steps by key in the key’s direction across the seam', () => { + const {column, onChange} = renderWheel({ + options: QUARTERS, + value: 45, + label: 'Minute', + isLooping: true, + }); + const rows = stripRowsOf(column).length; + const start = bandRow(column); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + expect(onChange).toHaveBeenLastCalledWith(0); + advance(500); + expect((bandRow(column) - start + rows) % rows).toBe(1); + }); + + it('stays put when a key would turn onto a copy of its own row', () => { + // PageDown moves ten rows: on a two-row loop that is a copy of the row + // under the band, so nothing turns and nothing commits. + const {column, onChange} = renderWheel({ + options: HALVES, + value: 30, + label: 'Minute', + isLooping: true, + }); + const start = bandRow(column); + fireEvent.keyDown(column, {key: 'PageDown'}); + advance(1000); + expect(bandRow(column)).toBe(start); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('turns the shorter way round from a rest beside the seam onto the nearest enabled row', () => { + // :00 and :15 disabled. One row on from :45 rests on :00, across the + // seam: :45 is one row back, :30 two rows on, so it turns back to :45 + // and commits nothing. + const {column, onChange} = renderWheel({ + options: QUARTERS.map(option => ({ + ...option, + isDisabled: option.value === 0 || option.value === 15, + })), + value: 45, + label: 'Minute', + isLooping: true, + }); + const rows = stripRowsOf(column).length; + const start = bandRow(column); + // 10 px of hysteresis + a row, held still, lifted. + drag(column, 150, 150 - (10 + ROW), {steps: 25, interval: 40, hold: 300}); + advance(2000); + expect((bandRow(column) - start + rows) % rows).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('repaints the band’s row in its new colour when the bounds change under it', () => { + const {column, rerender, onChange} = renderWheel({ + options: QUARTERS, + value: 15, + label: 'Minute', + isLooping: true, + }); + const band = () => stripRowsOf(column)[bandRow(column)]; + expect(band().style.color).toBe(colorVars['--color-text-accent']); + rerender( + ({ + ...option, + isDisabled: option.value === 15, + }))} + value={15} + onChange={onChange} + isLooping + />, + ); + expect(band().style.color).toBe(colorVars['--color-text-disabled']); + }); + + it('never turns a whole revolution when no other row is enabled', () => { + // Only :15 is enabled, so an arrow key has nowhere to go. + const {column, onChange} = renderWheel({ + options: QUARTERS.map(option => ({ + ...option, + isDisabled: option.value !== 15, + })), + value: 15, + label: 'Minute', + isLooping: true, + }); + const start = bandRow(column); + fireEvent.keyDown(column, {key: 'ArrowDown'}); + advance(1000); + expect(bandRow(column)).toBe(start); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('flings on through the seam, its copies drawn all the way', () => { + const {column, onChange} = renderWheel({ + options: QUARTERS, + value: 0, + label: 'Minute', + isLooping: true, + }); + // 120 px in 90 ms, UIKit's measured fling: several turns of the list. + drag(column, 180, 60, {steps: 6, interval: 15}); + advance(4000); + // A full column at rest, and the value on the band committed. + expect(shownLabels(column)).toHaveLength(11); + const rested = Number(stripRowsOf(column)[bandRow(column)].dataset.label); + if (rested === 0) { + expect(onChange).not.toHaveBeenCalled(); + } else { + expect(onChange).toHaveBeenLastCalledWith(rested); + } + }); + + it('marks the copy on the band, wherever it is', () => { + const {column} = renderWheel({ + options: QUARTERS, + value: 45, + label: 'Minute', + isLooping: true, + }); + const onBand = stripRowsOf(column)[bandRow(column)]; + expect(onBand.dataset.label).toBe('45'); + expect(onBand.style.fontWeight).not.toBe(''); + // The other copies of 45 in sight, the option itself among them, carry + // no mark. + const others = stripRowsOf(column).filter( + row => row !== onBand && row.dataset.label === '45', + ); + expect(others.length).toBeGreaterThan(1); + for (const row of others) { + expect(row.style.fontWeight).not.toBe(onBand.style.fontWeight); + } + }); + + it('marks the band’s row when a short loop opens on its first row', () => { + // Offset 0: parking moves nothing, so no new row is reported. + const {column} = renderWheel({ + options: QUARTERS, + value: 0, + label: 'Minute', + isLooping: true, + }); + const onBand = stripRowsOf(column)[bandRow(column)]; + expect(onBand.dataset.label).toBe('00'); + expect(onBand.style.fontWeight).toBe( + fontWeightVars['--font-weight-semibold'], + ); + }); + + it('keeps a disabled minute’s colour on a copy under the band', () => { + const options = QUARTERS.map(option => + option.value === 30 ? {...option, isDisabled: true} : option, + ); + const {column} = renderWheel({ + options, + value: 45, + label: 'Minute', + isLooping: true, + }); + // A finger holds the wheel a row back: the copy of 30 before the band's + // 45 (itself a copy) reaches the band. + touch('touchstart', column, CENTRE); + advance(16); + touch('touchmove', column, CENTRE + 20); + advance(16); + touch('touchmove', column, CENTRE + 20 + ROW); + const near = [...stripRowsOf(column)].sort( + (a, b) => Math.abs(distanceOf(a)) - Math.abs(distanceOf(b)), + )[0]; + expect(near.dataset.label).toBe('30'); + expect(near.hasAttribute('data-wheel-copy')).toBe(true); + expect(near.style.color).toBe(colorVars['--color-text-disabled']); + touch('touchend', column, CENTRE + 20 + ROW); + }); + + it('keeps a two-row list that does not loop to its two rows (AM/PM)', () => { + const {column} = renderWheel({ + options: [ + {value: 0, label: 'AM'}, + {value: 1, label: 'PM'}, + ], + value: 0, + label: 'AM/PM', + }); + expect(shownLabels(column)).toEqual(['AM', 'PM']); + expect(column.querySelectorAll('[data-wheel-copy]')).toHaveLength(0); + }); +}); + +describe('PickerWheel — markup', () => { + // Written before the listbox wiring moved into useWheelListbox: the + // snapshot holds the touch column to its markup. + it('keeps the touch column’s markup', () => { + const {column} = renderWheel(); + // React's generated ids depend on how many renders ran before. + expect( + column.parentElement?.innerHTML.replace(/_r_[0-9a-z]+_/g, '_r_id_'), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/DateInput/PickerWheel.tsx b/packages/core/src/DateInput/PickerWheel.tsx new file mode 100644 index 0000000000000..88a5927550d2b --- /dev/null +++ b/packages/core/src/DateInput/PickerWheel.tsx @@ -0,0 +1,1042 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file PickerWheel.tsx + * @input Wheel's props (wheelOptions.ts): option list, selected value, + * change callback; optionally looping + * @output Exports PickerWheel — one picker column that moves like UIPickerView + * @position Internal component; the Wheel under a finger (Wheel.tsx picks it) + * + * This column moves like UIPickerView's and keeps today's look, under a + * finger on every touch device (one touch model, iOS and Android alike); + * Wheel.tsx draws today's scrolling column under a mouse or other fine + * pointer. It is rebuilt on the gesture system and the UIKit constants table + * rather than on a native scroller, because a native scroll-snap container + * cannot move like a picker: WebKit gives every + * scroll-snapping scroller the fast deceleration rate (0.99 a millisecond), + * a UIPickerView decelerates at the normal rate (0.998) onto a row it picks + * from its own projection, and a scroller's momentum cannot be caught, + * measured or proven. + * + * - A finger drags the rows through a PanGestureRecognizer: every move + * writes the rows' transforms, past an end on UIKit's rubber band, and the + * lift reads a scroll view's release velocity, a pan's `scrollVelocity()`. + * - The lift starts the motion UIKit would (wheelPhysics.ts: a coast onto a + * row, a snap, a coast into an end and its bounce, a return from past an + * end), baked per row and played on the compositor (wheelMotion.ts), so a + * busy main thread neither stops nor jumps it. A finger landing on a moving + * wheel stops it where it is on screen. + * - A tap on a row turns the wheel onto it on the tap spin (0.3 s, + * `picker.tapSpin.durationMs`, on `timing.curves.move`). A tap on the + * band's row does nothing. An end bounces on `scroll.bounce`. The curve + * and the bounce are resolved when their motion starts (internal roles, + * motion/resolveMotion.ts). + * - A scroll wheel turns the rows by its delta and snaps onto the nearest + * row once it stops; a ctrl-wheel, as a trackpad pinch arrives, is a pinch + * (`spec:AST-094` FR43), and with no pinch of its own the wheel leaves it + * to the page's zoom (FR10). + * - The value commits when the wheel comes to rest, as UIPickerView's + * didSelectRow does. A rest on a disabled row (outside the bounds) is + * followed by a turn onto the nearest enabled row, which then commits; a + * rest on the committed row commits nothing, even outside the bounds + * (component:DateInput FR13). + * - Each row reaching the band ticks a selection haptic where the browser + * can vibrate (not iOS Safari, which cannot play haptics from script). + * - Under reduced motion (design:reduced-motion DR2) the drag follows the + * finger, and the momentum that continues a throw (a release's coast, and + * its bounce at an end) plays as is, as scrolling keeps decelerating under + * iOS Reduce Motion. Every transition the wheel starts (a slow release's + * snap, a return from past an end, the turn on from a disabled row, a tap, + * a key, a click, a scroll wheel's snap) lands without + * travel: the rows cross-fade onto their row, opacity only, on the + * animator's Reduce Motion fade (`reducedMotionFade`). + * + * The rows are today's (wheelLook.ts): each option is drawn once, as its own + * row (a short looping list adds hidden copies down the strip until it fills + * a loop), tipping and fading toward the column's edges as today's `view()` + * timeline draws it (only fading, under reduced motion), over today's band. + * The row under the band is marked as today's column marks it: in render at + * rest, painted directly on a drag frame, and moved where a motion stops or + * ends, so the compositor plays a motion with no layout or paint per frame. + * + * Accessibility: a listbox, one tab stop, `aria-activedescendant` on the + * committed row (a value set from outside shows in the same render). Arrow + * keys and Page Up/Down step to the next enabled row from the one the wheel + * is heading to; Home and End go to the first and the last enabled row. Each + * commits at once and turns the wheel there. + * + * SYNC: When modified, update: + * - /packages/core/src/DateInput/PickerWheel.test.tsx + * - /packages/core/src/DateInput/Wheel.tsx (the switch) + * - /packages/core/src/DateInput/DateInputTouch.test.tsx + * - /packages/core/src/DateInput/DateInput.doc.mjs + * - /packages/core/src/TimeInput/TimeInput.doc.mjs + */ + +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {PanGestureRecognizer} from '../gestures/PanGestureRecognizer'; +import {TapGestureRecognizer} from '../gestures/TapGestureRecognizer'; +import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +import {observeResize} from '../utils/sharedResizeObserver'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import {rubberBandInverse} from '../motion/rubberBand'; +import {resolveMotion} from '../motion/resolveMotion'; +import {picker} from '../motion/uikit/picker'; +import {registerScrollTable, scroll} from '../motion/uikit/scroll'; +import {registerTimingTable, timing} from '../motion/uikit/timing'; +import { + colorVars, + fontWeightVars, + radiusVars, + spacingVars, + typeScaleVars, +} from '../theme/tokens.stylex'; +import {useMediaQuery} from '../hooks/useMediaQuery'; +import {focusOutlineStyles} from '../utils'; +import {lockSelection} from '../gestures/selectionLock'; +import {dateInputTouchGeometry, dateInputTouchSizes} from './tokens.stylex'; +import {useOwnScrollGesture} from './useOwnScrollGesture'; +import {useWheelListbox} from './useWheelListbox'; +import {drumGeometry} from './wheelDrum'; +import {WHEEL_ROW_PITCH, todayLook} from './wheelLook'; +import {WheelMotion, type PlayOptions} from './wheelMotion'; +import {reducedMotionFade} from '../motion/reducedMotion'; +import { + dragOffset, + nearestIndex, + offsetBounds, + planRelease, + snapCurve, + spinCurve, + wrapIndex, + type WheelCurve, + type WheelLayout, +} from './wheelPhysics'; +import {restingStep, type WheelProps} from './wheelOptions'; + +/** Today's row pitch, px: the touch column draws today's look (wheelLook.ts). */ +const ROW_HEIGHT = WHEEL_ROW_PITCH; +/** UIKit's geometry scaled to the row: the rubber band's dimension. */ +const GEOMETRY = drumGeometry(ROW_HEIGHT); +/** Today's column, px: six 44 px day rows (`dateInputTouchGeometry.paneBlockSize`). */ +const COLUMN_HEIGHT = 6 * 44; +/** Wheel events stop moving the wheel after this long without one, ms. */ +const WHEEL_IDLE_MS = 160; + +const styles = stylex.create({ + // Today's column (Wheel.tsx): the same box, band and rows, moved by the + // gesture system and the compositor instead of a native scroller. + column: { + position: 'relative', + flex: '1 1 0', + minInlineSize: 0, + blockSize: dateInputTouchGeometry.paneBlockSize, + overflow: 'hidden', + outline: 'none', + // Gives the rows' tip somewhere to recede to, from the column's centre, + // as today's scroller does. + perspective: '520px', + WebkitTapHighlightColor: 'transparent', + }, + /** + * Today's selection band: a centred row-height plate behind the rows. + * Decorative: the committed value is announced by `aria-selected`. + */ + band: { + position: 'absolute', + insetInline: 0, + insetBlockStart: `calc(50% - (${dateInputTouchSizes.wheelItemSize} / 2))`, + blockSize: dateInputTouchSizes.wheelItemSize, + borderRadius: radiusVars['--radius-element'], + backgroundColor: colorVars['--color-neutral'], + pointerEvents: 'none', + }, + /** + * The strip: the rows at their places along the wheel, its top on the + * band's row, translated by minus the wheel's offset. Also what a + * reduced-motion cross-fade fades. + */ + strip: { + position: 'absolute', + insetInline: 0, + insetBlockStart: `calc(50% - (${dateInputTouchSizes.wheelItemSize} / 2))`, + transformStyle: 'preserve-3d', + pointerEvents: 'none', + }, + /** One row: an option at its place along the strip, tipped (wheelLook.ts). */ + row: { + position: 'absolute', + insetInline: 0, + blockSize: dateInputTouchSizes.wheelItemSize, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingInline: spacingVars['--spacing-2'], + boxSizing: 'border-box', + backfaceVisibility: 'hidden', + fontSize: typeScaleVars['--text-large-size'], + fontWeight: fontWeightVars['--font-weight-normal'], + color: colorVars['--color-text-primary'], + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + userSelect: 'none', + // No transition on the mark, unlike today's column: a colour or weight + // that animates lays out and paints every frame on the main thread, and + // a mark set as a drag lets go would do so through the coast + // (spec:AST-094 FR5). + }, + /** The row under the band, as today's column marks it. */ + rowActive: { + color: colorVars['--color-text-accent'], + fontWeight: fontWeightVars['--font-weight-semibold'], + }, + rowDisabled: { + color: colorVars['--color-text-disabled'], + }, + /** A row at its place along the strip. */ + rowAt: (top: string) => ({insetBlockStart: top}), +}); + +/** + * The motion tables the wheel resolves internal roles from: the timing curve + * and the scroll bounce (the picker's own values are constants). Nothing + * registers on import (motion/roles.ts) and each register is idempotent, so + * the wheel calls this right before it resolves a role. + */ +function registerWheelTables(): void { + registerTimingTable(); + registerScrollTable(); +} + +/** + * A turn onto `to` on the picker's tap spin (`picker.tapSpin.durationMs`), + * on `timing.curves.move` as resolved under `column` when the turn starts. + */ +function turnCurve( + column: Element | null, + from: number, + to: number, +): WheelCurve { + registerWheelTables(); + return spinCurve( + from, + to, + picker.tapSpin.durationMs / 1000, + resolveMotion(column, timing.curves.move), + ); +} + +/** A transition's options: the player's, and what started it. */ +interface MoveOptions extends PlayOptions { + /** Started by a touch drag's release (design:reduced-motion DR2). */ + touchRelease?: boolean; + /** + * The momentum that continues a throw: a coast, and its settle at an edge. + * It is direct movement, so it plays under reduced motion too + * (design:reduced-motion DR2), as scrolling keeps decelerating under iOS + * Reduce Motion. + */ + momentum?: boolean; +} + +/** Rows a looping wheel draws at least, so its seam is never in sight: the column's and one more. */ +const LOOP_ROWS = Math.floor((COLUMN_HEIGHT + ROW_HEIGHT) / ROW_HEIGHT) + 1; + +/** + * How many times a looping wheel of `rowCount` rows draws its list: a short + * list repeats down the strip until it fills a loop, as UIPickerView's + * looping table repeats around its cylinder. + */ +function loopCopies(rowCount: number): number { + return Math.max(1, Math.ceil(LOOP_ROWS / rowCount)); +} + +/** + * The position (an index along the strip, any integer when looping) of row + * `row` nearest `offset`: of its copies, `period` (the list's length) apart, + * the one nearest. + */ +function nearestPosition( + row: number, + offset: number, + layout: WheelLayout, + period: number, +): number { + if (!layout.looping) { + return row; + } + const cycles = Math.round((offset / layout.rowHeight - row) / period); + return row + cycles * period; +} + +interface DragState { + /** The finger's offset at the pan's start, before any rubber band. */ + readonly start: number; +} + +/** + * One UIPickerView column: the Wheel under a finger. + * + * @example + * ``` + * + * ``` + */ +export function PickerWheel({ + label, + options, + value, + onChange, + isActive = true, + isLooping = false, +}: WheelProps) { + const columnRef = useRef(null); + const rowsRef = useRef(null); + const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + const rowCount = options.length; + // UIPickerView's looping table loops a list of any length: a minute column + // every 15 or 30 minutes repeats down the whole column (measured). The + // motion runs over every row drawn, the copies included. + const loops = isLooping && rowCount > 1; + const copies = loops ? loopCopies(rowCount) : 1; + const layout = useMemo( + () => ({ + rowHeight: ROW_HEIGHT, + rowCount: rowCount * copies, + looping: loops, + }), + [rowCount, copies, loops], + ); + const [motion] = useState(() => new WheelMotion(layout)); + const selectedIndex = Math.max( + 0, + options.findIndex(option => option.value === value), + ); + const dragRef = useRef(null); + /** A finger landed on a moving wheel and stopped it. */ + const caughtRef = useRef(false); + + // The latest props, for the motion's and the gesture listeners' callbacks, + // which are attached once and outlive a render. + const latestRef = useRef({options, value, onChange, layout}); + useIsomorphicLayoutEffect(() => { + latestRef.current = {options, value, onChange, layout}; + }); + + // The row under the band is marked as today's column marks it. At rest + // that is the committed row, styled in render; while a finger drags the + // wheel, the row reaching the band is painted directly, so marking it never + // re-renders. A motion unmarks every row as it starts and marks the row it + // stops or ends on: the compositor plays it with no layout or paint per + // frame (spec:AST-094 FR5), and no mark rides away from the band. + const paintedRef = useRef>(new Set()); + const paintRow = useCallback((position: number | null) => { + const container = rowsRef.current; + if (container == null || container.children.length === 0) { + return; + } + const rows = container.children; + const {options: currentOptions, value: currentValue} = latestRef.current; + const active = + position == null + ? null + : (rows[wrapIndex(position, rows.length)] as HTMLElement); + const painted = paintedRef.current; + for (const row of painted) { + row.style.removeProperty('color'); + row.style.removeProperty('font-weight'); + } + painted.clear(); + const selected = currentOptions.findIndex( + option => option.value === currentValue, + ); + if (rows[selected] != null && rows[selected] !== active) { + // The committed row's own mark gives way while another row is under + // the band. + const row = rows[selected] as HTMLElement; + row.style.color = currentOptions[selected]?.isDisabled + ? colorVars['--color-text-disabled'] + : colorVars['--color-text-primary']; + row.style.fontWeight = fontWeightVars['--font-weight-normal']; + painted.add(row); + } + if (active == null || position == null) { + return; + } + // A copy's option: the strip repeats the list, so the row's place in the + // list, not on the strip, names it. + const index = wrapIndex(position, currentOptions.length); + active.style.color = currentOptions[index]?.isDisabled + ? colorVars['--color-text-disabled'] + : colorVars['--color-text-accent']; + active.style.fontWeight = fontWeightVars['--font-weight-semibold']; + painted.add(active); + }, []); + + // Every transition the wheel starts. Under reduced motion it lands without + // travel: the rows cross-fade onto their row on the animator's Reduce + // Motion fade, whatever the curve (design:reduced-motion DR2, DR4). The + // momentum that continues a throw is not a transition and plays as is. + const move = useCallback( + ( + curve: WheelCurve, + {touchRelease = false, momentum = false, ...options}: MoveOptions = {}, + ) => { + paintRow(null); + // The mark comes back from the row the motion reports nearest when it + // ends, even the row it began on. + motion.forgetNearest(); + const fadeTiming = momentum + ? null + : reducedMotionFade(columnRef.current, {touchRelease}); + if (fadeTiming == null) { + motion.play(curve, options); + return; + } + const fade = rowsRef.current == null ? [] : [rowsRef.current]; + motion.crossFade(curve.to, { + durationMs: fadeTiming.duration, + easing: fadeTiming.easing, + fade, + onEnd: options.onEnd, + }); + }, + [motion, paintRow], + ); + + // The wheel came to rest at `offset`: commit, or turn on to the row the + // rest rule names first. + const arrive = useCallback( + (offset: number) => { + const current = latestRef.current; + const position = nearestIndex(offset, current.layout); + const row = wrapIndex(position, current.options.length); + const committed = current.options.findIndex( + option => option.value === current.value, + ); + // Resting on the committed row is not a stop to correct + // (component:DateInput FR13): a committed value outside the bounds stays + // until a person moves the wheel off it, as in the scrolling column. On + // a loop the nearest enabled row is measured around the seam, and the + // wheel turns the shorter way to it. + const step = + row === committed + ? 0 + : restingStep( + current.options, + row, + Math.max(0, committed), + Boolean(current.layout.looping), + ); + if (step !== 0) { + const target = position + step; + move(turnCurve(columnRef.current, offset, target * ROW_HEIGHT), { + onEnd: (outcome, end) => { + if (outcome === 'arrived') { + arrive(end); + } + }, + }); + return; + } + const option = current.options[row]; + if ( + option != null && + !option.isDisabled && + option.value !== current.value + ) { + current.onChange(option.value); + } + }, + [move], + ); + + const onEnd = useCallback( + (outcome: 'arrived' | 'stopped', offset: number) => { + if (outcome === 'arrived') { + arrive(offset); + } + }, + [arrive], + ); + + // Hand the rows to the motion engine whenever they are rendered anew. + useIsomorphicLayoutEffect(() => { + motion.setLayout(layout); + const container = rowsRef.current; + const rows = + container == null + ? [] + : Array.from(container.children).filter( + (child): child is HTMLElement => child instanceof HTMLElement, + ); + motion.attach(rows, container); + }, [motion, layout, options]); + + // Today's look, for the column's height: rows tip and fade toward its + // edges (only fade, under reduced motion). The shared observer measures + // once at once, then on every resize. + useIsomorphicLayoutEffect(() => { + const column = columnRef.current; + if (column == null) { + return; + } + return observeResize(column, () => { + const height = column.getBoundingClientRect().height || COLUMN_HEIGHT; + motion.setLook(todayLook(height, {reducedMotion})); + }); + }, [motion, reducedMotion]); + + // The band's mark follows the row the motion reports nearest the band. + useIsomorphicLayoutEffect(() => { + motion.onNearestChange(paintRow); + return () => motion.onNearestChange(null); + }, [motion, paintRow]); + // At rest on the committed row, render's own mark takes over again. + useIsomorphicLayoutEffect(() => { + if ( + motion.isMoving() || + dragRef.current != null || + copies > 1 || + nearestIndex(motion.offset(), layout) !== + nearestPosition(selectedIndex, motion.offset(), layout, rowCount) + ) { + // On a short loop the row under the band can be any copy, which render + // cannot know: the painted mark stays. + return; + } + for (const row of paintedRef.current) { + row.style.removeProperty('color'); + row.style.removeProperty('font-weight'); + } + paintedRef.current.clear(); + }); + + // The motion's animations and timers end with the wheel. + useEffect(() => () => motion.dispose(), [motion]); + + // Park the committed row under the band when the wheel is shown, at once. A + // value changed from outside while it is shown (a bound clamping the time + // after another wheel turned, the calendar moved to another month) turns + // the wheel onto its row instead, as UIDatePicker turns onto its maximum + // (component:DateInput FR13): it never jumps there. Never while a finger or + // a motion owns the wheel. + const parkedRef = useRef(false); + useIsomorphicLayoutEffect(() => { + if (!isActive) { + parkedRef.current = false; + return; + } + if (dragRef.current != null || motion.isMoving()) { + return; + } + const position = nearestPosition( + selectedIndex, + motion.offset(), + layout, + rowCount, + ); + const target = position * ROW_HEIGHT; + const isAway = Math.abs(motion.offset() - target) > 0.01; + if (isAway && parkedRef.current) { + // The turn's own end paints the band's row. + move(turnCurve(columnRef.current, motion.offset(), target)); + return; + } + if (isAway) { + motion.setOffset(target); + } + parkedRef.current = true; + if (copies > 1) { + // A short loop's mark is painted, never rendered: a wheel parked where + // it already was (its first row, at offset 0) reports no new row, so + // the band's row is painted here. + paintRow(nearestIndex(motion.offset(), layout)); + } + }, [ + isActive, + selectedIndex, + layout, + motion, + rowCount, + copies, + paintRow, + move, + ]); + + // A short loop's mark is painted, never rendered, so a change in which + // rows are enabled (the bounds moved under a resting wheel) repaints the + // band's row in its new colour. + useIsomorphicLayoutEffect(() => { + if ( + copies === 1 || + !isActive || + dragRef.current != null || + motion.isMoving() + ) { + return; + } + paintRow(nearestIndex(motion.offset(), layout)); + }, [options, copies, isActive, layout, motion, paintRow]); + + // A hidden wheel keeps no motion running and holds no drag. Hiding it + // mid-drag ends the pan without calling its listener (the toggle's owner + // owns that end), so the drag is dropped here, uncommitted: shown again, + // the wheel parks the committed row. + useEffect(() => { + if (!isActive) { + dragRef.current = null; + motion.stop(); + } + }, [isActive, motion]); + + // A finger lands: a moving wheel stops where it is on screen. + const onTouchDown = useCallback( + (time: number) => { + caughtRef.current = motion.isMoving(); + if (caughtRef.current) { + motion.stop(time); + } + }, + [motion], + ); + + // The latest touch-down handler, for the gesture listener attached once. + const onTouchDownRef = useRef(onTouchDown); + useIsomorphicLayoutEffect(() => { + onTouchDownRef.current = onTouchDown; + }); + + const release = useCallback( + (velocity: number) => { + registerWheelTables(); + const plan = planRelease( + motion.offset(), + velocity, + latestRef.current.layout, + resolveMotion(columnRef.current, scroll.bounce), + ); + move(plan.curve, { + ticks: true, + onEnd, + touchRelease: true, + momentum: plan.kind === 'coast' || plan.kind === 'edge', + }); + }, + [motion, move, onEnd], + ); + + const pan = useGestureRecognizer( + columnRef, + () => + new PanGestureRecognizer({ + name: 'wheel-pan', + direction: 'vertical', + // A held finger on the wheel never starts text selection or the + // callout. + selectionLock: lockSelection, + shouldReceiveTouch: (_pan, touch) => { + onTouchDownRef.current(touch.time); + return true; + }, + }), + recognizer => { + const {layout: currentLayout} = latestRef.current; + switch (recognizer.state) { + case 'began': { + const shown = motion.isMoving() ? motion.stop() : motion.offset(); + dragRef.current = {start: fingerOffset(shown, currentLayout)}; + motion.resetDrag(); + motion.setOffset( + dragOffset( + dragRef.current.start - recognizer.translation().y, + currentLayout, + ), + {ticks: true}, + ); + break; + } + case 'changed': { + const drag = dragRef.current; + if (drag == null) { + break; + } + motion.setOffset( + dragOffset(drag.start - recognizer.translation().y, currentLayout), + {ticks: true}, + ); + break; + } + case 'ended': { + const drag = dragRef.current; + dragRef.current = null; + // A scroll view's release velocity (the pan's last two, blended as + // UIPickerView's); the rows move against the finger. + release(drag == null ? 0 : -recognizer.scrollVelocity().y); + break; + } + case 'cancelled': { + dragRef.current = null; + release(0); + break; + } + case 'possible': + case 'failed': + break; + } + }, + {isEnabled: isActive}, + ); + + // A touch that stopped a moving wheel and lifted without dragging: the + // wheel eases onto the nearest row, as a release without velocity does. + useEffect( + () => + pan.addTransitionListener((_pan, _from, to) => { + // The flag stays up until the next touch lands: the tap recognizer + // may hear of this lift after the pan does, and must not read it as + // a tap on a row. + if (to === 'failed' && caughtRef.current && !motion.isMoving()) { + release(0); + } + }), + [pan, motion, release], + ); + + useGestureRecognizer( + columnRef, + () => new TapGestureRecognizer({name: 'wheel-tap'}), + recognizer => { + if (recognizer.state !== 'ended' || caughtRef.current) { + return; + } + const column = columnRef.current; + if (column == null) { + return; + } + const box = column.getBoundingClientRect(); + const {y} = recognizer.location(); + const offset = motion.offset(); + const currentLayout = latestRef.current.layout; + // The tapped row itself: on a short loop a copy of the row under the + // band can sit a few rows away, and the wheel turns onto that copy, as + // UIKit's does. + const target = rowAtPoint( + y - (box.top + box.height / 2), + offset, + currentLayout, + ); + if (target == null || target === nearestIndex(offset, currentLayout)) { + return; + } + move(turnCurve(columnRef.current, offset, target * ROW_HEIGHT), { + onEnd, + }); + }, + {isEnabled: isActive}, + ); + + // Keep the finger: inside a BottomSheet the sheet would otherwise read a + // downward drag here as swipe-to-dismiss (see useOwnScrollGesture). + useOwnScrollGesture(columnRef, 'all', {isEnabled: isActive}); + // The pointer that finger also stays here. A sheet arms its pull-down on + // the body's pointerdown and counts on a native pan to cancel a touch + // pointer, but this wheel pans through the gesture system, which never + // cancels it: without this, the sheet follows the first 8 px of a downward + // drag before the wheel's pan takes over. The gesture host listens in the + // capture phase, before this. + useEffect(() => { + const column = columnRef.current; + if (column == null || !isActive) { + return undefined; + } + const keep = (event: PointerEvent) => event.stopPropagation(); + column.addEventListener('pointerdown', keep); + return () => column.removeEventListener('pointerdown', keep); + }, [isActive]); + + // A mouse wheel or a trackpad turns the rows; the wheel eases onto the + // nearest row once the events stop. + useEffect(() => { + const column = columnRef.current; + if (column == null || !isActive) { + return; + } + let idle: number | undefined; + const onWheel = (event: WheelEvent) => { + // A ctrl-wheel is a pinch (a trackpad pinch arrives as one, + // `spec:AST-094` FR43). The wheel has no pinch, so it stays the page's + // zoom (FR10) and never turns the wheel. + if (event.ctrlKey) { + return; + } + event.preventDefault(); + const currentLayout = latestRef.current.layout; + const delta = + event.deltaMode === 1 + ? event.deltaY * ROW_HEIGHT + : event.deltaMode === 2 + ? event.deltaY * GEOMETRY.height + : event.deltaY; + const {min, max} = offsetBounds(currentLayout); + const shown = motion.isMoving() ? motion.stop() : motion.offset(); + motion.setOffset(Math.min(max, Math.max(min, shown + delta)), { + ticks: true, + }); + window.clearTimeout(idle); + idle = window.setTimeout(() => { + const offset = motion.offset(); + const target = + nearestIndex(offset, latestRef.current.layout) * ROW_HEIGHT; + move(snapCurve(offset, target, 1), {onEnd}); + }, WHEEL_IDLE_MS); + }; + column.addEventListener('wheel', onWheel, {passive: false}); + return () => { + column.removeEventListener('wheel', onWheel); + window.clearTimeout(idle); + }; + }, [isActive, motion, move, onEnd]); + + // A key commits the row at `position` at once and turns the wheel there + // from `offset`. + const turnToPosition = useCallback( + (position: number, offset: number) => { + const current = latestRef.current; + const option = + current.options[ + current.layout.looping + ? wrapIndex(position, current.options.length) + : position + ]; + if (option.value !== current.value) { + current.onChange(option.value); + } + move(turnCurve(columnRef.current, offset, position * ROW_HEIGHT)); + }, + [move], + ); + + // The same onto `row`'s copy nearest where the wheel was heading. + const turnTo = useCallback( + (row: number, heading: number, offset: number) => { + const current = latestRef.current; + turnToPosition( + nearestPosition(row, heading, current.layout, current.options.length), + offset, + ); + }, + [turnToPosition], + ); + + const moveBy = useCallback( + (delta: number) => { + const current = latestRef.current; + const count = current.options.length; + if (count === 0 || delta === 0) { + return; + } + // Keys step from the row the wheel is heading to, not the one it is + // passing: two quick presses move two rows. + const heading = motion.target(); + const offset = motion.isMoving() ? motion.stop() : motion.offset(); + const headingPosition = nearestIndex(heading, current.layout); + const from = wrapIndex(headingPosition, count); + const looping = Boolean(current.layout.looping); + const isEnabled = (index: number) => { + const option = + current.options[looping ? wrapIndex(index, count) : index]; + return option != null && !option.isDisabled; + }; + const step = Math.sign(delta); + let next = from + delta; + if (!looping) { + next = Math.min(count - 1, Math.max(0, next)); + } + // The first enabled row from the target onwards; failing that (the + // target is past the bounds), the enabled row nearest it on the way + // back, short of the row it started from. On a loop the search onwards + // stops half a turn from the start (or at the target, when the key + // moves further): past it a row is nearer the other way round, so a + // key never turns the long way round onto it, as a rest never does. + const reach = Math.max(Math.abs(delta), Math.floor(count / 2)); + let found: number | null = null; + for (let index = next, tries = 0; tries < count; tries++, index += step) { + if (!looping && (index < 0 || index >= count)) { + break; + } + if (looping && Math.abs(index - from) > reach) { + break; + } + if (isEnabled(index)) { + found = index; + break; + } + } + for (let index = next; found == null && index !== from; index -= step) { + if (isEnabled(index)) { + found = index; + } + } + if (found == null) { + return; + } + // The step's own direction, even on a short loop, where the same row + // also sits the other way round. A key never turns a whole revolution: + // a target that folds back onto the row the wheel is heading to stays + // put, and any other travels less than one turn. + let travel = found - from; + if (looping) { + const ahead = (((travel * step) % count) + count) % count; + if (ahead === 0) { + return; + } + travel = ahead * step; + } + turnToPosition(headingPosition + travel, offset); + }, + [motion, turnToPosition], + ); + + // Home and End: the first and the last enabled row, wherever the wheel is + // heading. + const moveToEnd = useCallback( + (end: 'first' | 'last') => { + const {options: rows} = latestRef.current; + const enabled = rows.flatMap((option, index) => + option.isDisabled ? [] : [index], + ); + const row = end === 'first' ? enabled[0] : enabled[enabled.length - 1]; + if (row == null) { + return; + } + const heading = motion.target(); + const offset = motion.isMoving() ? motion.stop() : motion.offset(); + turnTo(row, heading, offset); + }, + [motion, turnTo], + ); + + // A click on a row (assistive technology, a keyboard, a test, a mouse the + // tap recognizer did not take) commits it at once and turns the wheel + // there, as the arrow keys do. A finger's tap is the tap recognizer's, and + // the click it would produce is swallowed by the gesture system. + const activateRow = useCallback( + (row: number) => { + const current = latestRef.current; + const option = current.options[row]; + if (option == null || option.isDisabled) { + return; + } + const offset = motion.isMoving() ? motion.stop() : motion.offset(); + if (option.value !== current.value) { + current.onChange(option.value); + } + const target = nearestPosition( + row, + offset, + current.layout, + current.options.length, + ); + move(turnCurve(columnRef.current, offset, target * ROW_HEIGHT)); + }, + [motion, move], + ); + + const {listboxProps, optionProps} = useWheelListbox({ + label, + options, + value, + // The committed row: a value set from outside shows in the same render. + activeIndex: selectedIndex, + stepBy: moveBy, + toEnd: moveToEnd, + activate: activateRow, + }); + + return ( +
+ + ); +} + +PickerWheel.displayName = 'PickerWheel'; + +/** + * The position of the row under a point `dy` px below the band's centre + * (an option index, or on a looping wheel any integer), or null past either + * end of a list that does not loop. Today's rows tip about their own + * centres, so each centre stays where the row is laid out. + */ +function rowAtPoint( + dy: number, + offset: number, + layout: WheelLayout, +): number | null { + const position = Math.round((offset + dy) / layout.rowHeight); + if (!layout.looping && (position < 0 || position >= layout.rowCount)) { + return null; + } + return position; +} + +/** + * The finger's offset for a shown one: past an end, the travel that the + * rubber band shows as `shown` (so a drag that starts in a bounce continues + * without a jump). + */ +function fingerOffset(shown: number, layout: WheelLayout): number { + const {min, max} = offsetBounds(layout); + const dimension = GEOMETRY.rubberBandDimension; + if (shown < min) { + return min + rubberBandInverse(shown - min, dimension); + } + if (shown > max) { + return max + rubberBandInverse(shown - max, dimension); + } + return shown; +} diff --git a/packages/core/src/DateInput/Wheel.test.tsx b/packages/core/src/DateInput/Wheel.test.tsx new file mode 100644 index 0000000000000..594700c0742ae --- /dev/null +++ b/packages/core/src/DateInput/Wheel.test.tsx @@ -0,0 +1,587 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Wheel.test.tsx + * @input Uses Vitest, Testing Library, the Wheel column and a stubbed + * matchMedia (and CSS.supports, to show the engine does not matter) + * @output Proves where today's wheel rests when it stops on a row it cannot + * commit, and which column (and which frame) each primary pointer draws + * @position Unit coverage for Wheel.tsx: the switch and today's column + * (PickerWheel.test.tsx covers the touch column); the wheels inside the + * DateInput, DateTimeInput and TimeInput sheets are covered by their own + * touch suites + */ + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest'; +import { + nearestEnabledIndex, + ScrollWheel, + Wheel, + type WheelOption, +} from './Wheel'; +import {SCROLL_QUIET_MS} from './useScrollSettle'; +import {restingStep} from './wheelOptions'; + +const ROW = 28; + +beforeAll(() => { + // jsdom implements no scrolling; the wheel parks its committed row on mount. + Element.prototype.scrollTo = vi.fn(); +}); + +/** Hours 0-23 with 09:00-17:00 bounds: 0-8 and 18-23 are disabled. */ +const HOURS: WheelOption[] = Array.from({length: 24}, (_, hour) => ({ + value: hour, + label: String(hour).padStart(2, '0'), + isDisabled: hour < 9 || hour > 17, +})); + +describe('nearestEnabledIndex', () => { + it('keeps an enabled row', () => { + expect(nearestEnabledIndex(HOURS, 12, 10)).toBe(12); + }); + + it('goes to the bound on the side the wheel stopped on', () => { + expect(nearestEnabledIndex(HOURS, 7, 10)).toBe(9); + expect(nearestEnabledIndex(HOURS, 0, 10)).toBe(9); + expect(nearestEnabledIndex(HOURS, 20, 10)).toBe(17); + expect(nearestEnabledIndex(HOURS, 23, 10)).toBe(17); + }); + + it('breaks a tie toward the committed row', () => { + const gap: WheelOption[] = [ + {value: 0, label: '0'}, + {value: 1, label: '1', isDisabled: true}, + {value: 2, label: '2'}, + ]; + expect(nearestEnabledIndex(gap, 1, 0)).toBe(0); + expect(nearestEnabledIndex(gap, 1, 2)).toBe(2); + }); + + it('stays on the committed row when no row is enabled', () => { + const none = HOURS.map(option => ({...option, isDisabled: true})); + expect(nearestEnabledIndex(none, 3, 10)).toBe(10); + }); +}); + +describe('restingStep', () => { + // A year of months; only January and February in the bounds. + const MONTHS: WheelOption[] = Array.from({length: 12}, (_, month) => ({ + value: month, + label: String(month + 1), + isDisabled: month > 1, + })); + + it('stays on an enabled row', () => { + expect(restingStep(MONTHS, 1, 0, true)).toBe(0); + }); + + it('turns the shorter way round a loop, across its seam', () => { + // December: January is one row on, February ten rows back. + expect(restingStep(MONTHS, 11, 0, true)).toBe(1); + // Without the loop, the list ends at December: back to February. + expect(restingStep(MONTHS, 11, 0)).toBe(-10); + }); + + it('breaks a tie around the loop toward the committed row', () => { + // Only January and July enabled: October is three rows from July (back) + // and from January (on, across the seam). + const TWO = MONTHS.map(option => ({ + ...option, + isDisabled: option.value !== 0 && option.value !== 6, + })); + expect(restingStep(TWO, 9, 0, true)).toBe(3); + expect(restingStep(TWO, 9, 6, true)).toBe(-3); + }); +}); + +describe('Wheel — where it comes to rest', () => { + afterEach(() => { + // @ts-expect-error - deleting the shadow restores Element's own getter + delete HTMLElement.prototype.offsetHeight; + }); + + /** + * jsdom has no layout: rows get a height, and the scroller's scrollTop + * and scrollTo are recorded so a test can stop the wheel on any row. + */ + function renderWheel(value: number, Column: typeof ScrollWheel = Wheel) { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'option' ? ROW : 0; + }, + }); + const onChange = vi.fn(); + const scrollTo = vi.fn(); + const {rerender} = render( + , + ); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + let top = value * ROW; + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => top, + set: (next: number) => { + top = next; + }, + }); + scroller.scrollTo = ((options: ScrollToOptions) => { + scrollTo(options); + }) as HTMLElement['scrollTo']; + /** The wheel scrolls to `row` and stops there. */ + const stopOn = (row: number) => { + act(() => { + top = row * ROW; + scroller.dispatchEvent(new Event('scroll')); + scroller.dispatchEvent(new Event('scrollend')); + }); + }; + return {onChange, scrollTo, stopOn, rerender}; + } + + it('commits an enabled row it stops on', () => { + const {onChange, scrollTo, stopOn} = renderWheel(10); + stopOn(12); + expect(scrollTo).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalledWith(12); + }); + + it('stopping below the minimum travels on to the minimum and commits it there', () => { + const {onChange, scrollTo, stopOn} = renderWheel(10); + stopOn(7); + // Not back to the committed 10: on to 9, the nearest hour in bounds. + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'smooth'}); + expect(onChange).not.toHaveBeenCalled(); + // The second rest, on 9, commits it. + stopOn(9); + expect(onChange).toHaveBeenCalledWith(9); + }); + + it('stopping past the maximum travels back to the maximum', () => { + const {onChange, scrollTo, stopOn} = renderWheel(10); + stopOn(21); + expect(scrollTo).toHaveBeenCalledWith({ + top: 17 * ROW, + behavior: 'smooth', + }); + stopOn(17); + expect(onChange).toHaveBeenCalledWith(17); + }); + + it('opening on a value outside the bounds parks on it without travelling or committing', () => { + // 08:00 with 09:00-17:00 bounds: the sheet opens, the wheel parks on 8, + // and the browser reports that scroll's rest. Nobody touched the wheel, + // so nothing may move or commit. + const {onChange, scrollTo, stopOn} = renderWheel(8); + stopOn(8); + expect(scrollTo).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('turns onto a value set while it is shown, as a bound clamping the time after another wheel turned (component:DateInput FR13)', () => { + // 22:00 past a 17:00 maximum: the field clamps to 17:00, and the hour + // wheel, at rest on 22, turns onto 17 as UIDatePicker's does; it never + // jumps there, and it commits nothing of its own. + const {onChange, scrollTo, rerender} = renderWheel(22); + scrollTo.mockClear(); + rerender( + , + ); + expect(scrollTo).toHaveBeenCalledWith({top: 17 * ROW, behavior: 'smooth'}); + expect(scrollTo).not.toHaveBeenCalledWith({ + top: 17 * ROW, + behavior: 'auto', + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('a stop on another disabled row still travels to the bounds', () => { + const {onChange, scrollTo, stopOn} = renderWheel(8); + stopOn(6); + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'smooth'}); + stopOn(9); + expect(onChange).toHaveBeenCalledWith(9); + }); + + describe('under reduced motion', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + /** Reduced motion on, with a fine or a coarse primary pointer. */ + function stubReducedMotion(pointer: 'fine' | 'coarse') { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: + query === '(prefers-reduced-motion: reduce)' || + (query === '(pointer: coarse)' && pointer === 'coarse'), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + } + + it('lands on the nearest row in bounds at once with a mouse', () => { + stubReducedMotion('fine'); + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const {scrollTo, stopOn} = renderWheel(10); + stopOn(7); + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'auto'}); + expect(scrollTo).not.toHaveBeenCalledWith( + expect.objectContaining({behavior: 'smooth'}), + ); + }); + + it('cross-fades onto it after a touch: opacity only, 200 ms', () => { + // A touch screen whose primary pointer is fine (a laptop's): today's + // column, and a touch release fades (DR2). + stubReducedMotion('fine'); + vi.useFakeTimers(); + const {scrollTo, stopOn} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + stopOn(7); + expect(animate).toHaveBeenCalledTimes(1); + const [keyframes, timing] = animate.mock.calls[0]; + // Out and back in, opacity alone. + expect((keyframes as Keyframe[]).map(frame => frame.opacity)).toEqual([ + 1, 0, 1, + ]); + for (const frame of keyframes as Keyframe[]) { + expect( + Object.keys(frame).filter( + key => key !== 'offset' && key !== 'easing', + ), + ).toEqual(['opacity']); + } + expect(timing.duration).toBeLessThanOrEqual(200); + // The rows land at the dip's midpoint, with no travel. + expect(scrollTo).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(timing.duration / 2); + }); + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'auto'}); + }); + + it('steps with the arrow keys at once with a keyboard', () => { + stubReducedMotion('fine'); + const {scrollTo} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + act(() => { + scroller.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), + ); + }); + expect(scrollTo).toHaveBeenCalledWith({ + top: 11 * ROW, + behavior: 'auto', + }); + }); + + /** Steps the wheel down one row with the keyboard. */ + function pressArrowDown() { + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + act(() => { + scroller.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), + ); + }); + } + + it('lands an arrow key step on a coarse pointer under its cross-fade, not before', () => { + // A finger gets the touch column (PickerWheel.test.tsx); today's + // column keeps its dip for a coarse pointer wherever it is rendered. + stubReducedMotion('coarse'); + vi.useFakeTimers(); + const {onChange, scrollTo, rerender} = renderWheel(10, ScrollWheel); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + pressArrowDown(); + expect(onChange).toHaveBeenCalledWith(11); + // The parent takes the step. The new value must not move the rows + // while they are still fully visible: they land under the dip. + rerender( + , + ); + expect(animate).toHaveBeenCalledTimes(1); + expect(scrollTo).not.toHaveBeenCalled(); + const [, timing] = animate.mock.calls[0]; + act(() => { + vi.advanceTimersByTime(timing.duration / 2); + }); + expect(scrollTo.mock.calls).toEqual([ + [{top: 11 * ROW, behavior: 'auto'}], + ]); + }); + + it('starts a second step’s dip where the first one’s fade-in has the column', () => { + // design:reduced-motion DR2: a leaving element fades out where it is, + // so a step during the last dip's fade-in never pops back to opaque. + // A finger gets the touch column (PickerWheel.test.tsx); today's + // column keeps its dip for a coarse pointer wherever it is rendered. + stubReducedMotion('coarse'); + vi.useFakeTimers(); + renderWheel(10, ScrollWheel); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + pressArrowDown(); + const [first, timing] = animate.mock.calls[0]; + expect((first as Keyframe[])[0].opacity).toBe(1); + // Past the midpoint: the rows have landed and the column fades back in. + act(() => { + vi.advanceTimersByTime(timing.duration / 2 + 40); + }); + scroller.style.opacity = '0.5'; + pressArrowDown(); + expect(animate).toHaveBeenCalledTimes(2); + const [second] = animate.mock.calls[1]; + expect((second as Keyframe[]).map(frame => frame.opacity)).toEqual([ + 0.5, 0, 1, + ]); + }); + + it('changes at once after an earlier touch on a fine pointer, for a key or a tap', () => { + // A touch screen whose primary pointer is fine (a laptop's): only a + // touch drag's release fades (DR2); a key step or a row tap is + // immediate (DR3). + stubReducedMotion('fine'); + const {scrollTo} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + pressArrowDown(); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 11 * ROW, + behavior: 'auto', + }); + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + fireEvent.click(screen.getByRole('option', {name: '13'})); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 13 * ROW, + behavior: 'auto', + }); + expect(animate).not.toHaveBeenCalled(); + }); + + it('cross-fades the travel after a touch drag on a fine pointer', () => { + stubReducedMotion('fine'); + vi.useFakeTimers(); + const {scrollTo, stopOn} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + stopOn(7); + expect(animate).toHaveBeenCalledTimes(1); + expect(scrollTo).not.toHaveBeenCalled(); + act(() => { + vi.advanceTimersByTime(animate.mock.calls[0][1].duration / 2); + }); + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'auto'}); + }); + + /** A primary mouse press, move or release, as usePointerDragScroll reads it. */ + const mouse = (type: string, y: number) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + Object.defineProperties(event, { + clientY: {value: y}, + pointerId: {value: 1}, + pointerType: {value: 'mouse'}, + button: {value: 0}, + }); + return event; + }; + + /** jsdom has no pointer capture, which a mouse drag takes. */ + const capturable = (scroller: HTMLElement) => { + scroller.setPointerCapture = () => {}; + scroller.releasePointerCapture = () => {}; + scroller.hasPointerCapture = () => true; + }; + + it('travels at once after a mouse drag that follows a touch, on a fine pointer', () => { + // usePointerDragScroll keeps a primary mouse press from bubbling (it must + // not reach BottomSheet's own drag), so the wheel reads the pointer on + // the way down, before that. + stubReducedMotion('fine'); + vi.useFakeTimers(); + const {scrollTo, stopOn} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + capturable(scroller); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + act(() => { + scroller.dispatchEvent(mouse('pointerdown', 200)); + // Pulled down three rows: 7, below the minimum. + scroller.dispatchEvent(mouse('pointermove', 200 + 3 * ROW)); + scroller.dispatchEvent(mouse('pointerup', 200 + 3 * ROW)); + }); + stopOn(7); + expect(animate).not.toHaveBeenCalled(); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 9 * ROW, + behavior: 'auto', + }); + }); + + it('waits for a mouse drag’s release before travelling back inside the bounds', () => { + // Each scrollTop the drag assigns ends in its own scrollend in Chromium, + // and the hand can pause with the button down: neither is rest. + stubReducedMotion('fine'); + vi.useFakeTimers(); + const {scrollTo, onChange} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + capturable(scroller); + act(() => { + scroller.dispatchEvent(mouse('pointerdown', 200)); + scroller.dispatchEvent(mouse('pointermove', 200 + 3 * ROW)); + scroller.dispatchEvent(new Event('scroll')); + scroller.dispatchEvent(new Event('scrollend')); + vi.advanceTimersByTime(SCROLL_QUIET_MS * 2); + }); + expect(scrollTo).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + act(() => { + scroller.dispatchEvent(mouse('pointerup', 200 + 3 * ROW)); + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scrollTo).toHaveBeenLastCalledWith({ + top: 9 * ROW, + behavior: 'auto', + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('travels at once after a trackpad scroll that follows a touch, on a fine pointer', () => { + stubReducedMotion('fine'); + const {scrollTo, stopOn} = renderWheel(10); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + const animate = vi.fn(); + scroller.animate = animate as HTMLElement['animate']; + fireEvent.pointerDown(scroller, {pointerType: 'touch'}); + fireEvent.wheel(scroller, {deltaY: -120}); + stopOn(7); + expect(animate).not.toHaveBeenCalled(); + expect(scrollTo).toHaveBeenCalledWith({top: 9 * ROW, behavior: 'auto'}); + }); + }); + + it('never commits a disabled row, even with nowhere valid to go', () => { + const none = HOURS.map(option => ({...option, isDisabled: true})); + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'option' ? ROW : 0; + }, + }); + const onChange = vi.fn(); + render( + , + ); + const scroller = screen.getByRole('listbox', {name: 'Hour'}); + let top = 10 * ROW; + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, + get: () => top, + }); + scroller.scrollTo = vi.fn() as HTMLElement['scrollTo']; + act(() => { + top = 4 * ROW; + scroller.dispatchEvent(new Event('scroll')); + scroller.dispatchEvent(new Event('scrollend')); + }); + expect(scroller.scrollTo).toHaveBeenCalledWith({ + top: 10 * ROW, + behavior: 'smooth', + }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('Wheel — by pointer', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * The pointer and the engine: `(pointer: coarse)` is a finger, and + * `-webkit-touch-callout` exists only in WebKit on iOS, which must not + * matter. + */ + function stubDevice({ios, coarse}: {ios: boolean; coarse: boolean}) { + vi.stubGlobal('CSS', { + supports: (condition: string) => + ios && condition.includes('-webkit-touch-callout'), + }); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })), + ); + } + + function renderPicker() { + const onChange = vi.fn(); + const view = render( + , + ); + return {...view, onChange}; + } + + it.each([ + ['iOS WebKit', true], + ['Android Chrome', false], + ])('draws the touch column under a finger, on %s', (_, ios) => { + stubDevice({ios, coarse: true}); + const {container} = renderPicker(); + expect(container.querySelector('[data-wheel]')).not.toBeNull(); + // Each option is a drawn row, once. + expect(container.querySelectorAll('[data-wheel-row]')).toHaveLength( + HOURS.length, + ); + }); + + it.each([ + ['a mouse', false], + ['iOS WebKit with a mouse', true], + ])('keeps the scrolling column, with no frame, for %s', (_, ios) => { + stubDevice({ios, coarse: false}); + const {container} = renderPicker(); + expect(container.querySelector('[data-wheel]')).toBeNull(); + // Today's column, straight in its container: no group frame around it. + expect( + screen.getByRole('listbox', {name: 'Hour'}).parentElement?.parentElement, + ).toBe(container); + }); +}); + +describe('Wheel — markup', () => { + // Written before the listbox wiring moved into useWheelListbox: the + // snapshot holds today's column to its markup (attributes, classes, order). + it('keeps today’s column’s markup', () => { + const {container} = render( + {}} />, + ); + // React's generated ids depend on how many renders ran before. + expect( + container.innerHTML.replace(/_r_[0-9a-z]+_/g, '_r_id_'), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/DateInput/Wheel.tsx b/packages/core/src/DateInput/Wheel.tsx index 059e4dfd6db97..0ec42e8997109 100644 --- a/packages/core/src/DateInput/Wheel.tsx +++ b/packages/core/src/DateInput/Wheel.tsx @@ -4,15 +4,26 @@ /** * @file Wheel.tsx - * @input Option list, selected value, change callback - * @output Exports Wheel — a snap-scrolling picker column - * @position Internal component; consumed by MonthYearWheels.tsx + * @input Option list, selected value, change callback; under a finger, + * optionally looping + * @output Exports Wheel — one picker column — ScrollWheel (today's column), + * and the shared types of wheelOptions.ts + * @position Internal component; consumed by MonthYearWheels.tsx, + * TouchTimeField.tsx and TouchDateTimeField.tsx * - * A wheel is a scroll container, not a custom gesture surface. Momentum, + * Wheel picks its column by the primary pointer (useTouchPresentation): one + * touch model on every touch device, iOS and Android alike. Under a finger + * it moves like UIPickerView in today's look: PickerWheel.tsx, the touch + * layer, imported here alone. Under a mouse or other fine pointer it is the snap-scrolling + * column below, today's Astryx wheel. A wheel draws only on a sheet, so a caller who + * pins the fine surface (`presentation="popover"` on its input) gets no + * sheet and no wheel (architecture:public-component-api INV16). + * + * The scrolling column is a scroll container, not a custom gesture surface. Momentum, * rubber-banding, and the settle animation are the platform's; all this adds * is `scroll-snap-align: center` on each option, half a viewport of padding at * each end so the first and last option can reach the middle, and a commit - * when the scrolling stops. + * when the scrolling stops and no finger or mouse drag still holds it. * * The falloff (rows fading and tipping away from the centre) is a CSS * scroll-driven animation on a `view()` timeline — the browser interpolates it @@ -21,19 +32,26 @@ * browser that does not understand `animation-timeline` would otherwise run * the same keyframes on the document timeline and simply play them once. * + * Reduced motion (design:reduced-motion): the wheel's own scrolls (a step + * from a key or a click, the travel to the nearest enabled row) land without + * travel, cross-fading on a touch screen and at once otherwise. A finger's + * scroll and its momentum are the platform's. + * * Accessibility: this is a listbox, not a novel widget. It is one tab stop * with arrow/Home/End/PageUp/PageDown keys, `aria-activedescendant` tracking * the active row, and every row reachable by tap — none of which depends on * the scroll-driven decoration. * * SYNC: When modified, update: + * - /packages/core/src/DateInput/PickerWheel.tsx (the touch column) + * - /packages/core/src/DateInput/wheelOptions.ts * - /packages/core/src/DateInput/TouchDateField.tsx * - /packages/core/src/DateTimeInput/TouchDateTimeField.tsx * - /packages/core/src/DateInput/DateInput.doc.mjs * - /packages/core/src/DateInput/DateInputTouch.test.tsx */ -import {useCallback, useEffect, useId, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; import { colorVars, @@ -44,10 +62,21 @@ import { durationVars, } from '../theme/tokens.stylex'; import {focusOutlineStyles} from '../utils'; +import {reducedMotionFade} from '../motion/reducedMotion'; +import {useTouchPresentation} from '../utils/touchPresentation'; +import {PickerWheel} from './PickerWheel'; import {dateInputTouchSizes, dateInputTouchGeometry} from './tokens.stylex'; import {useOwnScrollGesture} from './useOwnScrollGesture'; import {usePointerDragScroll} from './usePointerDragScroll'; import {useScrollSettle} from './useScrollSettle'; +import {useWheelListbox} from './useWheelListbox'; +import {nearestEnabledIndex, type WheelProps} from './wheelOptions'; + +export { + nearestEnabledIndex, + type WheelOption, + type WheelProps, +} from './wheelOptions'; const ITEM_BLOCK_SIZE = dateInputTouchSizes.wheelItemSize; @@ -204,37 +233,35 @@ const styles = stylex.create({ }, }); -export interface WheelOption { - /** Stable numeric identity of the row (a month 1-12, or a year). */ - value: number; - /** Row text. */ - label: string; - /** Rows outside min/max stay visible but cannot be committed. */ - isDisabled?: boolean; +/** + * One picker column: UIPickerView's motion in today's look under a finger, today's + * snap-scrolling column under a fine pointer. + * + * @example + * ``` + * + * ``` + */ +export function Wheel(props: WheelProps) { + // One touch model on every touch device: a finger gets UIPickerView's + // motion in today's look, a fine pointer today's column. + const {isTouch} = useTouchPresentation(); + return isTouch ? : ; } -export interface WheelProps { - /** Accessible name for the column, e.g. "Month". */ - label: string; - /** Rows, top to bottom. */ - options: ReadonlyArray; - /** Committed value; must match one option's `value`. */ - value: number; - /** Fired when the wheel comes to rest on a different, enabled row. */ - onChange: (value: number) => void; - /** - * False while the wheel is hidden — scroll offsets of a display:none - * scroller are meaningless, so listeners and the initial scroll wait. - */ - isActive?: boolean; -} +Wheel.displayName = 'Wheel'; /** - * One snap-scrolling picker column. + * One snap-scrolling picker column: today's wheel, under a fine pointer. * * @example * ``` - * * ``` */ -export function Wheel({ +export function ScrollWheel({ label, options, value, onChange, isActive = true, }: WheelProps) { - const id = useId(); const scrollerRef = useRef(null); const selectedIndex = Math.max( 0, @@ -259,6 +285,13 @@ export function Wheel({ // `selectedIndex` only catches up when the wheel settles. const [activeIndex, setActiveIndex] = useState(selectedIndex); + // A mouse cannot drag a scroll container, so without this the wheel ignores + // the one gesture its shape invites. Touch is untouched — it pans natively, + // with momentum this could not match. See usePointerDragScroll. The drag it + // holds is not rest either: the settle below waits for its release, as it + // waits for a finger's (useScrollSettle). + const {isDraggingRef} = usePointerDragScroll(scrollerRef, isActive); + // Row height in px, read from layout rather than assumed, so a theme that // retunes --date-input-touch-wheel-item-size still lands on the right row. const itemBlockSize = useCallback((): number => { @@ -268,20 +301,101 @@ export function Wheel({ : 0; }, []); + // What last started a scroll of the wheel: a pointer's type, or 'wheel' + // for a mouse wheel or trackpad. Under reduced motion the release of a + // touch drag cross-fades whatever the primary pointer (design:reduced-motion + // DR2); a key step, a row tap or a trackpad's scroll follows the primary + // pointer (DR2 on a coarse one, at once otherwise, DR3). + const lastPointerRef = useRef(null); + + // A reduced-motion dip whose rows have not landed yet: its timer and the + // row it lands on. While one is pending the rows stay put for it, so they + // never move at full opacity, and a further step retargets it. + const pendingFadeRef = useRef<{timer: number; top: number} | null>(null); + // The running dip, so a step during its fade-in starts the next one where + // the column is. + const dipRef = useRef(null); + useEffect( + () => () => { + if (pendingFadeRef.current != null) { + window.clearTimeout(pendingFadeRef.current.timer); + pendingFadeRef.current = null; + } + }, + [], + ); + const scrollToIndex = useCallback( - (index: number, behavior: ScrollBehavior) => { + ( + index: number, + behavior: ScrollBehavior, + {touchRelease = false}: {touchRelease?: boolean} = {}, + ) => { const scroller = scrollerRef.current; const size = itemBlockSize(); if (scroller == null || size === 0) { return; } - scroller.scrollTo({top: index * size, behavior}); + const top = index * size; + // Under reduced motion (design:reduced-motion) the wheel's own scroll + // lands without travel: a dip on a touch screen or for a touch drag's + // release, at once otherwise. + const fadeTiming = + behavior === 'smooth' + ? reducedMotionFade(scroller, {touchRelease}) + : null; + const pending = pendingFadeRef.current; + if (fadeTiming == null) { + if (pending != null) { + window.clearTimeout(pending.timer); + pendingFadeRef.current = null; + } + scroller.scrollTo({top, behavior}); + return; + } + if (fadeTiming.duration === 0 || typeof scroller.animate !== 'function') { + if (pending != null) { + window.clearTimeout(pending.timer); + pendingFadeRef.current = null; + } + scroller.scrollTo({top, behavior: 'auto'}); + return; + } + if (pending != null) { + // The dip is still fading out: its rows land here instead. + pending.top = top; + return; + } + const {duration, easing} = fadeTiming; + // A step while the last dip still fades back in starts this one where + // the column is, not at full opacity: a leaving element fades out where + // it is (design:reduced-motion DR2). + const shown = Number.parseFloat(getComputedStyle(scroller).opacity); + dipRef.current?.cancel(); + dipRef.current = scroller.animate( + [ + {opacity: Number.isFinite(shown) ? shown : 1, easing}, + {opacity: 0, easing}, + {opacity: 1}, + ], + {duration, easing: 'linear'}, + ); + const landing = {top, timer: 0}; + landing.timer = window.setTimeout(() => { + pendingFadeRef.current = null; + scroller.scrollTo({top: landing.top, behavior: 'auto'}); + }, duration / 2); + pendingFadeRef.current = landing; }, [itemBlockSize], ); - // Commit on rest. A disabled row is bounced back to the committed one - // rather than silently keeping a value the wheel is not showing. + // Commit on rest. A wheel that stopped on a disabled row (one outside the + // bounds) travels on to the nearest enabled row, as UIDatePicker does, and + // that second rest commits it. Resting on the committed row itself is not a + // stop to correct: the park after the sheet opens settles there with no + // touch, and a committed value outside the bounds stays until a person + // moves the wheel. // // Declared here, above the park effect, because that effect consults the // `isAtRestRef` this returns before it repositions anything. @@ -296,20 +410,30 @@ export function Wheel({ options.length - 1, Math.max(0, Math.round(scroller.scrollTop / size)), ); - const option = options[index]; - if (option == null || option.isDisabled) { - scrollToIndex(selectedIndex, 'smooth'); + const restIndex = + index === selectedIndex + ? index + : nearestEnabledIndex(options, index, selectedIndex); + if (restIndex !== index) { + scrollToIndex(restIndex, 'smooth', { + touchRelease: lastPointerRef.current === 'touch', + }); return; } - if (option.value !== value) { + const option = options[index]; + if (option != null && !option.isDisabled && option.value !== value) { onChange(option.value); } }, isActive, + isDraggingRef, ); - // Park the committed row under the band whenever the wheel is shown, or the - // value is changed from outside (the calendar scrolled to another month). + // Park the committed row under the band whenever the wheel is shown, at + // once. A value changed from outside while it is shown (a bound clamping the + // time after another wheel turned, the calendar scrolled to another month) + // turns the wheel onto its row instead, as UIDatePicker turns onto its + // maximum (component:DateInput FR13): it never jumps there. // // NEVER while the wheel is still moving. A scroller that is mid-gesture or // still carrying momentum is the user's, and repositioning it does not stop @@ -317,8 +441,15 @@ export function Wheel({ // as a new settle, commits the next row along, and parks again. See // useScrollSettle. The settle handler re-checks the position afterwards, so // a correction that is genuinely needed still happens, just at rest. + const parkedRef = useRef(false); useEffect(() => { - if (!isActive || !isAtRestRef.current) { + if (!isActive) { + parkedRef.current = false; + return; + } + // Nor while a reduced-motion dip is still to land its rows: they land + // under it, on this row. + if (!isAtRestRef.current || pendingFadeRef.current != null) { return; } const size = itemBlockSize(); @@ -327,11 +458,16 @@ export function Wheel({ return; } if (Math.round(scroller.scrollTop / size) !== selectedIndex) { - scroller.scrollTo({top: selectedIndex * size, behavior: 'auto'}); + if (parkedRef.current) { + scrollToIndex(selectedIndex, 'smooth'); + } else { + scroller.scrollTo({top: selectedIndex * size, behavior: 'auto'}); + } } + parkedRef.current = true; // eslint-disable-next-line @eslint-react/set-state-in-effect -- the highlight follows a scroll position, which only exists after layout setActiveIndex(selectedIndex); - }, [isActive, selectedIndex, itemBlockSize, isAtRestRef]); + }, [isActive, selectedIndex, itemBlockSize, isAtRestRef, scrollToIndex]); // Highlight follows the finger. rAF-throttled: a scroll can fire far more // often than the display refreshes, and this only feeds a repaint. @@ -375,11 +511,6 @@ export function Wheel({ // there is no way to share — it takes every touch that lands on it. useOwnScrollGesture(scrollerRef, 'all', {isEnabled: isActive}); - // A mouse cannot drag a scroll container, so without this the wheel ignores - // the one gesture its shape invites. Touch is untouched — it pans natively, - // with momentum this could not match. See usePointerDragScroll. - usePointerDragScroll(scrollerRef, isActive); - const moveBy = useCallback( (delta: number) => { const next = Math.min( @@ -399,68 +530,61 @@ export function Wheel({ [activeIndex, options, scrollToIndex, value, onChange], ); - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - moveBy(1); - break; - case 'ArrowUp': - event.preventDefault(); - moveBy(-1); - break; - case 'PageDown': - event.preventDefault(); - moveBy(10); - break; - case 'PageUp': - event.preventDefault(); - moveBy(-10); - break; - case 'Home': - event.preventDefault(); - moveBy(-activeIndex); - break; - case 'End': - event.preventDefault(); - moveBy(options.length - 1 - activeIndex); - break; - default: - break; - } + // Home and End step to the first and the last row, as any key does: a + // disabled one stops them. + const toEnd = useCallback( + (end: 'first' | 'last') => { + moveBy(end === 'first' ? -activeIndex : options.length - 1 - activeIndex); }, [moveBy, activeIndex, options.length], ); + const activate = useCallback( + (index: number) => { + const option = options[index]; + if (option == null || option.isDisabled) { + return; + } + setActiveIndex(index); + scrollToIndex(index, 'smooth'); + if (option.value !== value) { + onChange(option.value); + } + }, + [options, scrollToIndex, value, onChange], + ); + + const {listboxProps, optionProps} = useWheelListbox({ + label, + options, + value, + activeIndex, + stepBy: moveBy, + toEnd, + activate, + }); + return (