diff --git a/.changeset/appshell-adaptive-height.md b/.changeset/appshell-adaptive-height.md new file mode 100644 index 0000000000000..e0ce6ce83fdf3 --- /dev/null +++ b/.changeset/appshell-adaptive-height.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] AppShell accepts `height="adaptive"`: `fill` with a mouse or trackpad, `auto` under a finger (a coarse primary pointer, through the shared touch resolver). With `fill`, the main region scrolls inside a viewport-high shell, so a phone browser never sees the page scroll: its toolbar or address bar does not minimize, and on iOS a status-bar tap does not return to the top. `adaptive` lets the page scroll under the sticky header under a finger and keeps independent scroll regions with a mouse. The root now reports which element scrolls in `data-scroll-owner` (`content` for the main region, `document` for the page), for code that listens to or restores the scroll position. `fill` and `auto` are unchanged, and `fill` stays the default. + +@vjeux diff --git a/.changeset/appshell-touch-scroll-edge.md b/.changeset/appshell-touch-scroll-edge.md new file mode 100644 index 0000000000000..97f431eb168ee --- /dev/null +++ b/.changeset/appshell-touch-scroll-edge.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] AppShell: under a finger (one touch model on every touch device), when the page scrolls (`height="auto"`, or `height="adaptive"` on a touch device), the pinned header looks like an iOS 26 bar. It paints no background of its own and draws no divider. Once the content has moved more than 10px under it, a veil of the header's colour fades in at its top on iOS's measured spring (0.29 s): strongest at the top, gone just below the header. It fades out (0.51 s) when the content comes back. A scroll the program drives (`scrollTo`, a status-bar tap) switches the veil at once instead: on iOS 26.2 a fade that starts on such a scroll makes WebKit drop the header's items for a frame or two. Under Reduce Motion the fades are the animator's 200 ms cross-fade. Where the browser supports `prefers-reduced-transparency` (Safari 26.2 does not yet), Reduce Transparency makes the veil an opaque band ending at the header's bottom edge. With a mouse nothing changes. The springs and the veil's shape are internal measured constants; the veil takes the header's theme colour. + +@vjeux diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..736b20da4327d --- /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. + +@vjeux diff --git a/.changeset/cli-discover-search-field.md b/.changeset/cli-discover-search-field.md new file mode 100644 index 0000000000000..7af26934d1bfd --- /dev/null +++ b/.changeset/cli-discover-search-field.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental SearchField component from `@astryxdesign/lab`. + +@vjeux diff --git a/.changeset/core-internal-entries.md b/.changeset/core-internal-entries.md new file mode 100644 index 0000000000000..d72359bb74ad9 --- /dev/null +++ b/.changeset/core-internal-entries.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Core's internal entries, reserved for Astryx's own packages, also give Lab components the pan recognizer, the gesture types, the touch presentation queries and the motion math and tuning tables that Core components use. Apps import nothing new. + +@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..1c66836c74243 --- /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, with every press painter writing `data-astryx-press` through one internal writer in `utils/pressFeedback.ts`. 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/hooks-is-rtl-element.md b/.changeset/hooks-is-rtl-element.md new file mode 100644 index 0000000000000..507bbfb5f6827 --- /dev/null +++ b/.changeset/hooks-is-rtl-element.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] `isRtlElement` is exported from `@astryxdesign/core/hooks` (proposed; pending owner review): whether an element renders right to left, read from its computed `direction` when it is called, for drag, scroll and layout math. + +@vjeux diff --git a/.changeset/lab-navigation-stack-cli-discover.md b/.changeset/lab-navigation-stack-cli-discover.md new file mode 100644 index 0000000000000..fb8a16f4bc967 --- /dev/null +++ b/.changeset/lab-navigation-stack-cli-discover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental NavigationStack and NavigationScreen components from `@astryxdesign/lab`. + +@vjeux diff --git a/.changeset/list-dividers-hide-on-press.md b/.changeset/list-dividers-hide-on-press.md new file mode 100644 index 0000000000000..0af8460ffdca3 --- /dev/null +++ b/.changeset/list-dividers-hide-on-press.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] In a `List` with `hasDividers`, a touch press hides the dividers on either side of the pressed row, so the pressed row reads as one unbroken highlight; both come back the moment the finger lifts, while the highlight fades. This applies under any finger (a coarse primary pointer); with a mouse the dividers stay drawn. + +@vjeux diff --git a/.changeset/list-row-press-compositor.md b/.changeset/list-row-press-compositor.md new file mode 100644 index 0000000000000..df7f0f38f7256 --- /dev/null +++ b/.changeset/list-row-press-compositor.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A list row's touch press release keeps fading while the page runs script. `ListItem`, `Item` and `TreeList` rows used to stay fully pressed after the finger lifted until the main thread was free, and every frame of the fade repainted the row; the release now runs on the compositor, with the same color, clock and curve as before. Under a finger, while a row is pressed and through its release, the press paints on the row's `::before` and the row is its own stacking context; at rest nothing of it applies, so a row's own `::before` decoration and its children's stacking are as they were. During a press the layer is the row's `::before`, so a caller's decoration there takes the layer's box and paint for that time (a page stylesheet keeps the properties it sets); decoration that should stay through a press belongs on `::after` or a child element. With a mouse or trackpad the row is unchanged: no layer and no new stacking context. + +@vjeux diff --git a/.changeset/list-selection-hold.md b/.changeset/list-selection-hold.md new file mode 100644 index 0000000000000..222de0bbf1579 --- /dev/null +++ b/.changeset/list-selection-hold.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[chore] Internal: under a finger, a list row tapped to push can stay selected while the pushed screen covers it, then deselect as the screen pops, as a UIKit table row does; with a mouse the press releases at the lift as before. The hold that drives it (`List/selectionHold.ts`) is internal to core, so no public export changes. The deselect runs on the compositor, and a divided list keeps the dividers around the held row hidden. + +@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/scroll-edge-effect-internal.md b/.changeset/scroll-edge-effect-internal.md new file mode 100644 index 0000000000000..4f8d6a2f1d9b4 --- /dev/null +++ b/.changeset/scroll-edge-effect-internal.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the scroll edge effect moves to core's internal motion modules and takes its springs, the veil's edge and colour, and the inset of a bar overlaying a scroll container from its caller, so Lab's NavigationStack uses it too; a scroll container's switch is measured from its padding edge in every engine. AppShell's touch bar is unchanged. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock-gestures.md b/.changeset/touch-selection-lock-gestures.md new file mode 100644 index 0000000000000..d1f3ab7c2b7fc --- /dev/null +++ b/.changeset/touch-selection-lock-gestures.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host applies a recognizer's own `selectionLock` to that recognizer's contact and imports no lock itself; a long press brings one only when it has a hold (a `minimumPressDuration` above 0), and `selectionLock: null` opts out. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-selection-lock.md b/.changeset/touch-selection-lock.md new file mode 100644 index 0000000000000..e5285afc1f8ad --- /dev/null +++ b/.changeset/touch-selection-lock.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Where the primary pointer is fine (a mouse, a trackpad, a touchscreen laptop, a desktop pen tablet), the press feedback controller no longer registers a touch listener: it reads the primary pointer at the first finger or pen press and keeps its `touchcancel` listener only while that pointer is coarse. + +@vjeux diff --git a/.changeset/uikit-constants-table.md b/.changeset/uikit-constants-table.md index 01fd41a182b0b..2c063cdb50bc7 100644 --- a/.changeset/uikit-constants-table.md +++ b/.changeset/uikit-constants-table.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point; no component reads it yet. +[chore] Internal: the UIKit constants table for touch presentations (springs, scroll physics, timing curves and navigation values), each value with its source and a checked list of the simulator measurements still owed. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-animator-pseudo-element.md b/.changeset/uikit-motion-animator-pseudo-element.md new file mode 100644 index 0000000000000..7ccb10bab35da --- /dev/null +++ b/.changeset/uikit-motion-animator-pseudo-element.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator drives a pseudo-element such as a dialog's ::backdrop: a static filling animation holds its value after a curve ends and after `set`, a custom property on its host promotes it, and a Reduce Motion dip borrows and hands back the pseudo-element's own opacity. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md index d489e3022e6db..6b23ce1985186 100644 --- a/.changeset/uikit-motion-animator.md +++ b/.changeset/uikit-motion-animator.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point; no component uses it yet. +[chore] Internal: a compositor animator for touch presentations. It plays UIKit's springs, and scroll coasts (built by a separate coast module, so a surface that never coasts does not carry them; `decay` plays momentum, and sends a spring back from past an edge through the Reduce Motion policy: a cross-fade on a coarse pointer or after a touch release, at once elsewhere) as Web Animations on translate, rotate, scale and opacity, so they keep running while JavaScript is busy, and reads values and velocities back from the curves for interruption, retargeting and Reduce Motion cross-fades. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-math.md b/.changeset/uikit-motion-math.md index 7af3d73a92a98..cbb9399568857 100644 --- a/.changeset/uikit-motion-math.md +++ b/.changeset/uikit-motion-math.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the spring, decay, rubber-band and projection math of UIKit for touch presentations, as pure functions checked against an independent integration and against values read from UIKit. Not exported from the package entry point. @vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..b234a4e987752 --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: useAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-navigation-large-title-rise.md b/.changeset/uikit-navigation-large-title-rise.md new file mode 100644 index 0000000000000..dcfbd4bf17942 --- /dev/null +++ b/.changeset/uikit-navigation-large-title-rise.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: core's UIKit motion tables gain `navigation.inlineTitleRise`: the 15 px a navigation bar's title rises as it fades in, once a large title has scrolled under the bar, measured on iOS 26.2. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-navigation-stack-constants.md b/.changeset/uikit-navigation-stack-constants.md new file mode 100644 index 0000000000000..33d899b54b1a1 --- /dev/null +++ b/.changeset/uikit-navigation-stack-constants.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: core's UIKit motion tables gain the navigation values the lab NavigationStack runs on, each measured on iOS 26.2: `navigation.contentPopHysteresis` (the content back swipe's 27 pt start), `navigation.interactivePopVelocitySamples` (the release velocity averages the last 3), `navigation.interactivePopRubberBand` (past its start the back swipe's screen follows at 0.71 of the finger) and `springs.navigationInteractivePopReturn` (its return when released there), `springs.navigationBackButton` (the back chevron's fade), and `navigation.backChevronPushDelayMs` (when the chevron starts fading in on a push). The message catalog gains `@astryx.navigationStack.back`, the lab NavigationStack's back button label. The tables are not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-search-table.md b/.changeset/uikit-search-table.md new file mode 100644 index 0000000000000..081aad9460697 --- /dev/null +++ b/.changeset/uikit-search-table.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the UIKit constants table gains the iOS 26 search bar's values, measured on iOS 26.2: the activation and cancel clock (383 ms on ease-in-ease-out), the stacked field's geometry and its close button's entrance, the navigation bar's fades over an activation, the field scrolling away with the content, the bottom placement's rest above the keyboard and the clear button, each with its source. Lab's SearchField reads them. Nothing public changes: the table is not exported, and themes do not retune it. + +@vjeux diff --git a/.changeset/use-finger-scroll.md b/.changeset/use-finger-scroll.md new file mode 100644 index 0000000000000..e48f0b171df03 --- /dev/null +++ b/.changeset/use-finger-scroll.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useFingerScroll(scroller)` tells a finger's scroll from the program's, and the gestures README gains a guide to migrating a raw-touch drag onto the recognizers. Internal to the touch components: not exported from the package entry point or the gestures barrel. + +@vjeux diff --git a/apps/storybook/stories/AppShell.stories.tsx b/apps/storybook/stories/AppShell.stories.tsx index 82a16a6c68537..bdf1d2fe04f1c 100644 --- a/apps/storybook/stories/AppShell.stories.tsx +++ b/apps/storybook/stories/AppShell.stories.tsx @@ -21,6 +21,7 @@ import {useMediaQuery} from '@astryxdesign/core/hooks'; import {TextArea} from '@astryxdesign/core/TextArea'; import {TextInput} from '@astryxdesign/core/TextInput'; import * as stylex from '@stylexjs/stylex'; +import {colorVars} from '@astryxdesign/core/theme/tokens.stylex'; import { HomeIcon, Cog6ToothIcon, @@ -60,8 +61,41 @@ const styles = stylex.create({ flexDirection: 'column' as const, gap: 32, }, + // No top padding: the tiles meet the header, so they start passing under + // it at once. + tiles: { + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: 12, + paddingInline: 16, + paddingBlockEnd: 16, + }, + tile: { + display: 'flex', + alignItems: 'flex-end', + height: 120, + padding: 12, + borderRadius: 12, + color: colorVars['--color-on-dark'], + fontWeight: 600, + }, + tileBlue: {backgroundColor: colorVars['--color-icon-blue']}, + tileGreen: {backgroundColor: colorVars['--color-icon-green']}, + tileOrange: {backgroundColor: colorVars['--color-icon-orange']}, + tilePurple: {backgroundColor: colorVars['--color-icon-purple']}, + tilePink: {backgroundColor: colorVars['--color-icon-pink']}, + tileTeal: {backgroundColor: colorVars['--color-icon-teal']}, }); +const TILE_COLORS = [ + styles.tileBlue, + styles.tileGreen, + styles.tileOrange, + styles.tilePurple, + styles.tilePink, + styles.tileTeal, +]; + // ============================================================================= // Helpers // ============================================================================= @@ -246,7 +280,7 @@ const meta: Meta = { argTypes: { height: { control: 'radio', - options: ['fill', 'auto'], + options: ['fill', 'auto', 'adaptive'], }, variant: { control: 'radio', @@ -277,7 +311,7 @@ export const Playground: StoryObj = { }, height: { control: 'radio', - options: ['fill', 'auto'], + options: ['fill', 'auto', 'adaptive'], }, }, args: { @@ -505,6 +539,62 @@ export const AutoHeight: Story = { ), }; +/** + * Adaptive height: `fill` with a mouse or trackpad, `auto` where the primary + * pointer is touch. On a phone (or with touch emulation) the page itself + * scrolls under the sticky header, so a status-bar tap returns to the top and + * the browser's toolbar minimizes as you scroll. On a desktop the main region + * scrolls inside the viewport-high shell; the page's action gives keyboard + * users a stop inside it, from which the arrow keys scroll it. + */ +export const AdaptiveHeight: Story = { + render: () => ( + } + sideNav={} + height="adaptive"> +
+
+
+ +
+
+ ), +}; + +/** + * The touch bar over scrolling content. Under a finger, when the page scrolls + * (`height="auto"`, as here, or `height="adaptive"` on a phone), the pinned + * header paints no background of its own, as an iOS 26 bar does. Scroll the + * tiles under it: once they have moved 10px under the bar, a veil of the + * bar's colour fades in at the top of the screen, and it fades out when they + * come back. With a mouse the header stays opaque. + */ +export const TouchBarOverContent: Story = { + render: () => ( + } + height="auto" + variant="surface"> +
+ {Array.from({length: 18}, (_, index) => ( +
+ Tile {index + 1} +
+ ))} +
+
+ ), +}; + /** * Auto height mode with Field-based inputs. Scroll the inputs under the * sticky header — the header must stay above the input surfaces. Field diff --git a/apps/storybook/stories/List.stories.tsx b/apps/storybook/stories/List.stories.tsx index 881f53db5430d..78bfb30e335de 100644 --- a/apps/storybook/stories/List.stories.tsx +++ b/apps/storybook/stories/List.stories.tsx @@ -7,7 +7,7 @@ * @position Storybook demonstrations for List consumers */ -import {useState} from 'react'; +import {useRef, useState, type MouseEvent} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {Meta, StoryObj} from '@storybook/react'; import {List, ListItem} from '@astryxdesign/core/List'; @@ -18,6 +18,12 @@ import {Icon} from '@astryxdesign/core/Icon'; import {Switch} from '@astryxdesign/core/Switch'; import {Heading, Text} from '@astryxdesign/core/Text'; import {VStack} from '@astryxdesign/core/Layout'; +import {Button} from '@astryxdesign/core/Button'; +// The selection hold is internal to core (no public API records it yet). +import { + holdSelection, + type SelectionHold as RowSelectionHold, +} from '../../../packages/core/src/List/selectionHold'; import { Cog6ToothIcon, BellIcon, @@ -408,3 +414,118 @@ export const HeaderInFlexParent: Story = { }, }, }; + +function SelectionHoldDemo() { + const hold = useRef(null); + const slider = useRef(null); + const [open, setOpen] = useState(null); + const [progress, setProgress] = useState(0); + const push = (label: string) => (event: MouseEvent) => { + // In the same task as the activation that pushes. + hold.current = holdSelection(event.target); + setProgress(0); + setOpen(label); + }; + const settle = async ( + to: 0 | 1, + timing?: Parameters[1], + {followSlider = false}: {followSlider?: boolean} = {}, + ) => { + const current = hold.current; + // A slow pop shows where it is on the slider (written to the input, no + // render per frame), so a finger that takes the slider over mid-flight + // continues from the value on screen. (Off for the regular pop, whose + // proof counts every paint.) + const following = {frame: 0}; + const follow = () => { + if (current != null && slider.current != null) { + slider.current.value = String(current.presentation()); + } + following.frame = requestAnimationFrame(follow); + }; + if (followSlider) { + following.frame = requestAnimationFrame(follow); + } + // No hold under a mouse: the screen just pops. + const arrived = current == null || (await current.animate(to, timing)); + cancelAnimationFrame(following.frame); + if (!arrived) { + // Interrupted (a finger took the progress over mid-flight): the hold + // stays where the finger left it. + return; + } + if (to === 1) { + setOpen(null); + current?.end(true); + current?.focus({preventScroll: true}); + hold.current = null; + } else { + current?.end(false); + setProgress(0); + } + }; + return ( + + + {['Inbox', 'Sent', 'Drafts'].map(label => ( + } + /> + ))} + + {open != null && ( + + {open} (pushed) +
`; + const FIXTURE = ` +
`; + +async function busyFixture(page: Page) { + await page.route('http://busy.test/', route => + route.fulfill({contentType: 'text/html', body: BUSY_FIXTURE}), + ); + await page.goto('http://busy.test/'); +} + // A real navigation (not setContent, whose document.open() erases the window // listeners the input log installs). async function fixture(page: Page) { @@ -242,6 +298,894 @@ test.describe('touch input', () => { }); }); +test.describe('frame evidence', () => { + test('a compositor animation advances every frame through a blocked main thread; a rAF one freezes, whatever the screencast rate', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + + const run = async (driver: 'compositor' | 'raf', everyNthFrame = 1) => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const tracked = await trackMarker(page, await cast.stop()); + return busyWindowProof(tracked, window, {axis: 'y'}); + }; + + const compositor = await run('compositor'); + testInfo.annotations.push({ + type: 'busy-main-thread compositor', + description: compositor.summary, + }); + expect(compositor.verdict.failures, compositor.summary).toEqual([]); + expect(compositor.monotonic).toBe(true); + // Resolvable on the cast's grid: two position steps or more per frame. + expect(compositor.notes, compositor.summary).toEqual([]); + expect(compositor.pxPerFrame).toBeGreaterThanOrEqual(2 * compositor.stepPx); + + const raf = await run('raf'); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control', + description: raf.summary, + }); + // Frozen for the whole block: no presented frame moved (the compositor + // sends none at all when nothing on screen changes), and the verdict that + // passes the compositor animation fails it on every criterion. + expect(raf.changed).toBe(0); + expect(raf.longestStallMs).toBeGreaterThan(600); + expect(raf.verdict.pass).toBe(false); + expect(raf.verdict.failures.join('\n')).toMatch(/longest stall/); + expect(raf.verdict.failures.join('\n')).toMatch(/expected frames moved/); + + // The verdict judges the frames the screencast delivered, not its rate: + // at one frame in six (a loaded machine's ~100 ms), the compositor + // animation still passes and the rAF one still fails. + const slow = await run('compositor', 6); + expect(slow.cadenceMs, slow.summary).toBeGreaterThan(60); + expect(slow.verdict.failures, slow.summary).toEqual([]); + const slowRaf = await run('raf', 6); + expect(slowRaf.verdict.pass, slowRaf.summary).toBe(false); + expect(slowRaf.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('frames the screencast skips while the compositor presents them are not a stall; a frozen animation still fails with the compositor record', async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'compositor traces are Chromium-only (README: engine limits)', + ); + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const run = async (driver: 'compositor' | 'raf') => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page); + const compositor = await startCompositorTrace(page); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const presented = await compositor.stop(); + const tracked = await trackMarker(page, await cast.stop()); + return {window, tracked, presented}; + }; + + const moving = await run('compositor'); + expect(moving.presented.length).toBeGreaterThan(0); + // The screencast as a loaded machine delivers it: four frames in a row + // skipped mid-block, which the compositor still presented. + const mid = moving.tracked.findIndex(f => f.t > moving.window.from + 350); + const skipped = moving.tracked.filter((_, i) => i < mid || i >= mid + 4); + const alone = busyWindowProof(skipped, moving.window, {axis: 'y'}); + expect(alone.verdict.failures.join('\n'), alone.summary).toMatch( + /longest stall/, + ); + const proof = busyWindowProof(skipped, moving.window, { + axis: 'y', + presented: moving.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread compositor record', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.recorderSkipped).toBeGreaterThanOrEqual(4); + + // A rAF animation presents no change for the whole block: the record + // excuses nothing, whatever the compositor drew meanwhile. + const frozen = await run('raf'); + const still = busyWindowProof(frozen.tracked, frozen.window, { + axis: 'y', + presented: frozen.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control, compositor record', + description: `${still.summary}; compositor frames inside the block: ${frozen.presented.filter(t => t > frozen.window.from && t < frozen.window.to).length}`, + }); + expect(still.verdict.pass).toBe(false); + expect(still.recorderSkipped).toBe(0); + expect(still.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('a block that starts during a just-started transition still lets the compositor advance it', async ({ + page, + }, testInfo) => { + // `during` blocks in the frame the transition has run 16 ms. In WebKit + // the transition reaches the compositor in that frame's rendering update, + // so a loop inside the rAF callback froze it (2 of 29 frames moved). + // Linear and longer than the block, so every frame of the block moves two + // position steps or more and the transition is still running when it + // ends. The cast shows the box at rest first, so a transition that reaches + // the screen after the block began is judged from there (onScreenFromMs). + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const cast = await startScreencast(page); + await expect.poll(() => cast.frameCount()).toBeGreaterThan(0); + await box.evaluate(el => { + el.style.transition = 'transform 1600ms linear'; + el.getBoundingClientRect(); + el.style.transform = 'translateY(560px)'; + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 16, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread transition', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('a block never starts on an animation that is not on the compositor yet', async ({ + page, + }, testInfo) => { + // An animation created in the frame the helper looks is still pending (not + // committed to the compositor). A block that started on it then froze it + // in Chromium: no presented frame moved. + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + await box.evaluate(el => { + el.style.willChange = 'transform'; + }); + const cast = await startScreencast(page); + // A rAF loop registered before the helper's tick runs ahead of it in every + // frame; on its 12th frame it creates the animation, and the helper's tick + // looks in that same frame. + await box.evaluate(el => { + let n = 0; + const loop = () => { + n += 1; + if (n === 12) { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + return; + } + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 0, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread pending animation', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('in the WebKit container, compiled shaders outlive the run', async ({ + browser, + }, testInfo) => { + // The official Playwright image keeps its browsers here. + test.skip( + testInfo.project.name !== 'webkit' || + process.env.PLAYWRIGHT_BROWSERS_PATH !== '/ms-playwright', + 'runs inside webkit-container.mjs', + ); + // A cache inside the container dies with it: every run then compiled each + // shader again, 450–700 ms in the first frame of a blurred layer. + const dir = process.env.MESA_SHADER_CACHE_DIR; + expect(dir, 'MESA_SHADER_CACHE_DIR names the host-kept cache').toBeTruthy(); + // Written on the host by the script before the container starts. + expect( + fs.readFileSync(path.join(dir ?? '', 'ASTRYX-SHADER-CACHE'), 'utf8'), + ).toContain('playwright'); + // Mesa writes what it compiles there once a page draws a blurred layer. + // Earlier runs have filled the cache already, so this run's browser gets + // an empty directory beside them, in the same host-kept mount, and the + // test watches its own writes arrive. + const fresh = fs.mkdtempSync(path.join(dir ?? '', 'self-test-')); + try { + const own = await browser.browserType().launch({ + env: {...process.env, MESA_SHADER_CACHE_DIR: fresh}, + }); + try { + const page = await own.newPage(); + await page.setContent( + '
', + ); + await page.evaluate( + () => + document + .getElementById('glass') + ?.animate([{scale: '1'}, {scale: '0.9'}], {duration: 200}) + .finished, + ); + } finally { + await own.close(); + } + expect(fs.readdirSync(fresh, {recursive: true})).not.toEqual([]); + } finally { + fs.rmSync(fresh, {recursive: true, force: true}); + } + }); + + test('rect sampling reads one rect per animation frame while the main thread is free', async ({ + page, + browserName, + }) => { + await fixture(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600, easing: 'linear'}, + ); + }); + const samples = await sampleRects(box, 400); + const xs = samples.map(s => s.box?.x ?? NaN); + const gaps = samples + .slice(1) + .map((s, i) => s.t - samples[i].t) + .sort((a, b) => a - b); + const cadence = gaps[Math.floor(gaps.length / 2)]; + expect(cadence).toBeLessThan(34); + expect(samples.length).toBeGreaterThanOrEqual(10); + if (browserName === 'chromium') { + // One sample per presented frame at 60 Hz. (Headless WebKit's animation + // frames are irregular, so only the cadence is checked there.) + expect(samples.length).toBeGreaterThanOrEqual(20); + } + expect(xs.every((x, i) => i === 0 || x >= xs[i - 1])).toBe(true); + expect(xs.at(-1) ?? 0).toBeGreaterThan(xs[0] + 80); + }); + + test('the trace counts no Layout for a transform animation and one per frame for `top`', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const box = page.locator('#box'); + // Count the animation's steady middle: starting and ending it may lay out once each. + const animate = async (property: 'transform' | 'top') => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await box.evaluate((el, prop) => { + const keyframes = + prop === 'transform' + ? [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(200px)'}, + ] + : [{top: '360px'}, {top: '560px'}]; + el.animate(keyframes, {duration: 600}); + return performance.timeOrigin + performance.now(); + }, property); + await page.waitForTimeout(700); + }); + return countBetween(trace, started + 100, started + 500); + }; + const transform = await animate('transform'); + const top = await animate('top'); + expect(transform.Layout ?? 0).toBe(0); + expect(transform.Paint ?? 0).toBe(0); + // ~24 frames in 400 ms: one layout per frame. + expect(top.Layout ?? 0).toBeGreaterThan(18); + }); + + test("the trace counts no Layout from the harness's own animation recorder, which stands down while it runs", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + // The recorder runs, as in any spec that waited for an animation first. + await waitForRest(pop); + const watch = await watchAnimations(pop); + const middle = async (askEveryFrame: boolean) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate((el, every) => { + const w = window as unknown as {stop?: boolean}; + w.stop = false; + if (every) { + const ask = () => { + if (!w.stop) { + requestAnimationFrame(ask); + } + }; + requestAnimationFrame(ask); + } + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }, askEveryFrame); + await page.waitForTimeout(700); + await page.evaluate(() => { + (window as unknown as {stop?: boolean}).stop = true; + }); + }); + // The steady middle: starting and ending it may lay out once each. + return countBetween(trace, started + 100, started + 500); + }; + expect((await middle(false)).Layout ?? 0).toBe(0); + // Control: a page that asks for a frame every frame lays it out in each. + expect((await middle(true)).Layout ?? 0).toBeGreaterThan(18); + // Nothing the traces ran went unrecorded. + const started = await watch.started(); + expect(started.map(a => a.durationMs)).toEqual([600, 600]); + }); + + test('a waitForRest awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // A per-frame wait here would make every frame a main-thread frame. + await waitForRest(pop); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // It still waited for the curve to end. + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForAnimation or watch.started() awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + let waited: {durationMs: number}[] = []; + let watched: {durationMs: number}[] = []; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // Both poll until 450 ms of the curve has played, across the steady + // middle; a read of the page's animations each time would lay it out. + waited = await waitForAnimation(pop, {minProgressMs: 450}); + watched = await watch.started({minProgressMs: 450}); + await page.waitForTimeout(200); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // Each still answered with the animation once 450 ms of it had played. + expect(waited.map(a => a.durationMs)).toEqual([600]); + expect(watched.map(a => a.durationMs)).toEqual([600]); + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for an animation the action starts two frames later', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const trace = await traceRendering(page, async () => { + // The action starts the animation two frames later, as a component's + // effect often does. + await pop.evaluate(el => { + requestAnimationFrame(() => + requestAnimationFrame(() => { + (window as unknown as {__started: number}).__started = + performance.timeOrigin + performance.now(); + el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600}, + ); + }), + ); + }); + await waitForRest(pop); + }); + const started = await page.evaluate( + () => (window as unknown as {__started?: number}).__started ?? null, + ); + // The traced window holds the whole animation. + expect(started).not.toBeNull(); + expect(trace.window.to - (started as number)).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for a run going backward to its end, and no longer', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + // How long a traced waitForRest holds after a 600 ms move starts. + const traced = async (reverse: boolean, endDelay = 0) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate( + (el, {reverse, endDelay}) => { + const run = el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600, endDelay}, + ); + if (reverse) { + // Its current time falls from its end time to 0. + run.reverse(); + } + return performance.timeOrigin + performance.now(); + }, + {reverse, endDelay}, + ); + await waitForRest(pop); + }); + await waitForRest(pop); + return trace.window.to - started; + }; + // Reversed with a 200 ms end delay: the move plays from 200 to 800 ms. + expect(await traced(true, 200)).toBeGreaterThanOrEqual(790); + // A reversed run is waited for as long as the same run forward, not + // twice as long. + const forward = await traced(false); + const backward = await traced(true); + expect(backward).toBeGreaterThanOrEqual(590); + expect(backward).toBeLessThan(forward * 1.5); + }); + + test('a waitForRest awaited inside a traced body runs on the real timers, so a frozen timer gate cannot hold it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + await installTimerGate(page); + await freezeTimers(page); + let outcome = ''; + await traceRendering(page, async () => { + await pop.evaluate(el => { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(100px)'}], + {duration: 300}, + ); + }); + // Held by the gate, the wait would neither resolve nor reach its + // deadline: the race reads that as "held". + outcome = await Promise.race([ + waitForRest(pop, {timeout: 2000}).then( + () => 'rested', + () => 'rejected', + ), + new Promise(resolve => setTimeout(() => resolve('held'), 4000)), + ]); + }); + await thawTimers(page); + expect(outcome).toBe('rested'); + }); + + test('inside a trace, waitForRest rests beside a scroll-timeline animation', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const scroller = page.locator('#scroller'); + await waitForRest(scroller); + let outcome = ''; + await traceRendering(page, async () => { + // A row driven by its scroller's timeline: it moves only on scroll. + await scroller.evaluate(el => { + const row = el.firstElementChild as HTMLElement; + row.animate([{opacity: 0.5}, {opacity: 1}], { + timeline: new ScrollTimeline({source: el}), + }); + }); + outcome = await waitForRest(scroller, {timeout: 2000}).then( + () => 'rested', + (error: Error) => error.message, + ); + }); + expect(outcome).toBe('rested'); + }); + + test('a CSS transition and animation that start and end inside a trace are reported, and lay out nothing in between', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + const pop = page.locator('#pop'); + // The recorder runs, and a watch is taken before the trace. + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + const pop = el as HTMLElement; + pop.style.transition = 'transform 400ms linear'; + void pop.offsetWidth; + pop.style.transform = 'translateY(100px)'; + pop.style.animation = 'harness-fade 400ms linear'; + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(600); + }); + // Its reads at the two starts fall before the steady middle. + expect(countBetween(trace, started + 100, started + 300).Layout ?? 0).toBe( + 0, + ); + const ended = await watch.started({timeout: 1_000}); + expect(ended.map(a => a.kind).sort()).toEqual([ + 'CSSAnimation', + 'CSSTransition', + ]); + expect(ended.every(a => a.finishedAt != null)).toBe(true); + }); + + test('the compositor record refuses another open page, whose frames it could not tell apart', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the compositor record is a Chromium trace (README: engine limits)', + ); + await fixture(page); + // Another page animating: its presented frames would be credited to + // this one, so the record refuses to start. + const other = await page.context().newPage(); + await other.setContent( + '
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); +}); + test.describe('curve sampling', () => { test('a CSS transition samples onto its cubic-bezier to 0.05 px', async ({ page, @@ -961,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, }) => { @@ -1025,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, }) => { @@ -1076,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'); @@ -1429,6 +2517,34 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); + 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); + await installTimerGate(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 1_500, easing: 'linear'}, + ); + }); + await freezeTimers(page); + // Settled, or still held after 5 s. + const within = (work: Promise) => + Promise.race([ + work.then(() => 'settled'), + new Promise(resolve => + setTimeout(() => resolve('held'), 5_000), + ), + ]); + expect( + await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), + ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); + await thawTimers(page); + }); + test('the timer gate holds a component timer until the filmstrip is done', async ({ page, }, testInfo) => { @@ -1614,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 537bfc4d4beca..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -12,10 +12,13 @@ * SYNC: When a module is added, export it here and describe it in README.md. */ +export * from './busy'; export * from './curves'; +export * from './frames'; export * from './math'; export * from './output'; 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 598f75d852cfe..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); @@ -119,7 +163,7 @@ const TIMER_GATE = `(() => { const realInterval = window.setInterval.bind(window); const realFrame = window.requestAnimationFrame.bind(window); const realCancelFrame = window.cancelAnimationFrame.bind(window); - const gate = {frozen: false, queue: [], frame: realFrame}; + const gate = {frozen: false, queue: [], frame: realFrame, timeout: realTimeout}; window.__harnessTimerGate = gate; const drop = key => { gate.queue = gate.queue.filter(entry => entry.key !== key); }; window.setTimeout = (fn, ms, ...args) => { 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/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index 97b4458b92bda..b7af190c055e1 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -625,6 +625,16 @@ "path": "components/lab/mobile-tokenizer", "aliases": [] }, + { + "name": "component-lab-navigation-screen", + "path": "components/lab/navigation-screen", + "aliases": [] + }, + { + "name": "component-lab-navigation-stack", + "path": "components/lab/navigation-stack", + "aliases": [] + }, { "name": "component-lab-radial-chart", "path": "components/lab/radial-chart", @@ -640,6 +650,11 @@ "path": "components/lab/schedule", "aliases": [] }, + { + "name": "component-lab-search-field", + "path": "components/lab/search-field", + "aliases": [] + }, { "name": "component-lab-stat", "path": "components/lab/stat", diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/cli/assets/discover/official-packages.json b/packages/cli/assets/discover/official-packages.json index 1bc625e358829..6bf972997ce12 100644 --- a/packages/cli/assets/discover/official-packages.json +++ b/packages/cli/assets/discover/official-packages.json @@ -152,6 +152,14 @@ "kind": "component", "name": "MobileTokenizer" }, + { + "kind": "component", + "name": "NavigationScreen" + }, + { + "kind": "component", + "name": "NavigationStack" + }, { "kind": "component", "name": "RadialChart" @@ -164,6 +172,10 @@ "kind": "component", "name": "Schedule" }, + { + "kind": "component", + "name": "SearchField" + }, { "kind": "component", "name": "Stat" diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index a3aa68558d1d7..15b586f874f60 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -2090,5 +2090,17 @@ "@astryx.comments.postFailed": { "defaultMessage": "Your comment couldn't be posted.", "description": "Error shown above the new-comment composer of a lab Comments panel when posting failed; the draft is kept and a Retry action follows." + }, + "@astryx.navigationStack.back": { + "defaultMessage": "Back", + "description": "Label of the back button in a NavigationStack's bar when the screen it returns to has no title of its own. Returns to the previous screen; keep it short, as on an iOS navigation bar." + }, + "@astryx.searchField.cancel": { + "defaultMessage": "Cancel", + "description": "Accessible name of a lab SearchField's close button, which ends the search: it clears the query, ends editing and deactivates the field." + }, + "@astryx.searchField.clear": { + "defaultMessage": "Clear text", + "description": "Accessible name of a lab SearchField's clear button, which empties the query." } } diff --git a/packages/core/package.json b/packages/core/package.json index 8bd491e6273cb..0283c22d73e71 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -819,6 +819,11 @@ "types": "./dist/Markdown/remark.d.ts", "default": "./dist/Markdown/remark.js" }, + "./internal/motion": { + "source": "./src/internal/motion.ts", + "types": "./dist/internal/motion.d.ts", + "default": "./dist/internal/motion.js" + }, "./internal/touch": { "source": "./src/internal/touch.ts", "types": "./dist/internal/touch.d.ts", @@ -859,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/AppShell/AppShell.doc.mjs b/packages/core/src/AppShell/AppShell.doc.mjs index 2a5b592458843..da7bd2368f737 100644 --- a/packages/core/src/AppShell/AppShell.doc.mjs +++ b/packages/core/src/AppShell/AppShell.doc.mjs @@ -96,7 +96,7 @@ export const docs = { name: 'Main content', required: true, description: - 'children, rendered in the main landmark. Scrolls internally when height is fill, and with the page when it is auto.', + 'children, rendered in the main landmark. Scrolls internally when height is fill, and with the page when it is auto. With adaptive it scrolls internally with a mouse or trackpad and with the page on a touch device.', }, { name: 'Mobile nav drawer', @@ -141,7 +141,7 @@ export const docs = { // coincidence. The legal values live in the description instead (#1645). type: 'ReactNode', description: - "Mobile navigation configuration. Accepts false (disable), a config object (tune auto behavior), or ReactNode (full custom drawer). The config object is {hasToggle?: boolean, isOpen?: boolean, onOpenChange?: (isOpen: boolean) => void, content?: ReactNode, breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'none', defaultIsMobile?: boolean}; breakpoint defaults to 'md', resolves through the nearest Theme's widthBreakpoints, and switches to the wider layout at equality. 'none' is always non-mobile and ignores defaultIsMobile.", + "Mobile navigation configuration. Accepts false (disable), a config object (tune auto behavior), or ReactNode (full custom drawer). The config object is {hasToggle?: boolean, isOpen?: boolean, onOpenChange?: (isOpen: boolean) => void, content?: ReactNode, breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'none', defaultIsMobile?: boolean}; breakpoint defaults to 'md', resolves through the nearest Theme's widthBreakpoints, and switches to the wider layout at equality. 'none' is always non-mobile and ignores defaultIsMobile for the layout; with height=\"adaptive\", defaultIsMobile also seeds the server render's touch assumption (the page scrolls), whatever the breakpoint.", slotElements: [{__element: 'MobileNav', props: {}}], }, { @@ -158,9 +158,9 @@ export const docs = { }, { name: 'height', - type: "'fill' | 'auto'", + type: "'fill' | 'auto' | 'adaptive'", description: - "Height behavior: 'fill' makes the shell fill the viewport (100dvh) with independent scroll containers; 'auto' lets the shell grow with content and uses sticky positioning for nav.", + "Height behavior: 'fill' makes the shell fill the viewport (100dvh) with independent scroll containers; 'auto' lets the shell grow with content and uses sticky positioning for nav; 'adaptive' is 'fill' with a mouse or trackpad and 'auto' where the primary pointer is touch, so phones keep the browser's own page behaviors (scroll to top from the status bar, a toolbar that minimizes as you scroll). The root reports which element scrolls in `data-scroll-owner`: `content` (the main region) or `document` (the page). Under a finger, when the page scrolls, the pinned header paints no background of its own, as an iOS 26 bar does: once the content has moved more than 10px under it, a veil of the header's colour fades in at its top (strongest at the top, gone just below its bottom edge), and it fades out when the content comes back. A scroll the program drives (`scrollTo`, a status-bar tap) switches the veil at once instead of fading it. With a mouse the header stays opaque.", default: "'fill'", }, { @@ -280,7 +280,7 @@ export const docsZh = { name: 'mobileNav', type: 'ReactNode', description: - "移动端导航配置。接受 false(禁用)、配置对象(调整自动行为)或 ReactNode(完全自定义抽屉)。配置对象为 {hasToggle?, isOpen?, onOpenChange?, content?, breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'none', defaultIsMobile?};breakpoint 默认为 'md',从最近 Theme 的 widthBreakpoints 解析,并在等于断点时切换到较宽布局;'none' 始终使用非移动端布局并忽略 defaultIsMobile。", + "移动端导航配置。接受 false(禁用)、配置对象(调整自动行为)或 ReactNode(完全自定义抽屉)。配置对象为 {hasToggle?, isOpen?, onOpenChange?, content?, breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'none', defaultIsMobile?};breakpoint 默认为 'md',从最近 Theme 的 widthBreakpoints 解析,并在等于断点时切换到较宽布局;'none' 始终使用非移动端布局,布局上忽略 defaultIsMobile;当 height=\"adaptive\" 时,defaultIsMobile 还决定服务端渲染是否假定触摸输入(由页面滚动),与断点无关。", }, { name: 'banner', @@ -289,9 +289,9 @@ export const docsZh = { }, { name: 'height', - type: "'fill' | 'auto'", + type: "'fill' | 'auto' | 'adaptive'", description: - "高度行为:'fill' 使外壳填满视口(100dvh),各区域拥有独立的滚动容器;'auto' 使外壳随内容增长,导航使用 sticky 定位。", + "高度行为:'fill' 使外壳填满视口(100dvh),各区域拥有独立的滚动容器;'auto' 使外壳随内容增长,导航使用 sticky 定位;'adaptive' 在使用鼠标或触控板时等同 'fill',在主要指针为触控的设备上等同 'auto',让手机保留浏览器自身的页面行为(点按状态栏回到顶部、滚动时工具栏收起)。根元素通过 `data-scroll-owner` 标明滚动的元素:`content`(主内容区)或 `document`(页面)。在页面滚动的触控设备上,固定的页眉不绘制自身背景,如同 iOS 26 的栏:内容移入其下方超过 10px 后,页眉顶部淡入一层与页眉同色的遮罩(顶部最浓,延伸到底边下方不远处消失),内容回到原位时淡出。使用鼠标时页眉保持不透明。", default: "'fill'", }, { @@ -370,10 +370,10 @@ export const docsDense = { topNav: 'top nav slot, typically TopNav', sideNav: 'side nav slot, typically SideNav', mobileNav: - 'mobile nav config: false | ReactNode | config with theme-resolved sm|md|lg|xl|2xl|none breakpoint (exclusive upper edge); none is always non-mobile and ignores defaultIsMobile', + 'mobile nav config: false | ReactNode | config with theme-resolved sm|md|lg|xl|2xl|none breakpoint (exclusive upper edge); none is always non-mobile and ignores defaultIsMobile for the layout; with adaptive height defaultIsMobile also seeds the SSR touch assumption', banner: 'slot for system-wide announcements above topNav', height: - 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav', + 'fill=viewport 100dvh w/ independent scroll; auto=content-driven w/ sticky nav; adaptive=fill w/ mouse, auto on touch (page scroll); root data-scroll-owner=content|document; iOS touch+page scroll: header has no background, iOS 26 scroll-edge veil fades in past 10px of content under it (elsewhere opaque)', variant: 'nav bg style: wash=wash bg, surface=surface bg, section=dividers, elevated=wash nav w/ elevated surface content+radius', contentPadding: diff --git a/packages/core/src/AppShell/AppShell.test.tsx b/packages/core/src/AppShell/AppShell.test.tsx index 7f0117116782e..62670a75d46f3 100644 --- a/packages/core/src/AppShell/AppShell.test.tsx +++ b/packages/core/src/AppShell/AppShell.test.tsx @@ -18,6 +18,7 @@ import { beforeEach, afterEach, } from 'vitest'; +import {readFileSync} from 'node:fs'; import {renderToString} from 'react-dom/server'; import {act, render, screen, fireEvent} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -1146,3 +1147,209 @@ describe('AppShell', () => { ).toBe(true); }); }); + +describe('AppShell page scroll owner', () => { + // One mock list per query, so a test can make the primary pointer a finger + // without also crossing the width breakpoint (or the other way around). + function mockQueries(matchesFor: (query: string) => boolean) { + const lists = new Map>(); + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => { + let list = lists.get(query); + if (list == null) { + list = createMockMatchMedia(matchesFor(query)); + lists.set(query, list); + } + return list; + }), + ); + return lists; + } + + const TOUCH = '(pointer: coarse)'; + + it('reads touch in script only through the shared touch resolver', () => { + const source = readFileSync( + 'packages/core/src/AppShell/AppShell.tsx', + 'utf8', + ); + expect(source).toContain('useTouchPresentation('); + // No pointer check of its own in script: no media query on the pointer, + // literal or through a constant. A StyleX key spelled as the resolver's + // constant ('@media (pointer: coarse)') stays allowed; the resolver's + // spelling test pins it. + expect(source).not.toMatch( + /(?:matchMedia|useMediaQuery)\(\s*['"`][^'"`]*pointer/, + ); + expect(source).not.toMatch( + /(?:matchMedia|useMediaQuery)\(\s*[A-Z_]*(?:TOUCH|POINTER)/, + ); + }); + + it('adaptive scrolls the page, not the main region, where the primary pointer is touch', () => { + mockQueries(query => query === TOUCH); + render( + Nav} data-testid="shell"> +
Content
+
, + ); + + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'document', + ); + expect(getComputedStyle(screen.getByRole('banner')).position).toBe( + 'sticky', + ); + expect(getComputedStyle(screen.getByRole('main')).overflow).toBe('clip'); + }); + + it('adaptive keeps the main region as the scroller with a fine pointer', () => { + mockQueries(() => false); + render( + Nav} data-testid="shell"> +
Content
+
, + ); + + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'content', + ); + expect(getComputedStyle(screen.getByRole('banner')).position).not.toBe( + 'sticky', + ); + expect(getComputedStyle(screen.getByRole('main')).overflow).toBe('auto'); + }); + + it('fill keeps the main region as the scroller on a touch device', () => { + mockQueries(query => query === TOUCH); + render( + Nav} data-testid="shell"> +
Content
+
, + ); + + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'content', + ); + expect(getComputedStyle(screen.getByRole('main')).overflow).toBe('auto'); + }); + + it('auto scrolls the page whatever the pointer', () => { + mockQueries(() => false); + render( + Nav} data-testid="shell"> +
Content
+
, + ); + + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'document', + ); + }); + + it('a height outside the union keeps the auto minimum height, with no sticky regions', () => { + mockQueries(() => false); + render( + Nav} + data-testid="shell"> +
Content
+
, + ); + + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'document', + ); + expect(getComputedStyle(screen.getByRole('banner')).position).not.toBe( + 'sticky', + ); + expect(getComputedStyle(screen.getByRole('main')).overflow).not.toBe( + 'auto', + ); + }); + + it('adaptive keeps the side navigation mounted when the primary pointer changes, so a collapsed SideNav stays collapsed', async () => { + const lists = mockQueries(() => false); + const user = userEvent.setup(); + render( + Nav} + sideNav={ + + + + + + }> +
Content
+
, + ); + + await user.click(screen.getByRole('button', {name: 'Collapse sidebar'})); + expect( + screen.getByRole('button', {name: 'Expand sidebar'}), + ).toBeInTheDocument(); + + act(() => lists.get(TOUCH)?._setMatches(true)); + expect( + screen.getByRole('button', {name: 'Expand sidebar'}), + ).toBeInTheDocument(); + + act(() => lists.get(TOUCH)?._setMatches(false)); + expect( + screen.getByRole('button', {name: 'Expand sidebar'}), + ).toBeInTheDocument(); + }); + + it('adaptive follows the primary pointer when it changes', () => { + const lists = mockQueries(() => false); + render( + Nav} data-testid="shell"> +
Content
+
, + ); + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'content', + ); + + act(() => lists.get(TOUCH)?._setMatches(true)); + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'document', + ); + + act(() => lists.get(TOUCH)?._setMatches(false)); + expect(screen.getByTestId('shell')).toHaveAttribute( + 'data-scroll-owner', + 'content', + ); + }); + + it('adaptive starts out scrolling the page on the server when the mobile SSR hint is set', () => { + const host = document.createElement('div'); + host.innerHTML = renderToString( + } + mobileNav={{defaultIsMobile: true}} + data-testid="shell"> +
Content
+
, + ); + + expect( + host + .querySelector('[data-testid="shell"]') + ?.getAttribute('data-scroll-owner'), + ).toBe('document'); + }); +}); diff --git a/packages/core/src/AppShell/AppShell.tsx b/packages/core/src/AppShell/AppShell.tsx index 04fbb2808ac3e..08ac3be63fe63 100644 --- a/packages/core/src/AppShell/AppShell.tsx +++ b/packages/core/src/AppShell/AppShell.tsx @@ -7,7 +7,9 @@ import React from 'react'; /** * @file AppShell.tsx * @input Uses React, Theme-resolved breakpoints, Layout, navigation slots, and StyleX - * @output Exports AppShell component and AppShellProps with SSR-safe mobile layout + * @output Exports AppShell component and AppShellProps with SSR-safe mobile layout; + * on a touch screen whose page scrolls, the pinned header is an iOS 26 bar + * (no background, the scroll edge effect of motion/useScrollEdgeEffect) * @position Page shell for an application — the top-level wrapper for any app. * Composes Layout internally to provide header, sideNav, and main content areas. * Use for any app that needs a top nav, side navigation, and scrollable content. @@ -18,6 +20,8 @@ import React from 'react'; * - /packages/core/src/AppShell/AppShell.test.tsx * - /apps/storybook/stories/AppShell.stories.tsx * - /packages/cli/assets/templates/blocks/components/AppShell/ (showcase blocks) + * - /packages/core/src/AppShell/AppShellScrollEdge.test.tsx and + * __tests__/AppShellScrollEdge.interaction.browser.spec.ts (the touch bar) */ import { @@ -54,6 +58,9 @@ import type {BaseProps} from '../BaseProps'; import {mergeProps, isRenderable} from '../utils'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {useMediaQuery} from '../hooks/useMediaQuery'; +import {useTouchPresentation} from '../utils/touchPresentation'; +import {useScrollEdgeEffect} from '../motion/useScrollEdgeEffect'; +import {bars, registerBarsTable} from '../motion/uikit/bars'; import {observeResize} from '../utils/sharedResizeObserver'; import {themeProps} from '../utils/themeProps'; import {useTranslator} from '../i18n'; @@ -162,8 +169,10 @@ export interface MobileNavConfig { /** * SSR hint: whether the initial render should assume mobile layout. * Seeds the breakpoint state so the server-rendered HTML matches - * the client on mobile devices, avoiding a layout flash. Ignored when - * `breakpoint` is `none`, which is always non-mobile. + * the client on mobile devices, avoiding a layout flash. The breakpoint + * seed is ignored when `breakpoint` is `none`, which is always non-mobile. + * With `height="adaptive"` it also seeds the touch-pointer assumption of + * the server render (the page scrolls), whatever the breakpoint. * * Derive from the User-Agent header or a device-detection cookie * in a server component, then pass down. @@ -211,9 +220,16 @@ export interface AppShellProps extends BaseProps { * Height behavior: * - `fill`: Shell fills viewport, content scrolls internally (default) * - `auto`: Shell grows with content, page scrolls as a whole + * - `adaptive`: `fill` with a mouse or trackpad, `auto` where the primary + * pointer is touch, so phones keep the browser's own page behaviors + * (scroll to top from the status bar, a toolbar that minimizes as you + * scroll) + * + * The root reports which element scrolls in `data-scroll-owner`: + * `content` (the main region) or `document` (the page). * @default 'fill' */ - height?: 'fill' | 'auto'; + height?: 'fill' | 'auto' | 'adaptive'; /** * Mobile navigation configuration. @@ -421,6 +437,50 @@ const styles = stylex.create({ position: 'sticky', top: 0, zIndex: 1, + // The local stacking owner for the scroll edge veil's zIndex: -1 + // (spec:AST-027 FR2). + isolation: 'isolate', + }, + // iOS 26's bar: under a finger (a coarse primary pointer, one touch model on + // every touch device), a bar pinned over content that scrolls under it + // paints no background of its own (UIKit's bar background is empty in every + // appearance). What separates the content once it has moved under the bar + // is the scroll edge effect below. A mouse keeps the opaque bar. The key is + // the resolver's touch query, spelled literally as TOUCH_QUERY (the StyleX + // build gives an imported at-rule key a different priority layer). + navAreaWashTouchBar: { + backgroundColor: { + default: colorVars['--color-background-body'], + '@media (pointer: coarse)': 'transparent', + }, + }, + navAreaSurfaceTouchBar: { + backgroundColor: { + default: colorVars['--color-background-surface'], + '@media (pointer: coarse)': 'transparent', + }, + }, + // A variant added by module augmentation paints no header fill of its own + // with a mouse, as before; under a finger it is the same empty bar. + navAreaTouchBar: { + backgroundColor: { + default: null, + '@media (pointer: coarse)': 'transparent', + }, + }, + // The iOS 26 bar draws no hairline: the scroll edge effect separates the + // content instead. A coarse-pointer rule, not the resolver's guess, so a + // server render (`defaultIsMobile`) is right for either pointer. + headerDividerTouchBar: { + borderBlockEndWidth: { + default: 1, + '@media (pointer: coarse)': 0, + }, + }, + // The scroll edge effect's veil (useScrollEdgeEffect) sits behind the + // bar's content. + scrollEdgeBehind: { + zIndex: -1, }, // Sticky sideNav for auto height mode — sticks within the wrapper. // This div replaces the panel as the direct flex child of the middle @@ -444,6 +504,11 @@ const styles = stylex.create({ flex: 1, overflow: 'auto', }, + // `adaptive` with a fine pointer: the side region's wrapper stays mounted + // but generates no box. + sideNavInFill: { + display: 'contents', + }, }); // ============================================================================= @@ -455,7 +520,7 @@ const styles = stylex.create({ * top navigation, side navigation, and main content area. * * Slot-based API with `topNav`, `sideNav`, `banner`, and `children`. - * Supports two height modes (`fill` and `auto`), responsive side nav + * Supports three height modes (`fill`, `auto` and `adaptive`), responsive side nav * collapse, and mobile overlay with backdrop. * * @example @@ -590,8 +655,21 @@ export function AppShell({ document.getElementById(MAIN_CONTENT_ID)?.focus(); }, []); - const isFill = height === 'fill'; - const isAuto = height === 'auto'; + // `adaptive` resolves through the shared touch resolver: the page scrolls + // under a finger (a coarse primary pointer). The browser attaches its page + // behaviors to the document's own scroll (the toolbar or address bar that + // minimizes as the page scrolls, the pull at the top; on iOS, scroll to top + // from a status-bar tap), and a scroll region inside a viewport-high shell + // gets none of them. The SSR hint for mobile layout also seeds it, so a + // server-rendered phone page starts out scrolling the page. + const {isTouch} = useTouchPresentation({ + isTouch: mobileNavConfig?.defaultIsMobile ?? false, + }); + // A `height` outside the union keeps the recorded `auto` minimum height, + // with no sticky regions, as before `adaptive` existed. + const isFill = height === 'fill' || (height === 'adaptive' && !isTouch); + const isAuto = height === 'auto' || (height === 'adaptive' && isTouch); + const scrollsDocument = !isFill; // Nav style derived values const hasBanner = isRenderable(banner); @@ -610,9 +688,17 @@ export function AppShell({ : undefined; // Section normally inherits the shell's surface background. Its auto-height // header needs to paint that surface itself while content scrolls beneath it. - const headerAreaStyle = - navAreaStyle ?? - (isAuto && variant === 'section' ? styles.navAreaSurface : undefined); + // On a touch screen the pinned header paints nothing (navArea*TouchBar) and + // the scroll edge effect fades the content under it into that same colour; + // with a mouse each variant keeps its own fill (none for an augmented one). + const barFillsWithBody = variant === 'wash' || variant === 'elevated'; + const headerAreaStyle = !isAuto + ? navAreaStyle + : barFillsWithBody + ? styles.navAreaWashTouchBar + : variant === 'surface' || variant === 'section' + ? styles.navAreaSurfaceTouchBar + : styles.navAreaTouchBar; const contentAreaStyle = variant === 'wash' ? styles.contentBgWash @@ -649,6 +735,26 @@ export function AppShell({ return observeResize(headerEl, () => updateHeight()); }, [isAuto]); + // ========================================================================= + // iOS 26's scroll edge effect under the pinned header (touch, page scroll) + // ========================================================================= + // Under a finger (the resolver's touch presentation above, the same on + // every touch device) the pinned header is iOS 26's bar; a mouse keeps + // today's. The veil takes the bar's own colour; its edge is the header's + // bottom. AppShell animates with the bars table: it hands the springs and + // their registration to the effect. + const scrollEdge = useScrollEdgeEffect({ + enabled: isAuto && isTouch, + edge: '100%', + color: barFillsWithBody + ? colorVars['--color-background-body'] + : colorVars['--color-background-surface'], + layerXstyle: styles.scrollEdgeBehind, + springs: bars.scrollEdgeEffect, + registerSprings: registerBarsTable, + }); + const scrollEdgeLayer = isAuto ?
: null; + // ========================================================================= // Determine if sideNav should show as overlay (mobile) or inline // ========================================================================= @@ -724,9 +830,22 @@ export function AppShell({ ) ) : null; + // The iOS 26 bar draws no hairline on a touch screen; the scroll edge + // effect separates the content instead (headerDividerTouchBar, a + // coarse-pointer rule on the divider a page-scrolling shell keeps). + const dividerTouchBarStyle = isAuto ? styles.headerDividerTouchBar : null; + // A sidenav-only layout below the breakpoint renders its own top bar + // (autoMobileTopBar below); the scroll edge effect then belongs to it. + const shouldShowAutoToggle = + !mobileNavDisabled && mobileNavHasToggle && isBelowBreakpoint; + const autoMobileTopBarShown = + shouldShowAutoToggle && !hasTopNav && hasSideNav; const headerInner = hasTopNav || hasBanner ? ( - + {hasBanner && (
{banner}
)} @@ -744,6 +863,7 @@ export function AppShell({ themeProps('app-shell-header', {variant}), stylex.props(headerAreaStyle, isAuto && styles.headerSticky), )}> + {autoMobileTopBarShown ? null : scrollEdgeLayer} {headerInner}
) : undefined; @@ -771,9 +891,16 @@ export function AppShell({ ) : undefined; + // Under `adaptive` the wrapper stays in both pointer states and only its + // style changes, so a pointer change never remounts the side region (a + // collapsed SideNav stays collapsed). With a fine pointer it generates no + // box, and the panel lays out as in `fill`. const sideNavContent = - sideNavPanel != null && isAuto ? ( -
{sideNavPanel}
+ sideNavPanel != null && (isAuto || height === 'adaptive') ? ( +
+ {sideNavPanel} +
) : ( sideNavPanel ); @@ -823,36 +950,34 @@ export function AppShell({ // Build auto mobile nav hamburger for TopNav // Injected into the headerContent when mobileNav is enabled and hasToggle // ========================================================================= - const shouldShowAutoToggle = - !mobileNavDisabled && mobileNavHasToggle && isBelowBreakpoint; - // For sidenav-only layouts with no TopNav, render the sideNav in topbar // mode — it shows heading + footer icons horizontally, with the hamburger - const autoMobileTopBar = - shouldShowAutoToggle && !hasTopNav && hasSideNav ? ( -
- -
- - {sideNav} - - -
-
-
- ) : undefined; + const autoMobileTopBar = autoMobileTopBarShown ? ( +
+ {scrollEdgeLayer} + +
+ {sideNav} + +
+
+
+ ) : undefined; return ( @@ -860,6 +985,7 @@ export function AppShell({ {...rest} ref={useMergedRefs(ref, shellRef)} data-testid={dataTestId} + data-scroll-owner={scrollsDocument ? 'document' : 'content'} {...mergeProps( themeProps('app-shell', {variant}), stylex.props( @@ -877,6 +1003,7 @@ export function AppShell({ className, style, )}> + {isAuto &&
} {/* Skip-to-content link */} diff --git a/packages/core/src/AppShell/AppShellScrollEdge.test.tsx b/packages/core/src/AppShell/AppShellScrollEdge.test.tsx new file mode 100644 index 0000000000000..d674466232986 --- /dev/null +++ b/packages/core/src/AppShell/AppShellScrollEdge.test.tsx @@ -0,0 +1,405 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file AppShellScrollEdge.test.tsx + * @input vitest (jsdom), React Testing Library, AppShell, a query-aware + * matchMedia stub, a recording IntersectionObserver and the StyleX rules + * vitest injects + * @output Checks the iOS 26 bar under a finger (any touch device) whose page + * scrolls: the pinned header paints no background and no hairline there, + * carries the scroll edge layer, and the page carries the sentinel the + * effect observes; a mouse, or a shell whose main region scrolls, keeps + * today's opaque bar and draws no effect; the veil takes the bar's colour + * and its edge is the header's bottom; leaving the sentinel fades the + * layer for a finger's scroll and switches it at once for the program's. + * The veil's shape against the constants table is + * motion/useScrollEdgeEffect.test.tsx's + * @position Testing; validates AppShell.tsx's touch bar. The switch on a real + * page, the curve and the busy-main-thread behaviour are proven by + * __tests__/AppShellScrollEdge.interaction.browser.spec.ts + */ + +import {act, render, screen} from '@testing-library/react'; +import {renderToString} from 'react-dom/server'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {AppShell, type AppShellVariant} from './AppShell'; + +const TOUCH = '(pointer: coarse)'; + +/** Answers `(pointer: coarse)` with `isTouch` and every other query false. */ +function stubPointer(isTouch: boolean) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: query === TOUCH ? isTouch : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +class RecordingObserver { + static instances: RecordingObserver[] = []; + readonly observed: Element[] = []; + constructor(readonly callback: IntersectionObserverCallback) { + RecordingObserver.instances.push(this); + } + observe(target: Element) { + this.observed.push(target); + } + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + report(isIntersecting: boolean) { + act(() => { + this.callback( + [{isIntersecting} as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + }); + } +} + +const animations: {keyframes: Keyframe[]; element: Element}[] = []; + +beforeEach(() => { + RecordingObserver.instances = []; + animations.length = 0; + vi.stubGlobal('IntersectionObserver', RecordingObserver); + HTMLElement.prototype.animate = function (this: HTMLElement, keyframes) { + animations.push({keyframes: keyframes as Keyframe[], element: this}); + return {startTime: null, cancel: vi.fn()} as unknown as Animation; + }; +}); + +/** A finger lands on `target`. */ +function fingerDown(target: Element) { + const event = new Event('touchstart', {bubbles: true, cancelable: true}); + const touch = {identifier: 1, clientX: 10, clientY: 300, target}; + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + Object.defineProperty(event, 'touches', {value: [touch]}); + target.dispatchEvent(event); +} + +/** The finger that landed on `target` lifts. */ +function fingerUp(target: Element) { + const event = new Event('touchend', {bubbles: true, cancelable: true}); + const touch = {identifier: 1, clientX: 10, clientY: 300, target}; + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + Object.defineProperty(event, 'touches', {value: []}); + target.dispatchEvent(event); +} + +/** A finger lands on `target` and the page scrolls under it. */ +function fingerScroll(target: Element) { + fingerDown(target); + document.dispatchEvent(new Event('scroll')); +} + +/** Whether useScrollEdgeEffect asks for the layer to be composited. */ +function isPromoted(layer: Element | null): boolean { + return ( + (layer as HTMLElement | null)?.style.getPropertyValue( + '--_scroll-edge-will-change', + ) === 'opacity' + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); +}); + +type Where = 'default' | 'touch'; + +interface RuleDeclarations { + where: Where | null; + declarations: Map; +} + +/** + * The declarations of every injected rule owned by one of `el`'s classes, and + * where it applies: outside any at-rule, or under the coarse pointer alone. + */ +function rulesFor(el: Element): RuleDeclarations[] { + const classes = new Set(el.className.split(/\s+/)); + const found: RuleDeclarations[] = []; + const whereOf = (conditions: string[]): Where | null => { + if (conditions.length === 0) { + return 'default'; + } + return conditions.length === 1 && conditions[0] === TOUCH ? 'touch' : null; + }; + const visit = (rules: CSSRuleList, conditions: string[]) => { + for (const rule of Array.from(rules)) { + const group = rule as unknown as { + cssRules?: CSSRuleList; + conditionText?: string; + }; + if (group.cssRules != null && group.conditionText != null) { + visit(group.cssRules, [...conditions, group.conditionText]); + continue; + } + if (!(rule instanceof CSSStyleRule)) { + continue; + } + const owner = /^\.([\w-]+)/.exec(rule.selectorText)?.[1]; + if (owner == null || !classes.has(owner)) { + continue; + } + const declarations = new Map(); + for (let i = 0; i < rule.style.length; i++) { + const property = rule.style.item(i); + declarations.set(property, rule.style.getPropertyValue(property)); + } + found.push({where: whereOf(conditions), declarations}); + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, []); + } + return found; +} + +function declared( + el: Element, + property: string, + where: Where = 'default', +): string | undefined { + let value: string | undefined; + for (const rule of rulesFor(el)) { + if (rule.where === where && rule.declarations.has(property)) { + value = rule.declarations.get(property); + } + } + return value; +} + +function renderShell( + props: Partial> = {}, +) { + return render( + Nav
} data-testid="shell" {...props}> +
Content
+ , + ); +} + +const layerOf = (banner: HTMLElement) => + banner.querySelector(':scope > [aria-hidden="true"]'); + +describe('AppShell scroll edge effect', () => { + it('a finger whose page scrolls gets the effect layer and the sentinel', () => { + stubPointer(true); + renderShell({height: 'adaptive'}); + const banner = screen.getByRole('banner'); + const layer = layerOf(banner); + expect(layer).not.toBeNull(); + expect(declared(layer!, 'position')).toBe('absolute'); + expect(declared(layer!, 'pointer-events')).toBe('none'); + const sentinel = RecordingObserver.instances.at(-1)?.observed[0]; + expect(sentinel).toBeDefined(); + expect(sentinel?.parentElement).toBe(screen.getByTestId('shell')); + expect(sentinel?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('a mouse keeps the main region scrolling and draws no effect', () => { + stubPointer(false); + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + renderShell({height: 'adaptive'}); + expect(layerOf(screen.getByRole('banner'))).toBeNull(); + expect(RecordingObserver.instances).toHaveLength(0); + }); + + it('a shell whose main region scrolls draws no effect, even under a finger', () => { + stubPointer(true); + renderShell({height: 'fill'}); + expect(layerOf(screen.getByRole('banner'))).toBeNull(); + expect(RecordingObserver.instances).toHaveLength(0); + }); + + it('the pinned bar paints no background under a finger, its colour otherwise', () => { + stubPointer(true); + renderShell({height: 'auto', variant: 'surface'}); + const banner = screen.getByRole('banner'); + expect(declared(banner, 'background-color')).toBe( + 'var(--color-background-surface)', + ); + expect(declared(banner, 'background-color', 'touch')).toBe('transparent'); + expect(declared(layerOf(banner)!, 'display')).toBe('none'); + expect(declared(layerOf(banner)!, 'display', 'touch')).toBe('block'); + }); + + it('the veil takes the bar colour of the variant', () => { + // The colour is the veil's runtime value (a custom property on the + // layer, see useScrollEdgeEffect.test.tsx). + stubPointer(true); + const {unmount} = renderShell({height: 'auto', variant: 'wash'}); + expect( + layerOf(screen.getByRole('banner'))!.getAttribute('style'), + ).toContain('var(--color-background-body)'); + unmount(); + renderShell({height: 'auto', variant: 'section'}); + expect( + layerOf(screen.getByRole('banner'))!.getAttribute('style'), + ).toContain('var(--color-background-surface)'); + }); + + it('the veil’s edge is the header’s bottom', () => { + // Denser than UIKit's, with its edge at the header's bottom: the title + // keeps 4.5:1 over the brightest scrolled content (WCAG 1.4.3). + stubPointer(true); + renderShell({height: 'auto', variant: 'wash'}); + expect( + layerOf(screen.getByRole('banner'))!.getAttribute('style'), + ).toContain('calc(100% + 54.8px)'); + }); + + it('the touch bar hides the section divider with a coarse-pointer rule; a mouse keeps it', () => { + // LayoutHeader reflects its divider as `data-divider` (jsdom drops the + // logical border properties themselves). The divider stays in the + // markup, so a server render is right for either pointer; the rule under + // `(pointer: coarse)` takes its width away on the touch bar. + const header = () => + screen.getByRole('banner').querySelector('.astryx-layout-header'); + stubPointer(true); + const touch = renderShell({height: 'auto', variant: 'section'}); + expect(header()?.hasAttribute('data-divider')).toBe(true); + expect(rulesFor(header()!).some(rule => rule.where === 'touch')).toBe( + true, + ); + touch.unmount(); + stubPointer(false); + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + renderShell({height: 'auto', variant: 'section'}); + expect(header()?.hasAttribute('data-divider')).toBe(true); + }); + + it('a server render draws the same header whatever the pointer guess', () => { + const header = (defaultIsMobile: boolean) => + /<[^>]*astryx-layout-header[^>]*>/.exec( + renderToString( + Nav} + height="auto" + variant="section" + mobileNav={{defaultIsMobile}}> +
Content
+
, + ), + )?.[0]; + expect(header(true)).toContain('data-divider'); + expect(header(true)).toBe(header(false)); + }); + + it('with a mouse each variant keeps its own header fill; under a finger the bar paints none', () => { + stubPointer(false); + const fills: Record = {}; + for (const variant of ['wash', 'elevated', 'surface', 'section', 'brand']) { + const {unmount} = renderShell({ + height: 'auto', + variant: variant as AppShellVariant, + }); + const banner = screen.getByRole('banner'); + fills[variant] = + `${declared(banner, 'background-color')} / ` + + `${declared(banner, 'background-color', 'touch')}`; + unmount(); + } + expect(fills).toEqual({ + wash: 'var(--color-background-body) / transparent', + elevated: 'var(--color-background-body) / transparent', + surface: 'var(--color-background-surface) / transparent', + section: 'var(--color-background-surface) / transparent', + // A variant added by module augmentation: no fill of its own with a + // mouse, as before. + brand: 'undefined / transparent', + }); + }); + + it('the header holding the veil owns its stacking (isolation: isolate)', () => { + stubPointer(true); + renderShell({height: 'auto'}); + expect(declared(screen.getByRole('banner'), 'isolation', 'default')).toBe( + 'isolate', + ); + }); + + it('leaving the sentinel during a finger’s scroll fades the layer in', () => { + stubPointer(true); + renderShell({height: 'auto'}); + const layer = layerOf(screen.getByRole('banner')); + const observer = RecordingObserver.instances.at(-1)!; + observer.report(true); + animations.length = 0; + // A finger down on the content scrolls the page. + fingerScroll(screen.getByText('Content')); + observer.report(false); + const fade = animations.find(({element}) => element === layer); + expect(fade).toBeDefined(); + expect(fade?.keyframes[0]?.opacity).toBe('0'); + expect(fade?.keyframes.at(-1)?.opacity).toBe('1'); + }); + + // The WebKit workaround: iOS 26.2 drops the sticky bar's items when the + // program jumps the page under a composited layer, so the layer is + // composited only while a finger works the page. + it('the layer is composited from a finger’s landing until its scroll ends', async () => { + stubPointer(true); + renderShell({height: 'auto'}); + const layer = layerOf(screen.getByRole('banner')); + const content = screen.getByText('Content'); + expect(isPromoted(layer)).toBe(false); + fingerDown(content); + expect(isPromoted(layer)).toBe(true); + document.dispatchEvent(new Event('scroll')); + // Lifted with the page still moving: the momentum is the finger's. + fingerUp(content); + expect(isPromoted(layer)).toBe(true); + document.dispatchEvent(new Event('scrollend')); + await Promise.resolve(); + expect(isPromoted(layer)).toBe(false); + }); + + it('a tap that lifts without scrolling drops the layer at once', () => { + stubPointer(true); + renderShell({height: 'auto'}); + const layer = layerOf(screen.getByRole('banner')); + const content = screen.getByText('Content'); + fingerDown(content); + expect(isPromoted(layer)).toBe(true); + fingerUp(content); + expect(isPromoted(layer)).toBe(false); + }); + + it('a mouse never composites the layer', () => { + stubPointer(false); + renderShell({height: 'auto'}); + const content = screen.getByText('Content'); + fingerDown(content); + expect(isPromoted(layerOf(screen.getByRole('banner')))).toBe(false); + }); + + it('leaving the sentinel during a scroll the program drives switches the layer at once', () => { + stubPointer(true); + renderShell({height: 'auto'}); + const layer = layerOf(screen.getByRole('banner')); + const observer = RecordingObserver.instances.at(-1)!; + observer.report(true); + animations.length = 0; + document.dispatchEvent(new Event('scroll')); + observer.report(false); + expect(animations.some(({element}) => element === layer)).toBe(false); + expect((layer as HTMLElement | null)?.style.opacity).toBe('1'); + // ...with no compositing layer: the switch is written without promotion. + expect((layer as HTMLElement | null)?.style.willChange).toBe(''); + expect(isPromoted(layer)).toBe(false); + }); +}); diff --git a/packages/core/src/AppShell/__tests__/AppShellScrollEdge.interaction.browser.spec.ts b/packages/core/src/AppShell/__tests__/AppShellScrollEdge.interaction.browser.spec.ts new file mode 100644 index 0000000000000..4713ad50f71bd --- /dev/null +++ b/packages/core/src/AppShell/__tests__/AppShellScrollEdge.interaction.browser.spec.ts @@ -0,0 +1,606 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file AppShellScrollEdge.interaction.browser.spec.ts + * @input The built "Touch bar over content" AppShell story (height + * "auto", touch phone profile), programmatic and real-touch scrolling, + * and the interaction harness + * @output Interaction and animation proofs for the iOS 26 scroll edge effect + * under AppShell's pinned header, under a finger: a scroll the program + * drives switches it at once (off while the content sits 10px or less + * under the bar, on beyond, both ways); a finger dragging the page fades it + * in and back out; for a finger's scroll the fade follows UIKit's measured + * appear and disappear springs when seeked, keeps playing through a + * blocked main thread, does no Layout or Paint, is a cross-fade of at most + * 200 ms under Reduce Motion, and turns around from where it is when the + * content comes back mid-fade; Reduce Transparency makes it an opaque + * band; the layer is hidden from assistive technology; a mouse keeps + * today's opaque bar; a storyboard of the fade in and out + * @position Browser proof for AppShell.tsx's touch bar and + * motion/useScrollEdgeEffect.ts, run by `pnpm test:interaction`; jsdom + * scrolls nothing and runs no animations. Touch is one model on every + * touch device, so the phone profile here (a coarse pointer) is the touch + * case in both engines. A finger's scroll is a synthetic finger resting + * on the content while the page scrolls (the shared gesture host reads it + * as the finger's), or a real-touch drag in Chromium. WebKit here is + * desktop WebKit under Playwright; iOS Safari is the simulator run. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + compareCurve, + countBetween, + drag, + frames, + holdAnimations, + openStory, + releaseAnimations, + sampleCurve, + springStep, + startScreencast, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + waitForRest, + withStorybook, + type CastFrame, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +const STORY = 'core-appshell--touch-bar-over-content'; +/** `bars.scrollEdgeEffect.appear` and `.disappear`, as response and damping. */ +const APPEAR = {response: 0.2, dampingFraction: 1}; +const DISAPPEAR = {response: 0.35, dampingFraction: 1}; +/** Far enough that the tiles fill the space under the header. */ +const UNDER = 120; + +interface Shell { + layer: Locator; + header: Locator; + /** + * The shell's top edge on the page, px. Storybook pads every story, so the + * header pins (and content starts passing under it) only once the page + * has scrolled this far. + */ + top: number; +} + +async function openShell(page: Page): Promise { + await openStory(page, storybook(), STORY); + const header = page.getByRole('banner'); + const layer = header.locator(':scope > [aria-hidden="true"]'); + await expect(layer).toBeAttached(); + expect( + await page.evaluate(() => + document + .querySelector('[data-scroll-owner]') + ?.getAttribute('data-scroll-owner'), + ), + ).toBe('document'); + const top = await page.evaluate(() => { + const shell = document.querySelector('[data-scroll-owner]'); + return (shell?.getBoundingClientRect().top ?? 0) + window.scrollY; + }); + return {layer, header, top}; +} + +async function scrollToY(page: Page, y: number): Promise { + return page.evaluate(top => { + window.scrollTo(0, top); + return window.scrollY; + }, y); +} + +/** + * Scrolls the page while a finger rests on the content, then lifts it two + * frames later (after the observer has reported): the shared gesture host + * reads the scroll as the finger's, so the effect fades. + */ +async function fingerScrollToY(page: Page, y: number): Promise { + return page.evaluate(top => { + const target = document.querySelector('[role="main"]') ?? document.body; + const touch = {identifier: 7, clientX: 100, clientY: 400, target}; + const fire = (type: string, touches: (typeof touch)[]) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + Object.defineProperty(event, 'touches', {value: touches}); + target.dispatchEvent(event); + }; + fire('touchstart', [touch]); + window.scrollTo(0, top); + requestAnimationFrame(() => + requestAnimationFrame(() => fire('touchend', [])), + ); + return window.scrollY; + }, y); +} + +async function presentedOpacity(layer: Locator): Promise { + return layer.evaluate(element => Number(getComputedStyle(element).opacity)); +} + +/** Scrolls, lets the observer report, and waits for the fade to finish. */ +async function settleAt(page: Page, layer: Locator, y: number) { + const scrollY = await scrollToY(page, y); + await frames(page, 3); + await waitForRest(layer); + return {scrollY, opacity: await presentedOpacity(layer)}; +} + +test('a scroll the program drives switches the effect at once: off through 10px, on beyond, both ways', async ({ + page, +}) => { + const {layer, top} = await openShell(page); + // Content under the pinned bar, px → the effect's opacity at rest. + const steps: [number, number][] = [ + [-top, 0], + [5, 0], + [10, 0], + [11, 1], + [40, 1], + [10, 0], + [-top, 0], + ]; + const seen: {under: number; scrollY: number; opacity: number}[] = []; + for (const [under, expected] of steps) { + const reading = await settleAt(page, layer, top + under); + seen.push({under, ...reading}); + expect(reading.opacity, `${under}px under the bar`).toBe(expected); + // At once: the WebKit workaround (no fade starts on the program's + // scroll; on iOS 26.2 one would blank the bar's items). + expect( + await layer.evaluate(element => element.getAnimations().length), + ).toBe(0); + } + // A script scrolls the page in whole px here, so 10 → 11 brackets UIKit's + // switch (off at 10.0, on from 10.25); the sentinel's edge rule puts it at + // exactly 10 (AppShellScrollEdge.test.tsx and useScrollEdgeEffect.ts). + test.info().annotations.push({ + type: 'switch', + description: JSON.stringify(seen), + }); +}); + +test('a finger dragging the page under the bar fades the effect in, and back out', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'native scrolling from injected touches is Chromium-only (harness README)', + ); + const {layer} = await openShell(page); + const driver = await touchDriver(page); + const readings: {phase: string; scrollY: number; opacity: number}[] = []; + const read = async (phase: string) => { + readings.push({ + phase, + scrollY: await page.evaluate(() => window.scrollY), + opacity: await presentedOpacity(layer), + }); + }; + // Up 60px, resting before the lift (no fling). + await driver.play( + drag({x: 200, y: 520}, {x: 200, y: 460}, {durationMs: 300, restMs: 450}), + { + onEvent: async step => { + if (step.type === 'touchEnd') { + await read('finger resting, about to lift'); + } + }, + }, + ); + await waitForRest(layer); + await read('after the lift'); + // Back down past the rest offset. + await driver.play( + drag({x: 200, y: 300}, {x: 200, y: 420}, {durationMs: 300, restMs: 600}), + ); + await waitForRest(layer); + await read('back at rest'); + test.info().annotations.push({ + type: 'drag', + description: JSON.stringify(readings), + }); + expect(readings[0].scrollY).toBeGreaterThan(10); + // The finger is still down: the fade ran during the drag (0.294 s spring, + // 450 ms of rest), so it is all but complete. + expect(readings[0].opacity).toBeGreaterThan(0.95); + expect(readings[1].opacity).toBe(1); + expect(readings[2].scrollY).toBeLessThanOrEqual(10); + expect(readings[2].opacity).toBe(0); +}); + +test('a finger’s scroll fades on UIKit’s measured appear and disappear springs', async ({ + page, +}) => { + const {layer, top} = await openShell(page); + const compareFade = async ( + to: number, + oracle: (t: number) => number, + lengthMs: number, + ) => { + await fingerScrollToY(page, to); + await waitForAnimation(layer); + await holdAnimations(page, layer); + const samples = await sampleCurve(layer, timeline(lengthMs, 1000 / 120)); + // Resume rather than jump to the end: the animator's own clock keeps + // running while the harness holds the animation, so the next fade must + // start after this one has really ended. + await releaseAnimations(page); + await waitForRest(layer); + await page.waitForTimeout(lengthMs); + return compareCurve( + samples.map(s => ({t: s.t, value: s.opacity})), + oracle, + // Keyframes sampled within 0.05 px-equivalent; opacity rounds to 1/255 + // in the computed style of some engines. + 0.006, + ); + }; + const appear = await compareFade( + top + UNDER, + t => springStep(APPEAR, t), + 300, + ); + const disappear = await compareFade( + 0, + t => 1 - springStep(DISAPPEAR, t), + 520, + ); + test.info().annotations.push({ + type: 'curves', + description: JSON.stringify({ + appear: {max: appear.maxError, rms: appear.rmsError}, + disappear: {max: disappear.maxError, rms: disappear.rmsError}, + }), + }); + expect(appear.pass, `appear worst ${JSON.stringify(appear.worst)}`).toBe( + true, + ); + expect( + disappear.pass, + `disappear worst ${JSON.stringify(disappear.worst)}`, + ).toBe(true); +}); + +/** + * One pixel per frame: the colour at `point` (CSS px) of each screencast + * frame, decoded in a scratch page so Node needs no image decoder. + */ +async function pixelPerFrame( + page: Page, + cast: ReadonlyArray, + point: {x: number; y: number}, +): Promise<{t: number; rgb: [number, number, number]}[]> { + const scratch = await page.context().newPage(); + try { + const out: {t: number; rgb: [number, number, number]}[] = []; + for (let i = 0; i < cast.length; i += 12) { + const batch = cast.slice(i, i + 12); + const values = await scratch.evaluate( + async ({items, at}) => { + const result: [number, number, number][] = []; + for (const item of items) { + const blob = await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const scale = bitmap.width / item.viewportWidth; + const [r, g, b] = context.getImageData( + Math.round(at.x * scale), + Math.round(at.y * scale), + 1, + 1, + ).data; + result.push([r, g, b]); + } + return result; + }, + {items: batch, at: point}, + ); + values.forEach((rgb, j) => out.push({t: batch[j].t, rgb})); + } + return out; + } finally { + await scratch.close(); + } +} + +test('the fade keeps playing through a blocked main thread', async ({page}) => { + const {layer, top} = await openShell(page); + // A point over the first tile, high in the header where the veil is + // strong: the fade shows there as the tile washing towards the bar colour. + const point = {x: 100, y: 12}; + const cast = await startScreencast(page); + await fingerScrollToY(page, top + UNDER); + const [fade] = await waitForAnimation(layer); + const busy = await blockMainThread(page, 220); + await page.waitForTimeout(400); + const pixels = await pixelPerFrame(page, await cast.stop(), point); + const during = pixels.filter(p => p.t > busy.from && p.t < busy.to); + const distinct = new Set(during.map(p => p.rgb.join(','))).size; + test.info().annotations.push({ + type: 'busy window', + description: JSON.stringify({ + fade, + busy, + framesDuringBlock: during.length, + distinctColoursDuringBlock: distinct, + during: during.map(p => ({t: Math.round(p.t - busy.from), rgb: p.rgb})), + }), + }); + // A main-thread fade would present nothing new while the thread is held. + expect(during.length).toBeGreaterThanOrEqual(4); + expect(distinct).toBeGreaterThanOrEqual(4); + // Monotonic: the tile only ever washes towards the veil (its red channel, + // low on the blue tile, rises). + const reds = during.map(p => p.rgb[0]); + expect(reds.every((r, i) => i === 0 || r >= reds[i - 1] - 1)).toBe(true); +}); + +test('no Layout or Paint runs while the effect fades', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'tracing is a Chromium protocol'); + const {layer, top} = await openShell(page); + let started = 0; + const trace = await traceRendering(page, async () => { + await fingerScrollToY(page, top + UNDER); + const [fade] = await waitForAnimation(layer); + started = fade.from; + await page.waitForTimeout(400); + }); + // The steady middle of the 0.294 s fade: its first frame may commit. + const counts = countBetween(trace, started + 50, started + 250); + test.info().annotations.push({ + type: 'rendering', + description: JSON.stringify(counts), + }); + expect(counts.Layout ?? 0).toBe(0); + expect(counts.Paint ?? 0).toBe(0); +}); + +// design:reduced-motion DR2: under Reduce Motion a coarse pointer's +// cross-fade is opacity only and at most 200 ms. +test('Reduce Motion fades the effect in at most 200 ms', async ({page}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {layer, top} = await openShell(page); + await fingerScrollToY(page, top + UNDER); + await waitForAnimation(layer); + await holdAnimations(page, layer); + const samples = await sampleCurve(layer, [50, 100, 200]); + await releaseAnimations(page, {finish: true}); + const at = (t: number) => + samples.find(sample => sample.t === t)?.opacity ?? Number.NaN; + // A fade, not a jump: partway at 50 ms… + expect(at(50)).toBeGreaterThan(0.05); + expect(at(50)).toBeLessThan(0.95); + // …and done by 200 ms (UIKit's own 0.294 s spring is not). + expect(at(200)).toBeGreaterThan(0.99); + expect(springStep(APPEAR, 200)).toBeLessThan(0.99); +}); + +test('Reduce Transparency makes the effect an opaque band ending at the bar', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'prefers-reduced-transparency is emulated through the Chromium protocol', + ); + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setEmulatedMedia', { + features: [{name: 'prefers-reduced-transparency', value: 'reduce'}], + }); + const {layer, header, top} = await openShell(page); + await settleAt(page, layer, top + UNDER); + const geometry = await layer.evaluate(element => { + const style = getComputedStyle(element); + return { + mask: style.maskImage, + opacity: style.opacity, + height: element.getBoundingClientRect().height, + }; + }); + const headerHeight = await header.evaluate( + element => element.getBoundingClientRect().height, + ); + expect(geometry.mask).toBe('none'); + expect(geometry.opacity).toBe('1'); + expect(geometry.height).toBeCloseTo(headerHeight, 1); +}); + +test('the effect is hidden from assistive technology', async ({page}) => { + const {layer, top} = await openShell(page); + await settleAt(page, layer, top + UNDER); + const hidden = await page.evaluate(() => { + const shell = document.querySelector('[data-scroll-owner]'); + const decorative = [ + ...document.querySelectorAll( + '[role="banner"] > [aria-hidden="true"], [data-scroll-owner] > [aria-hidden="true"]', + ), + ]; + return { + count: decorative.length, + allHidden: decorative.every( + element => element.getAttribute('aria-hidden') === 'true', + ), + focusable: decorative.some( + element => + element.matches('a, button, input, [tabindex]') || + element.querySelector('a, button, input, [tabindex]') != null, + ), + sentinelInShell: shell?.firstElementChild?.getAttribute('aria-hidden'), + }; + }); + expect(hidden.count).toBe(2); + expect(hidden.allHidden).toBe(true); + expect(hidden.focusable).toBe(false); + expect(hidden.sentinelInShell).toBe('true'); + await expect(page.getByRole('banner')).toBeVisible(); +}); + +test('a finger bringing the content back mid-fade turns the fade around from where it is', async ({ + page, +}) => { + const {layer, top} = await openShell(page); + // Content under the bar starts the appear spring; three frames in, the + // content comes back to rest, and the disappear spring takes over from the + // opacity the effect has, without a jump either way. + const trace = await page.evaluate( + async ({start, back}) => { + const element = document.querySelector( + '[role="banner"] > [aria-hidden="true"]', + ) as HTMLElement; + const opacity = () => Number(getComputedStyle(element).opacity); + const frame = async () => + new Promise(resolve => requestAnimationFrame(resolve)); + // A finger rests on the content through both scrolls: the finger's. + const target = document.querySelector('[role="main"]') ?? document.body; + const touch = {identifier: 7, clientX: 100, clientY: 400, target}; + const fire = (type: string, touches: (typeof touch)[]) => { + const event = new Event(type, {bubbles: true, cancelable: true}); + Object.defineProperty(event, 'changedTouches', {value: [touch]}); + Object.defineProperty(event, 'touches', {value: touches}); + target.dispatchEvent(event); + }; + fire('touchstart', [touch]); + window.scrollTo(0, start); + // Wait for the fade to start, then let it run three frames. + for (let i = 0; i < 60 && element.getAnimations().length === 0; i++) { + await frame(); + } + for (let i = 0; i < 3; i++) { + await frame(); + } + const before = opacity(); + window.scrollTo(0, back); + const after: number[] = []; + for (let i = 0; i < 45; i++) { + await frame(); + after.push(opacity()); + } + fire('touchend', []); + return {before, after}; + }, + {start: top + UNDER, back: top}, + ); + test.info().annotations.push({ + type: 'interruption', + description: JSON.stringify(trace), + }); + // Turned around mid-flight: neither off nor fully on. + expect(trace.before).toBeGreaterThan(0.05); + expect(trace.before).toBeLessThan(0.95); + // No jump: the next frames continue from there (still rising for a frame + // or two until the observer reports, never past 1)… + expect(Math.max(...trace.after)).toBeLessThan(0.99); + expect(Math.abs(trace.after[0] - trace.before)).toBeLessThan(0.3); + // …then it falls monotonically to 0 on the disappear spring. + const peak = trace.after.indexOf(Math.max(...trace.after)); + const fall = trace.after.slice(peak); + expect(fall.every((o, i) => i === 0 || o <= fall[i - 1] + 0.002)).toBe(true); + expect(trace.after.at(-1)).toBeLessThan(0.01); + await waitForRest(layer); +}); + +test('a finger’s scroll fades the effect; the program’s then switches it at once', async ({ + page, +}) => { + const {layer, top} = await openShell(page); + await fingerScrollToY(page, top + UNDER); + await waitForAnimation(layer); + await waitForRest(layer); + expect(await presentedOpacity(layer)).toBe(1); + // A status-bar tap or a scrollTo: no finger on the content. + await scrollToY(page, top); + await frames(page, 3); + expect(await layer.evaluate(element => element.getAnimations().length)).toBe( + 0, + ); + expect(await presentedOpacity(layer)).toBe(0); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('the bar stays opaque and no effect runs', async ({page}) => { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const header = page.getByRole('banner'); + const bar = await header.evaluate(element => { + const layer = element.querySelector(':scope > [aria-hidden="true"]'); + return { + background: getComputedStyle(element).backgroundColor, + layerDisplay: layer == null ? 'none' : getComputedStyle(layer).display, + }; + }); + expect(bar.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(bar.layerDisplay).toBe('none'); + }); +}); + +test( + 'storyboard: the effect fading in and out', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {layer, top} = await openShell(page); + const viewport = page.viewportSize() ?? {width: 402, height: 714}; + const clip = {x: 0, y: 0, width: viewport.width, height: 140}; + const measure = async () => ({opacity: await presentedOpacity(layer)}); + await fingerScrollToY(page, top + UNDER); + await waitForAnimation(layer); + const {storyboard: appearBoard} = await animationStoryboard(page, { + name: 'appshell-scroll-edge-appear', + clip, + scope: layer, + measure, + columns: 6, + cellScale: 0.5, + }); + // Resume rather than jump to the end (see the curve test). + await releaseAnimations(page); + await waitForRest(layer); + await page.waitForTimeout(300); + // Back to 9px under the bar: still under it, so the fade-out shows + // over the tiles at the header's bottom edge. + await fingerScrollToY(page, top + 9); + await waitForAnimation(layer); + const {storyboard: disappearBoard} = await animationStoryboard(page, { + name: 'appshell-scroll-edge-disappear', + clip, + scope: layer, + measure, + columns: 6, + cellScale: 0.5, + }); + await releaseAnimations(page, {finish: true}); + await appearBoard.write(testInfo); + await disappearBoard.write(testInfo); + // The trace is the assertion; the sheets are the evidence. + const ins = appearBoard.frames.map(f => f.data.opacity as number); + const outs = disappearBoard.frames.map(f => f.data.opacity as number); + expect(ins[0]).toBeLessThan(0.05); + expect(ins.every((o, i) => i === 0 || o >= ins[i - 1] - 0.002)).toBe(true); + expect(ins.at(-1)).toBeGreaterThan(0.99); + expect(outs[0]).toBeGreaterThan(0.95); + expect(outs.every((o, i) => i === 0 || o <= outs[i - 1] + 0.002)).toBe( + true, + ); + expect(outs.at(-1)).toBeLessThan(0.01); + }, +); diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 34b264d00124d..60cc655f00f9e 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -4,7 +4,8 @@ /** * @file Item.tsx - * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer + * @input Uses React, ReactNode, StyleXStyles, theme tokens, useClickableContainer, + * the shared press overlays and the compositor press layer * @output Exports Item component, ItemProps type; publishes the shared inline inset * @position Core layout primitive; consumed by index.ts, tested by Item.test.tsx * @@ -53,7 +54,10 @@ import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {cellPressableProps} from '../utils/pressFeedback'; +import {rowPressLayerStyles} from '../utils/rowPressLayer'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useInteractionModalityTracking} from '../utils/interactionModality'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; // The gesture and the panels, loaded only by a row that has swipe actions: a @@ -586,6 +590,10 @@ export function Item({ ...restProps }: ItemProps) { const pressable = usePressFeedback(); + // A selection hold (List/selectionHold.ts) returns focus to this row after + // a pop; its ring follows how the user last interacted, so a finger's pop + // shows none. + useInteractionModalityTracking(); const LinkComponent = useLinkComponent(); // Delegation mode: the row is an enlarged click/tap target for a nested @@ -876,7 +884,7 @@ export function Item({ (isSelected && !allowsAriaSelected ? true : undefined) } aria-disabled={isDisabled || undefined} - {...(isInteractive ? pressable : undefined)} + {...(isInteractive ? {...pressable, ...cellPressableProps} : undefined)} {...mergeProps( themeProps('item', {density, align}), focusOutlineProps.focusWithin( @@ -886,6 +894,10 @@ export function Item({ align === 'first-line' && styles.alignFirstLine, isInteractive && styles.interactive, isInteractive && interactionOverlayStyles.backgroundColor, + // The touch press on a compositor layer (after the overlay, whose + // own touch paint it turns off): the release keeps fading while + // script runs. + isInteractive && rowPressLayerStyles.cell, isLinkRoot && styles.linkRoot, isHighlighted && styles.highlighted, isSelected && styles.selected, diff --git a/packages/core/src/List/List.doc.mjs b/packages/core/src/List/List.doc.mjs index 81b2acaba52ec..7f0d838f97a5c 100644 --- a/packages/core/src/List/List.doc.mjs +++ b/packages/core/src/List/List.doc.mjs @@ -38,7 +38,7 @@ export const docs = { { name: 'hasDividers', type: 'boolean', - description: 'Show dividers between items.', + description: 'Show dividers between items. Under a finger, a touch press hides the dividers on either side of the pressed row until the finger lifts, as iOS lists do.', default: 'false', }, { diff --git a/packages/core/src/List/List.exports.test.ts b/packages/core/src/List/List.exports.test.ts new file mode 100644 index 0000000000000..df27d6627f624 --- /dev/null +++ b/packages/core/src/List/List.exports.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file List.exports.test.ts + * @input vitest, core's package.json and the List entry point (index.ts) + * @output Asserts the selection hold stays internal: `@astryxdesign/core/List` + * exports no `holdSelection`, and core's `exports` do not list + * selectionHold.ts (`architecture:public-component-api` INV12) + * @position Unit test beside List/index.ts + */ + +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vitest'; +import * as list from './index'; + +describe('the selection hold stays internal', () => { + it('is not exported from @astryxdesign/core/List', () => { + expect(list).not.toHaveProperty('holdSelection'); + }); + + it('lives in a module that core’s exports do not list', () => { + const pkg = JSON.parse( + readFileSync(join(process.cwd(), 'packages/core/package.json'), 'utf8'), + ) as {exports: Record}; + const sources = Object.values(pkg.exports).map(entry => + typeof entry === 'string' ? entry : entry.source, + ); + expect(sources).not.toContain('./src/List/selectionHold.ts'); + }); +}); diff --git a/packages/core/src/List/List.test.tsx b/packages/core/src/List/List.test.tsx index 8ed1f09f40b38..2091dbf4bc7ee 100644 --- a/packages/core/src/List/List.test.tsx +++ b/packages/core/src/List/List.test.tsx @@ -13,6 +13,41 @@ import {describe, it, expect, vi} from 'vitest'; import {cleanup, render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {rulesDeclaredFor} from '../__tests__/pressState'; + +/** + * The element's injected rules split by the touch gate: inside + * `@media (pointer: coarse)` (TOUCH_QUERY, utils/touchPresentation.ts), or + * not. + */ +function touchGatedRules(el: Element): {touch: string[]; other: string[]} { + const classes = el.className + .split(/\s+/) + .filter(Boolean) + .map(name => `.${name}`); + const out = {touch: [] as string[], other: [] as string[]}; + const visit = (rules: CSSRuleList, touch: boolean) => { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + if (classes.some(cls => rule.selectorText.startsWith(cls))) { + (touch ? out.touch : out.other).push( + `${rule.selectorText} {${rule.style.cssText}}`, + ); + } + continue; + } + const nested = (rule as CSSGroupingRule).cssRules; + if (nested == null) { + continue; + } + const condition = (rule as CSSConditionRule).conditionText ?? ''; + visit(nested, touch || condition.includes('pointer: coarse')); + } + }; + for (const sheet of Array.from(document.styleSheets)) { + visit(sheet.cssRules, false); + } + return out; +} import {List} from './List'; import {ListItem} from './ListItem'; import {ListMarkerScope} from './ListContext'; @@ -488,6 +523,73 @@ describe('List', () => { expect(hrs).toHaveLength(0); }); + it('hides a touch-pressed row’s divider and the one above it, with the press layer over its own, under a finger only', () => { + render( + + {}} /> + {}} /> + , + ); + const row = screen.getByText('Item 1').closest('li') as HTMLElement; + const {touch: rules, other} = touchGatedRules(row); + // Outside the touch gate (a mouse) no press arm touches the dividers. + expect( + other.some( + rule => + rule.slice(0, rule.indexOf('{')).includes('data-astryx-press') && + /border-(bottom|block-end)-color|(^|[;{\s])(bottom|inset-block-end):/.test( + rule, + ), + ), + ).toBe(false); + const selector = (rule: string) => rule.slice(0, rule.indexOf('{')); + const hides = (condition: string) => + rules.some( + rule => + selector(rule).includes(condition) && + !selector(rule).includes('::before') && + /border-(bottom|block-end)-color:\s*transparent/.test(rule), + ); + // Its own divider while it is pressed, and the divider above the row + // being pressed: the row before a pressed sibling. + expect(hides('[data-astryx-press="on"]')).toBe(true); + expect(hides(':has(+ [data-astryx-press="on"])')).toBe(true); + // Only while the press is on: the release shows both again at once. + expect(hides('[data-astryx-press="fading"]')).toBe(false); + // The pressed row's layer reaches over the hidden divider, except on the + // last row, which has none. + const layer = rules.filter( + rule => + selector(rule).includes('::before') && + selector(rule).includes('[data-astryx-press="on"]') && + /(bottom|inset-block-end):/.test(rule), + ); + expect(layer.some(rule => /calc\(\s*-1\s*\*\s*var\(/.test(rule))).toBe( + true, + ); + expect( + layer.some( + rule => + selector(rule).includes(':last-child') && + /(bottom|inset-block-end):\s*0(px)?[;}\s]/.test(rule), + ), + ).toBe(true); + }); + + it('leaves a list without dividers alone during a press', () => { + render( + + {}} /> + , + ); + const row = screen.getByText('Item 1').closest('li') as HTMLElement; + expect( + rulesDeclaredFor(row).some(rule => + rule.slice(0, rule.indexOf('{')).includes(':has(+ [data-astryx-press'), + ), + ).toBe(false); + }); + // =========================================================================== // Interactive items — onClick (invisible button pattern) // =========================================================================== diff --git a/packages/core/src/List/List.tsx b/packages/core/src/List/List.tsx index b2379d9104181..388baea3d89d1 100644 --- a/packages/core/src/List/List.tsx +++ b/packages/core/src/List/List.tsx @@ -58,7 +58,9 @@ export interface ListProps extends BaseProps< density?: ListDensity; /** - * Whether to show dividers between list items. + * Whether to show dividers between list items. Under a finger, a touch + * press hides the dividers on either side of the pressed row until the + * finger lifts, as iOS lists do. * @default false */ hasDividers?: boolean; diff --git a/packages/core/src/List/ListItem.tsx b/packages/core/src/List/ListItem.tsx index 249d20f960efe..2be5b7250bb52 100644 --- a/packages/core/src/List/ListItem.tsx +++ b/packages/core/src/List/ListItem.tsx @@ -5,7 +5,8 @@ /** * @file ListItem.tsx * @input Uses React, ReactNode, StyleXStyles, theme tokens, the List row edge - * treatment, CheckboxInput, and the marker or task checkbox a + * treatment, the press layer (dividers beside a touch-pressed row hide and + * the layer reaches over the row's own), CheckboxInput, and the marker or task checkbox a * ListMarkerScope sets (Markdown's nested lists and the task items of mixed * lists) * @output Exports ListItem component, ListItemProps type @@ -186,7 +187,26 @@ const styles = stylex.create({ withDivider: { borderBlockEndWidth: borderVars['--border-width'], borderBlockEndStyle: 'solid', - borderBlockEndColor: colorVars['--color-border'], + // Under a finger (a coarse primary pointer, TOUCH_QUERY in + // utils/touchPresentation.ts), a touch press hides this row's divider + // and the one above it, in the frame the press is believed, with no + // transition, as a highlighted UIKit cell hides its separators; both + // come back in the first frame of the release, while the press is still + // fading. A selection hold (the row kept selected through a navigation + // push) hides them the same way until its deselect begins, and again + // after a cancel. With a mouse the dividers stay drawn. + borderBlockEndColor: { + default: colorVars['--color-border'], + '@media (pointer: coarse)': { + default: null, + '[data-astryx-press="on"]': 'transparent', + '[data-astryx-press="held"]:not([data-astryx-deselecting])': + 'transparent', + ':has(+ [data-astryx-press="on"])': 'transparent', + ':has(+ [data-astryx-press="held"]:not([data-astryx-deselecting]))': + 'transparent', + }, + }, // A longhand, not the `borderBlockEnd` shorthand: StyleX's default // property-specificity mode drops border shorthands silently, so the // shorthand never reached the shipped CSS and the last item kept its @@ -194,6 +214,25 @@ const styles = stylex.create({ ':last-child': { borderBlockEndWidth: 0, }, + // While its divider is hidden, the press layer (the `cell` style of + // utils/interactionOverlay.stylex.ts) reaches over it, so the whole row + // is pressed. The last row has no divider to cover. + '::before': { + insetBlockEnd: { + default: null, + '@media (pointer: coarse)': { + default: null, + '[data-astryx-press="on"]': { + default: `calc(-1 * ${borderVars['--border-width']})`, + ':last-child': 0, + }, + '[data-astryx-press="held"]:not([data-astryx-deselecting])': { + default: `calc(-1 * ${borderVars['--border-width']})`, + ':last-child': 0, + }, + }, + }, + }, }, }); diff --git a/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts new file mode 100644 index 0000000000000..84668ab2029db --- /dev/null +++ b/packages/core/src/List/__tests__/ListDividers.interaction.browser.spec.ts @@ -0,0 +1,247 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListDividers.interaction.browser.spec.ts + * @input The built List story with dividers, real touch input (Chromium CDP / + * WebKit's input command) and the interaction harness + * @output Interaction proofs for a divided list under a touch press: the + * pressed row's divider and the one above it are hidden in the frame the + * press is believed, with the press layer reaching over the pressed row's + * own; both come back in the first frame of the release; the last row's + * layer never reaches past the list; a storyboard of rest, press and the + * release's first frame + * @position Interaction evidence for ListItem's divider styles together with + * the press layer (the `cell` style of utils/interactionOverlay.stylex.ts). + * The behaviour is the one touch model, under any finger: the cases open + * the story as WebKit on iOS (./touchStory.ts), one as Android, and a mouse + * case shows the dividers staying drawn + * + * UIKit's reference: a highlighted cell hides its own separator and the one + * above it, unanimated, in the frame the highlight appears; on an animated + * deselect both return in the first frame while the gray is still fading. + * The states are read from the engine's computed styles, which carry the + * real cascade (layers, specificity), and photographed in the storyboard. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + holdAnimations, + releaseAnimations, + seekAnimations, + Storyboard, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--with-media'; +const HOLD_MS = 400; +/** Past the press onset (150 ms), well before the lift. */ +const PRESSED_AT_MS = 260; + +const rows = (page: Page) => page.locator('li[data-astryx-pressable]'); + +interface RowState { + readonly press: string | null; + /** The divider at the row's bottom edge, as painted. */ + readonly divider: string; + readonly dividerWidth: string; + /** Where the press layer's bottom edge sits, relative to the row's padding box. */ + readonly layerBottom: string; + readonly layerOpacity: string; +} + +async function rowStates(page: Page): Promise { + return rows(page).evaluateAll(elements => + elements.map(el => { + const style = getComputedStyle(el); + const layer = getComputedStyle(el, '::before'); + return { + press: el.getAttribute('data-astryx-press'), + divider: style.borderBottomColor, + dividerWidth: style.borderBottomWidth, + layerBottom: layer.bottom, + layerOpacity: layer.opacity, + }; + }), + ); +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +async function center(row: Locator) { + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +/** + * Holds row `index` with a finger and returns the rows' states while the + * press is on and in the release's first frame (its animations held at 0). + */ +async function pressRow(page: Page, index: number) { + const driver = await touchDriver(page); + const at = await center(rows(page).nth(index)); + let pressed: RowState[] = []; + let released: RowState[] = []; + await driver.play(tap(at, {holdMs: HOLD_MS}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(PRESSED_AT_MS); + pressed = await rowStates(page); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + released = await rowStates(page); + } + }, + }); + await releaseAnimations(page); + return {pressed, released}; +} + +test('Android: a touch press hides the same dividers (one touch model)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'Android is Chromium'); + await openStory(page, storybook(), STORY); + await expect(rows(page)).toHaveCount(3); + const rest = await rowStates(page); + const {pressed, released} = await pressRow(page, 1); + expect(pressed.map(row => row.press)).toEqual([null, 'on', null]); + expect(pressed[0].divider).toBe(TRANSPARENT); + expect(pressed[1].divider).toBe(TRANSPARENT); + expect(released[0].divider).toBe(rest[0].divider); + expect(released[1].divider).toBe(rest[1].divider); +}); + +test('a mouse press leaves the dividers drawn: touch work costs nothing under a fine pointer', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 900, height: 700}, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await expect(rows(page)).toHaveCount(3); + const rest = await rowStates(page); + const box = await rows(page).nth(1).boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + const pressed = await rowStates(page); + await page.mouse.up(); + expect(pressed.map(row => row.divider)).toEqual( + rest.map(row => row.divider), + ); + } finally { + await context.close(); + } +}); + +test.describe('a divided list under a touch press', () => { + test.beforeEach(async ({page}) => { + await openStory(page, storybook(), STORY); + await expect(rows(page)).toHaveCount(3); + }); + + test('hides the pressed row’s divider and the one above it; the release shows both at once', async ({ + page, + }) => { + const rest = await rowStates(page); + // At rest: a divider under every row but the last. + expect(rest.map(row => row.divider === TRANSPARENT)).toEqual([ + false, + false, + false, + ]); + expect(rest.map(row => row.dividerWidth)).toEqual(['1px', '1px', '0px']); + const resting = rest[0].divider; + + const {pressed, released} = await pressRow(page, 1); + expect(pressed.map(row => row.press)).toEqual([null, 'on', null]); + // The divider above the pressed row (the first row's) and its own. + expect(pressed[0].divider).toBe(TRANSPARENT); + expect(pressed[1].divider).toBe(TRANSPARENT); + // The layer covers the pressed row down through its hidden divider. + expect(pressed[1].layerOpacity).toBe('1'); + expect(pressed[1].layerBottom).toBe(`-${rest[1].dividerWidth}`); + // Rows that are not pressed have no layer at all. + expect(pressed[0].layerBottom).toBe('auto'); + + // The first frame of the release: both dividers are back while the + // press is still at full strength, and the layer is back inside the row. + expect(released[1].press).toBe('fading'); + expect(released[1].layerOpacity).toBe('1'); + expect(released[0].divider).toBe(resting); + expect(released[1].divider).toBe(resting); + expect(released[1].layerBottom).toBe('0px'); + }); + + test('the last row hides the divider above it and keeps its layer inside the list', async ({ + page, + }) => { + const rest = await rowStates(page); + const {pressed} = await pressRow(page, 2); + expect(pressed[2].press).toBe('on'); + // The second row's divider is the one above the pressed last row. + expect(pressed[1].divider).toBe(TRANSPARENT); + // The divider above that stays. + expect(pressed[0].divider).toBe(rest[0].divider); + // The last row has no divider, so its layer does not reach below it. + expect(pressed[2].layerBottom).toBe('0px'); + }); + + test('storyboard: rest, press, the release’s first frame @storyboard', async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== 'chromium', + 'One engine is enough for the record', + ); + // The list the rows live in. + const list = rows(page).first().locator('xpath=..'); + const storyboard = new Storyboard(page, { + name: 'list-dividers-press', + clip: list, + columns: 3, + // Full size: the dividers are hairlines. + cellScale: 1, + }); + await storyboard.capture('rest', 0, {rows: await rowStates(page)}); + const driver = await touchDriver(page); + const at = await center(rows(page).nth(1)); + await driver.play(tap(at, {holdMs: HOLD_MS}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(PRESSED_AT_MS); + await storyboard.capture('pressed', PRESSED_AT_MS, { + rows: await rowStates(page), + }); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + await storyboard.capture('release, first frame', HOLD_MS, { + rows: await rowStates(page), + }); + } + }, + }); + await releaseAnimations(page); + await storyboard.write(testInfo); + }); +}); diff --git a/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts new file mode 100644 index 0000000000000..9389101a9933c --- /dev/null +++ b/packages/core/src/List/__tests__/ListDividersPaths.interaction.browser.spec.ts @@ -0,0 +1,174 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListDividersPaths.interaction.browser.spec.ts + * @input The built List "With Media" story (dividers), the interaction + * harness, Storybook's colour-mode and direction globals + * @output The divider paths beside the main press proofs: a press the + * browser takes for a scroll brings both dividers back at once; a mouse + * press on a fine pointer leaves them drawn; dark mode and right-to-left + * hide and restore them the same way + * @position Interaction evidence for List/ListItem.tsx's divider arms, beside + * ListDividers.interaction.browser.spec.ts; under a finger + * (./touchStory.ts), where the arms apply + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + holdAnimations, + releaseAnimations, + seekAnimations, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--with-media'; +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +const rows = (page: Page) => page.locator('li[data-astryx-pressable]'); + +const dividers = async (page: Page) => + rows(page).evaluateAll(elements => + elements.map(el => getComputedStyle(el).borderBottomColor), + ); + +async function open(page: Page, globals?: string) { + await openStory(page, storybook(), STORY, {globals}); + await expect(rows(page)).toHaveCount(3); +} + +async function center(row: Locator) { + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +async function touchOnly(page: Page) { + const touch = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip(!touch, 'the divider arms serve a touch screen'); +} + +/** Holds the middle row; returns the dividers while pressed and at the release's start. */ +async function pressMiddle(page: Page) { + const driver = await touchDriver(page); + const at = await center(rows(page).nth(1)); + let pressed: string[] = []; + let released: string[] = []; + await driver.play(tap(at, {holdMs: 400}), { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(260); + pressed = await dividers(page); + } else { + await holdAnimations(page); + await seekAnimations(page, 0); + released = await dividers(page); + } + }, + }); + await releaseAnimations(page); + return {pressed, released}; +} + +test('a press the browser takes for a scroll brings both dividers back at once', async ({ + page, +}) => { + await open(page); + await touchOnly(page); + // A page tall enough to scroll, so a vertical pan is the browser's. + await page.evaluate(() => { + document.body.style.minHeight = '300vh'; + }); + const rest = await dividers(page); + const driver = await touchDriver(page); + const from = await center(rows(page).nth(1)); + let pressed: string[] = []; + await driver.play( + drag(from, {x: from.x, y: from.y - 80}, {holdMs: 300, durationMs: 160}), + { + onEvent: async (_, event) => { + if (event === 0) { + await page.waitForTimeout(260); + pressed = await dividers(page); + } + }, + }, + ); + expect(pressed[0]).toBe(TRANSPARENT); + expect(pressed[1]).toBe(TRANSPARENT); + await expect + .poll(async () => rows(page).nth(1).getAttribute('data-astryx-press')) + .toBeNull(); + expect(await dividers(page)).toEqual(rest); +}); + +test('a mouse press on a fine pointer leaves the dividers drawn', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + await open(page); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const rest = await dividers(page); + expect(rest[0]).not.toBe(TRANSPARENT); + const at = await center(rows(page).nth(1)); + await page.mouse.move(at.x, at.y); + await page.mouse.down(); + expect(await dividers(page)).toEqual(rest); + await page.mouse.up(); + expect(await dividers(page)).toEqual(rest); + } finally { + await context.close(); + } +}); + +for (const {name, globals} of [ + {name: 'dark mode', globals: 'colorMode:dark'}, + {name: 'right to left', globals: 'direction:rtl'}, +]) { + test(`${name}: a press hides the same two dividers and the release restores them`, async ({ + page, + }) => { + await open(page, globals); + await touchOnly(page); + if (name === 'right to left') { + expect( + await rows(page) + .first() + .evaluate(el => getComputedStyle(el).direction), + ).toBe('rtl'); + } + const rest = await dividers(page); + expect(rest[0]).not.toBe(TRANSPARENT); + const {pressed, released} = await pressMiddle(page); + expect(pressed[0]).toBe(TRANSPARENT); + expect(pressed[1]).toBe(TRANSPARENT); + expect(released[0]).toBe(rest[0]); + expect(released[1]).toBe(rest[1]); + }); +} + +test('dark mode keeps its own divider colour at rest', async ({page}) => { + await open(page); + const light = await dividers(page); + await open(page, 'colorMode:dark'); + const dark = await dividers(page); + expect(dark[0]).not.toBe(TRANSPARENT); + expect(dark[0]).not.toBe(light[0]); +}); diff --git a/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts new file mode 100644 index 0000000000000..60101b2a5c5ed --- /dev/null +++ b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts @@ -0,0 +1,475 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListRowPress.interaction.browser.spec.ts + * @input The built List, TreeList and press feedback stories, real touch input + * (Chromium CDP / WebKit's input command), the interaction harness, and the + * cell release clock and curve from the UIKit constants table + * (motion/uikit/list.ts) + * @output Interaction and animation proofs for a list row's touch press + * release: a held row paints the pressed token at full strength, and the + * release follows the cell release curve sampled frame by frame (both + * engines); the release keeps fading on every frame while the main thread + * is blocked (Chromium screencast, WebKit video); the release does no + * Layout or Paint; storyboards of the release and of the blocked window + * @position Interaction evidence for the row press layer (utils/interactionOverlay.stylex.ts `cell`) as composed + * by Item (List rows) and TreeList rows + * + * The press strength is read from pixels: the row's own background before + * the press is strength 0, the frame the press is believed is strength 1, and + * every other frame is placed between the two. The pressed overlay is a + * translucent token, so the sampled channel moves linearly with the layer's + * opacity. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + castStoryboard, + compareCurve, + countBetween, + cubicBezier, + freezeTimers, + holdAnimations, + installLongTaskLog, + installTimerGate, + openStory, + readLongTasks, + releaseAnimations, + seekAnimations, + startScreencast, + tap, + thawTimers, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + withStorybook, + writeEvidence, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {list} from '../../motion/uikit/list'; +import { + blockBehindBeaconOn, + installBeacon, + samplePixel, + sampleVideo, +} from './rowPixels'; + +const storybook = withStorybook(test); + +const RELEASE_MS = list.highlight.releaseMs; +const releaseEase = cubicBezier(...list.highlight.releaseCurve); +/** The layer's opacity t ms into the release: the strength, 1 → 0. */ +const expectedStrength = (t: number) => + 1 - releaseEase(Math.min(1, Math.max(0, t / RELEASE_MS))); + +/** Long enough that the press is believed (scroll.delaysContentTouchesMs). */ +const HOLD_MS = 400; + +interface RowStory { + readonly name: string; + readonly story: string; + /** The pressable row under test. */ + readonly row: (page: Page) => Locator; +} + +const LIST_ROW: RowStory = { + name: 'List row', + story: 'core-list--interactive', + // "Sent": not the selected row, so its resting background is the page's. + row: page => + page.locator('[data-astryx-pressable]').filter({hasText: 'Sent'}).first(), +}; + +const SCROLLER_ROW: RowStory = { + name: 'Item row in a scroller', + story: 'core-press-feedback-touch--rows', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(), +}; + +const TREE_ROW: RowStory = { + name: 'TreeList row', + story: 'core-treelist--basic', + row: page => + page + .locator('[data-astryx-pressable]') + .filter({hasText: 'App.tsx'}) + .first(), +}; + +interface OpenRow { + driver: TouchDriver; + row: Locator; + /** + * A point inside the row's trailing padding, clear of its text, icons and + * (in a tree) the guide lines, CSS px. + */ + probe: {x: number; y: number}; + center: {x: number; y: number}; +} + +async function openRow(page: Page, story: RowStory): Promise { + await installLongTaskLog(page); + await openStory(page, storybook(), story.story); + const driver = await touchDriver(page); + const row = story.row(page); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error(`${story.name}: no row`); + } + return { + driver, + row, + probe: { + x: Math.round(box.x + box.width - 3), + y: Math.round(box.y + box.height / 2), + }, + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +/** Opacity of the row's press layer (the `::before`), as the engine presents it. */ +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +/** The press attribute the controller writes on the row. */ +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +test('a held row paints the pressed token and its release follows the cell release curve, sampled frame by frame', async ({ + page, +}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + expect(await layerOpacity(row)).toBe(0); + // Gate the page's timers before the press, so the controller's own release + // timer (which removes the attribute after the clock) can be held while the + // release is seeked. + await installTimerGate(page); + await driver.play(tap(center, {holdMs: HOLD_MS})); + expect(await pressState(row)).toBe('fading'); + await freezeTimers(page); + const held = await holdAnimations(page, row); + const release = held.find( + a => a.target?.startsWith('li') && a.kind === 'CSSAnimation', + ); + expect(release, JSON.stringify(held)).toBeDefined(); + expect(release?.durationMs).toBe(RELEASE_MS); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(RELEASE_MS)) { + await seekAnimations(page, t); + samples.push({t, value: await layerOpacity(row)}); + } + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + const comparison = compareCurve(samples, expectedStrength, 0.005); + await writeEvidence( + 'curves/list-row-press-release', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + releaseMs: RELEASE_MS, + curve: list.highlight.releaseCurve, + held, + samples, + comparison, + }, + null, + 2, + ), + }, + testInfo, + ); + expect(samples[0].value).toBeCloseTo(1, 2); + expect( + comparison.maxError, + JSON.stringify(comparison.worst), + ).toBeLessThanOrEqual(0.005); + // At rest after the release: no press, no layer. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await layerOpacity(row)).toBe(0); +}); + +for (const story of [LIST_ROW, SCROLLER_ROW, TREE_ROW]) { + test(`${story.name}: the release keeps fading on every frame while the main thread is blocked`, async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright; WebKit has its own video proof below', + ); + const {driver, row, center, probe} = await openRow(page, story); + const cast = await startScreencast(page); + // Strength 0 is the row at rest; strength 1 the believed press. + await driver.play(tap(center, {holdMs: HOLD_MS})); + const [release] = await waitForAnimation(row, {minProgressMs: 16}); + const block = await blockMainThread(page, 700); + await page.waitForTimeout(150); + const frames = await cast.stop(); + const pixels = await samplePixel(page, frames, probe); + const rest = pixels[0].value; + const pressedFrames = pixels.filter(p => p.t < release.from); + const pressed = Math.min(...pressedFrames.map(p => p.value)); + const strength = (v: number) => (rest - v) / (rest - pressed); + // Judge the release while it was still visibly moving inside the block. + const end = Math.min(block.to, release.to) - 10; + const during = pixels.filter(p => p.t > block.from && p.t <= end); + const strengths = during.map(p => ({ + t: +(p.t - block.from).toFixed(1), + strength: +strength(p.value).toFixed(3), + })); + const intervals = during + .map((p, i) => (i === 0 ? p.t - block.from : p.t - during[i - 1].t)) + .filter(dt => dt > 0); + const longestGap = Math.max( + end - (during.at(-1)?.t ?? block.from), + ...intervals, + ); + await writeEvidence( + `busy-main-thread/${story.story}`, + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, release, rest, pressed, strengths}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, frames, { + name: `${story.story}-release-busy-main-thread`, + zero: block.from, + from: release.from - 40, + to: release.to + 40, + columns: 6, + cellScale: 0.45, + data: t => { + const p = pixels.find(f => f.t === t); + return {strength: p == null ? -1 : +strength(p.value).toFixed(3)}; + }, + }); + await film.write(testInfo, {block, release}); + // The press painted at full strength before the lift. + expect(rest - pressed).toBeGreaterThan(10); + // Frames kept coming through the block, each one further along the fade. + expect(during.length, JSON.stringify(strengths)).toBeGreaterThanOrEqual( + Math.floor((end - block.from) / (1000 / 60)) - 2, + ); + expect(longestGap).toBeLessThan(40); + for (let i = 1; i < strengths.length; i++) { + expect(strengths[i].strength).toBeLessThanOrEqual( + strengths[i - 1].strength + 0.01, + ); + } + const last = strengths.at(-1); + expect(last).toBeDefined(); + expect(strengths[0].strength).toBeGreaterThan((last?.strength ?? 1) + 0.2); + }); +} + +test('a busy first frame after the lift delays the release instead of skipping into it', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'WebKit starts an animation created just before a block at its last frame time (motion/animator.ts, known limits)', + ); + const {driver, row, center} = await openRow(page, LIST_ROW); + // When the press controller writes the release, 150 ms of script hold the + // main thread before the first frame. The release starts when the browser + // styles it, so the first frame due after the block shows it at most one + // frame in. + await row.evaluate(el => { + (window as unknown as {__first: Promise}).__first = new Promise( + resolve => { + const observer = new MutationObserver(() => { + if (el.getAttribute('data-astryx-press') !== 'fading') { + return; + } + observer.disconnect(); + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + // The first frame due after the block: a frame scheduled before + // it runs with that earlier time. + const read = () => { + if ((document.timeline.currentTime as number) < until) { + requestAnimationFrame(read); + return; + } + resolve(Number(getComputedStyle(el, '::before').opacity)); + }; + requestAnimationFrame(read); + }); + observer.observe(el, {attributeFilter: ['data-astryx-press']}); + }, + ); + }); + await driver.play(tap(center, {holdMs: 300})); + const first = await page.evaluate( + async () => (window as unknown as {__first: Promise}).__first, + ); + // At most one frame into the release: its ease-out curve takes the first + // frame to about 0.74; a start pinned before the block would be past 0.4. + expect(first).toBeGreaterThan(0.7); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('the release does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, row, center} = await openRow(page, LIST_ROW); + let release = {from: 0, to: 0}; + const started = Date.now(); + const trace = await traceRendering(page, async () => { + await driver.play(tap(center, {holdMs: HOLD_MS})); + [release] = await waitForAnimation(row); + await page.waitForTimeout(RELEASE_MS + 100); + }); + // The steady part of the release: its first and last frames may commit. + const during = countBetween(trace, release.from + 30, release.to - 30); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(await readLongTasks(page, {from: started, to: Date.now()})).toEqual( + [], + ); +}); + +test('WebKit: the release keeps fading while the main thread is blocked (video)', async ({ + browser, + browserName, +}, testInfo) => { + test.skip( + browserName !== 'webkit', + 'the Chromium screencast test covers Chromium', + ); + const size = {width: 402, height: 714}; + const context = await browser.newContext({ + viewport: size, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + recordVideo: {dir: testInfo.outputPath('video'), size}, + }); + const page = await context.newPage(); + const {driver, center, probe} = await openRow(page, LIST_ROW); + await installBeacon(page); + await blockBehindBeaconOn(page, 'touchend', 700); + await page.waitForTimeout(300); + await driver.play(tap(center, {holdMs: HOLD_MS})); + await page.waitForTimeout(1300); + const video = page.video(); + await context.close(); + const path = await video?.path(); + expect(path).toBeTruthy(); + const samples = await sampleVideo(path as string, probe, size); + const blocked = samples.filter(s => s.beacon === 'blocked'); + const rest = Math.max( + ...samples.filter(s => s.beacon === 'before').map(s => s.value), + ); + // Judge the release up to the frame it reaches the row's resting color. + // (Measured in the Linux WebKit build: once an accelerated animation has + // finished while script is still blocked, the compositor shows the value + // the main thread last committed until script runs again. That is this + // build's end-of-animation hand-back, after the release; iOS Safari 26.2 + // holds the resting color on the simulator. It is recorded below, not + // judged here.) + const restIndex = blocked.findIndex(s => s.value >= rest - 1); + const fading = restIndex === -1 ? blocked : blocked.slice(0, restIndex + 1); + const distinct = fading + .map(s => Math.round(s.value)) + .filter((v, i, all) => i === 0 || v !== all[i - 1]); + const afterEnd = restIndex === -1 ? [] : blocked.slice(restIndex + 1); + await writeEvidence( + 'busy-main-thread/list-row-release-webkit', + { + 'webkit.json': JSON.stringify( + {rest, blocked, distinct, afterEnd}, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'WebKit frames during the block', + description: `release: ${distinct.join(' ')}; after it ended, still blocked: ${[ + ...new Set(afterEnd.map(s => Math.round(s.value))), + ].join(' ')}`, + }); + expect(blocked.length).toBeGreaterThan(0); + // The row kept getting lighter while script could not run, all the way + // to its resting color: from a frame still short of rest, it reached rest + // inside the blocked window. A release on the main thread would hold its + // first frame's value until the block ends, 700 ms on, and could not. (The + // recording's frames are 40 ms apart, so how many land inside the 250 ms + // release depends on its phase: the count is not judged.) + const values = JSON.stringify(blocked.map(s => Math.round(s.value))); + expect(blocked[0].value, values).toBeLessThan(rest - 1); + expect(restIndex, values).toBeGreaterThan(0); + for (let i = 1; i < distinct.length; i++) { + expect(distinct[i], values).toBeGreaterThan(distinct[i - 1]); + } +}); + +test( + 'storyboard: a row released after a hold', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, row, center} = await openRow(page, LIST_ROW); + await installTimerGate(page); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const clip = { + x: 0, + y: Math.max(0, box.y - 16), + width: page.viewportSize()?.width ?? 402, + height: box.height + 32, + }; + await driver.play(tap(center, {holdMs: HOLD_MS})); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'list-row-press-release', + clip, + scope: row, + measure: async () => ({ + opacity: await layerOpacity(row), + state: await pressState(row), + }), + columns: 7, + cellScale: 0.6, + }); + await releaseAnimations(page, {finish: true}); + await thawTimers(page); + await storyboard.write(testInfo); + // The trace is the assertion; the sheet is the evidence. + const opacities = storyboard.frames.map(f => f.data.opacity as number); + expect(opacities[0]).toBeCloseTo(1, 2); + expect(opacities.at(-1)).toBeLessThan(0.01); + for (let i = 1; i < opacities.length; i++) { + expect(opacities[i]).toBeLessThanOrEqual(opacities[i - 1] + 1e-6); + } + storyboard.frames.forEach(f => + expect(f.data.opacity as number).toBeCloseTo(expectedStrength(f.t), 2), + ); + }, +); diff --git a/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts new file mode 100644 index 0000000000000..7e5f9ad6b62e8 --- /dev/null +++ b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts @@ -0,0 +1,360 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListRowPressPaths.interaction.browser.spec.ts + * @input The built List "Interactive" and Press Feedback "Rows" stories, the + * interaction harness, and the cell release clock (motion/uikit/list.ts) + * @output The row press paths beside the main release proofs: a press the + * browser takes for a scroll drops the highlight at once (the press + * controller's cancel, unchanged by the compositor layer); Reduce + * Motion keeps the fade (a fade, not a movement); a mouse press and hover on + * a fine pointer leave the press layer out of it + * @position Interaction evidence for the row press layer (utils/interactionOverlay.stylex.ts `cell`), beside + * ListRowPress.interaction.browser.spec.ts + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + holdAnimations, + openStory, + releaseAnimations, + seekAnimations, + tap, + touchDriver, + withStorybook, +} from '@astryxdesign/interaction-harness'; +import {list} from '../../motion/uikit/list'; + +const storybook = withStorybook(test); + +const RELEASE_MS: number = list.highlight.releaseMs; + +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (row: Locator) => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +const pressState = async (row: Locator) => + row.evaluate(el => el.getAttribute('data-astryx-press')); + +async function touchOnly(page: Page) { + const touch = await page.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip(!touch, 'the press layer serves a touch screen'); +} + +test("a press the browser takes for a scroll drops the highlight at once (the controller's cancel, unchanged)", async ({ + page, +}) => { + await openStory(page, storybook(), 'core-press-feedback-touch--rows'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Conversation 2'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // Every press state the controller writes, and every animation the row's + // layer starts, with page times. + await row.evaluate(el => { + const log: string[] = []; + (window as unknown as {__pressLog: string[]}).__pressLog = log; + new MutationObserver(() => + log.push(`press:${el.getAttribute('data-astryx-press')}`), + ).observe(el, {attributeFilter: ['data-astryx-press']}); + el.addEventListener('animationstart', event => + log.push(`start:${(event as AnimationEvent).pseudoElement}`), + ); + }); + const from = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Held long enough for the highlight to show, then a vertical pan the + // scroller takes: the browser cancels the touch for the page. + await driver.play( + drag(from, {x: from.x, y: from.y - 80}, {holdMs: 300, durationMs: 160}), + ); + await expect.poll(async () => pressState(row)).toBeNull(); + const log = await page.evaluate( + () => (window as unknown as {__pressLog: string[]}).__pressLog, + ); + // Highlighted while held, then the scroll's cancel removes it outright: + // no release fade starts, and the row is at rest. + expect(log, JSON.stringify(log)).toContain('press:on'); + expect(log.at(-1), JSON.stringify(log)).toBe('press:null'); + expect(log, JSON.stringify(log)).not.toContain('press:fading'); + expect(log, JSON.stringify(log)).not.toContain('start:::before'); + expect(await layerOpacity(row)).toBe(0); +}); + +test('Reduce Motion keeps the release fade: a fade, not a movement', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const driver = await touchDriver(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await driver.play( + tap({x: box.x + box.width / 2, y: box.y + box.height / 2}, {holdMs: 300}), + ); + await holdAnimations(page); + await seekAnimations(page, 0); + expect(await layerOpacity(row)).toBeCloseTo(1, 2); + await seekAnimations(page, RELEASE_MS / 2); + const midway = await layerOpacity(row); + expect(midway).toBeGreaterThan(0.05); + expect(midway).toBeLessThan(0.95); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => layerOpacity(row)).toBe(0); +}); + +test('a mouse press and hover on a fine pointer leave the press layer out of it', async ({ + browser, +}) => { + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), 'core-list--interactive'); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const fine = await page.evaluate( + () => matchMedia('(pointer: fine)').matches, + ); + expect(fine).toBe(true); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // No layer at all with a fine pointer: hovering, pressing and lifting + // never give the row a `::before` box. + const layer = async () => + row.evaluate(el => getComputedStyle(el, '::before').content); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + expect(await layer()).toBe('none'); + await page.mouse.down(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + await page.mouse.up(); + expect(await pressState(row)).toBeNull(); + expect(await layer()).toBe('none'); + } finally { + await context.close(); + } +}); + +test('a fine pointer: a row is as it was, with no press layer and no stacking context (desktop unchanged)', async ({ + browser, +}) => { + // AST-017 FR59/DEC-25: touch work leaves a fine pointer's rows as they were. The + // press layer and the stacking context it needs exist under a finger only, + // so a caller's own `::before` and z-index behave as before. + const context = await browser.newContext({ + viewport: {width: 1024, height: 768}, + hasTouch: false, + isMobile: false, + }); + const page = await context.newPage(); + try { + const rowBox = async (story: string, selector: string, label: string) => { + await openStory(page, storybook(), story); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const row = page.locator(selector).filter({hasText: label}).first(); + await expect(row).toBeVisible(); + return row.evaluate(el => ({ + before: getComputedStyle(el, '::before').content, + isolation: getComputedStyle(el).isolation, + })); + }; + expect( + await rowBox('core-list--interactive', '[data-astryx-pressable]', 'Sent'), + ).toEqual({before: 'none', isolation: 'auto'}); + // A row's own `::before` is exactly as its author wrote it: nothing of + // the layer (its opacity, stacking, hit testing) reaches it. + const own = await page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first() + .evaluate(el => { + const style = document.createElement('style'); + style.textContent = + '[data-test-own]::before{content:"•";position:static;opacity:1}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + }; + }); + expect(own).toEqual({ + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + }); + expect( + await rowBox( + 'core-treelist--interactive', + '#storybook-root [role="treeitem"] [data-astryx-pressable]', + 'Settings', + ), + ).toEqual({before: 'none', isolation: 'auto'}); + } finally { + await context.close(); + } +}); + +test('a radio row under a finger paints its circle’s press, and keeps its bare look: no row layer', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-radiolist--default'); + await touchOnly(page); + const row = page.locator('#storybook-root [data-astryx-pressable]').first(); + await expect(row).toBeVisible(); + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + // On the label, away from the circle: the row takes the press. + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width * 0.6, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + const look = await row.evaluate(el => { + const wrapper = [...el.querySelectorAll('*')].find( + child => + child.querySelector(':scope > input[type="radio"]') != null && + getComputedStyle(child, '::after').content !== 'none', + ); + return { + circle: + wrapper == null + ? null + : getComputedStyle(wrapper, '::after').backgroundImage, + layer: getComputedStyle(el, '::before').content, + }; + }); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The circle's own press paint: the pressed token, not a transparent + // gradient. + expect(look.circle).toMatch(/gradient/); + expect(look.circle).not.toMatch( + /^linear-gradient\(rgba\(0, 0, 0, 0\), rgba\(0, 0, 0, 0\)\)$/, + ); + // Only the circle paints, as with a mouse: the row has no layer. + expect(look.layer).toBe('none'); +}); + +test('under a finger, a row at rest is as it was: its own ::before keeps its look and it is no stacking context; the press layer takes the ::before only while pressed', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'the held touch is driven with Chromium touch events (CDP)', + ); + await openStory(page, storybook(), 'core-list--interactive'); + await touchOnly(page); + const row = page + .locator('[data-astryx-pressable]') + .filter({hasText: 'Sent'}) + .first(); + await expect(row).toBeVisible(); + const look = async () => + row.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return { + content: before.content, + opacity: before.opacity, + position: before.position, + zIndex: before.zIndex, + pointerEvents: before.pointerEvents, + isolation: getComputedStyle(el).isolation, + }; + }); + // A caller's own decoration on the row's `::before` (a page rule beats + // the components' layered styles for what it sets; the rest is theirs). + await row.evaluate(el => { + const style = document.createElement('style'); + style.textContent = '[data-test-own]::before{content:"•"}'; + document.head.append(style); + el.setAttribute('data-test-own', ''); + }); + const own = { + content: '"•"', + opacity: '1', + position: 'static', + zIndex: 'auto', + pointerEvents: 'auto', + isolation: 'auto', + }; + expect(await look()).toEqual(own); + // A press: the layer takes the `::before`, in its own stacking context. + const box = await row.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const cdp = await page.context().newCDPSession(page); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{x: box.x + box.width / 2, y: box.y + box.height / 2}], + }); + await expect.poll(async () => pressState(row)).toBe('on'); + // The layer's box (with the caller's content), painted, in its own + // stacking context. + const pressed = await look(); + expect(pressed.content).toBe('"•"'); + expect(pressed.position).toBe('absolute'); + expect(pressed.zIndex).toBe('-1'); + expect(pressed.opacity).toBe('1'); + expect(pressed.isolation).toBe('isolate'); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchCancel', + touchPoints: [], + }); + // The press over, the row's own decoration is back as it was. + await expect.poll(async () => pressState(row)).toBeNull(); + expect(await look()).toEqual(own); +}); diff --git a/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts new file mode 100644 index 0000000000000..848e854a701e9 --- /dev/null +++ b/packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts @@ -0,0 +1,531 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ListSelectionHold.interaction.browser.spec.ts + * @input The built List "Selection Hold" story (buttons and a slider standing + * in for a navigation stack), real touch input, the interaction harness, + * and the in-transition deselect clock (motion/uikit/list.ts) + * @output Interaction and animation proofs for a row kept selected through a + * push: the tapped row stays selected after its press would have faded; a + * pop deselects it on UIKit's 350 ms linear curve, then the row's control + * has focus, with the keyboard ring only after a keyboard's pop; a scrub + * sets the highlight from the progress and a cancel selects the row again + * at once; the slider follows a running deselect, so a finger takes it over + * from the value on screen; the deselect keeps advancing while the + * main thread is blocked (judged on the frames the screencast delivers, + * in both engines, whatever their rate), with no Layout or Paint; under + * reduced motion + * the deselect cross-fades, opacity only, within 200 ms + * (`design:reduced-motion`); a storyboard of the deselect + * @position Interaction evidence for List/selectionHold.ts on Item rows, in + * the one touch model (./touchStory.ts), with an Android case that holds the + * same way and a mouse case where there is no + * hold + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + castStoryboard, + compareCurve, + countBetween, + holdAnimations, + releaseAnimations, + seekAnimations, + startScreencast, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + withStorybook, + writeEvidence, +} from '@astryxdesign/interaction-harness'; +import {openStory} from './touchStory'; +import {expectReducedMotionCrossFade} from './reducedMotion'; +import {list} from '../../motion/uikit/list'; +import {samplePixel} from './rowPixels'; + +const storybook = withStorybook(test); + +const STORY = 'core-list--selection-hold'; +const POP_MS = list.deselect.navigationPopMs; +/** Highlight = 1 − progress; the in-transition deselect is linear. */ +const expectedHighlight = (t: number) => + 1 - Math.min(1, Math.max(0, t / POP_MS)); + +const row = (page: Page) => + page.locator('[data-astryx-pressable]').filter({hasText: 'Sent'}).first(); + +/** The press layer's strength: 0 while the row has no layer box (at rest). */ +const layerOpacity = async (target: Locator) => + target.evaluate(el => { + const before = getComputedStyle(el, '::before'); + return before.content === 'none' || before.content === 'normal' + ? 0 + : Number(before.opacity); + }); + +const pressState = async (target: Locator) => + target.evaluate(el => el.getAttribute('data-astryx-press')); + +/** Taps "Sent" with a finger: the activation pushes, and the row is held. */ +async function push(page: Page) { + await openStory(page, storybook(), STORY); + const target = row(page); + await expect(target).toBeVisible(); + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const driver = await touchDriver(page); + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect(page.getByTestId('pushed-screen')).toBeVisible(); + return { + target, + probe: { + x: Math.round(box.x + box.width - 3), + y: Math.round(box.y + box.height / 2), + }, + }; +} + +test('a row tapped again during its slow deselect is held again, with nothing marking it deselecting', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByRole('button', {name: 'Back, slowly'}).click(); + const deselecting = async () => + target.evaluate(el => el.hasAttribute('data-astryx-deselecting')); + await expect.poll(deselecting).toBe(true); + // A finger taps the row again mid-deselect: the new push holds it. + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + const driver = await touchDriver(page); + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + await expect.poll(async () => pressState(target)).toBe('held'); + // Fully held: its dividers stay hidden, as for the first hold. + expect(await deselecting()).toBe(false); + await expect.poll(async () => layerOpacity(target)).toBe(1); +}); + +test("a busy first frame delays the pop's deselect instead of skipping into it", async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'WebKit starts an animation created just before a block at its last frame time (motion/animator.ts, known limits)', + ); + const {target} = await push(page); + expect(await layerOpacity(target)).toBe(1); + // Back's handler starts the deselect; 150 ms of script then hold the main + // thread before the first frame. The deselect starts when the browser + // commits it, so the first frame due after the block shows it at most one + // frame in. + const first = await target.evaluate( + async row => + new Promise(resolve => { + document.addEventListener( + 'click', + () => { + const until = performance.now() + 150; + while (performance.now() < until) { + // Busy: the main thread is held. + } + // The first frame due after the block: a frame scheduled + // before it runs with that earlier time. + const read = () => { + if ((document.timeline.currentTime as number) < until) { + requestAnimationFrame(read); + return; + } + resolve(Number(getComputedStyle(row, '::before').opacity)); + }; + requestAnimationFrame(read); + }, + {once: true}, + ); + [...document.querySelectorAll('button')] + .find(button => button.textContent?.trim() === 'Back') + ?.click(); + }), + ); + // At most one frame into the 350 ms deselect (1 − 17/350 ≈ 0.95); a start + // pinned before the block would show it about half done. + expect(first).toBeGreaterThan(0.9); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('Android: the same hold; Back deselects the row (one touch model)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'Android is Chromium'); + const {target} = await push(page); + expect(await pressState(target)).toBe('held'); + expect(await layerOpacity(target)).toBe(1); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('a mouse: no hold; the press releases and Back just pops', async ({ + browser, +}) => { + const context = await browser.newContext({ + hasTouch: false, + isMobile: false, + viewport: {width: 900, height: 700}, + }); + const page = await context.newPage(); + try { + await openStory(page, storybook(), STORY); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const target = row(page); + await expect(target).toBeVisible(); + await target.click(); + await expect(page.getByTestId('pushed-screen')).toBeVisible(); + expect(await pressState(target)).toBeNull(); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + expect(await pressState(target)).toBeNull(); + } finally { + await context.close(); + } +}); + +test('the tapped row stays selected while the pushed screen covers it', async ({ + page, +}) => { + const {target} = await push(page); + // Well past the press's own release clock: the hold keeps it selected. + await page.waitForTimeout(list.highlight.releaseMs + 300); + expect(await pressState(target)).toBe('held'); + expect(await layerOpacity(target)).toBe(1); +}); + +test('the dividers around a held row stay hidden while it is covered, return when the deselect begins, and hide again on a cancel', async ({ + page, +}) => { + const {target} = await push(page); + const dividers = async () => + page + .locator('li[data-astryx-pressable]') + .evaluateAll(elements => + elements.map(el => getComputedStyle(el).borderBottomColor), + ); + const hidden = 'rgba(0, 0, 0, 0)'; + // Sent is the second row: its own divider and Inbox's above it. + await page.waitForTimeout(list.highlight.releaseMs + 300); + const covered = await dividers(); + expect(covered[0]).toBe(hidden); + expect(covered[1]).toBe(hidden); + // An interactive pop begins: both come back in its first frame while the + // highlight is still mostly on. + await page.getByLabel('Pop progress').fill('0.1'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.9, 2); + const deselecting = await dividers(); + expect(deselecting[0]).not.toBe(hidden); + expect(deselecting[1]).not.toBe(hidden); + // A cancel selects the row again, and the dividers hide again. + await page.getByRole('button', {name: 'Cancel pop'}).click(); + await expect.poll(async () => layerOpacity(target)).toBe(1); + const reselected = await dividers(); + expect(reselected[0]).toBe(hidden); + expect(reselected[1]).toBe(hidden); + // The pop: back in its first frame, and they stay once it ends. + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await holdAnimations(page, target); + await seekAnimations(page, 0); + const popStart = await dividers(); + expect(popStart[0]).not.toBe(hidden); + expect(popStart[1]).not.toBe(hidden); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => pressState(target)).toBeNull(); + expect(await dividers()).toEqual(popStart); +}); + +test('a pop deselects the row on the in-transition curve, then its control has focus', async ({ + page, +}, testInfo) => { + const {target} = await push(page); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const held = await holdAnimations(page, target); + // The deselect on the row's layer. (The motion foundation keeps a + // pseudo-element's animation in effect past the curve, holding its end + // value until script runs: only the first POP_MS are the curve.) + const deselect = held.find(a => a.target?.startsWith('li')); + expect(deselect, JSON.stringify(held)).toBeDefined(); + const samples: {t: number; value: number}[] = []; + for (const t of timeline(POP_MS)) { + await seekAnimations(page, t); + samples.push({t, value: await layerOpacity(target)}); + } + await releaseAnimations(page, {finish: true}); + const comparison = compareCurve(samples, expectedHighlight, 0.01); + await writeEvidence( + 'curves/list-selection-hold-pop', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {popMs: POP_MS, held, samples, comparison}, + null, + 2, + ), + }, + testInfo, + ); + expect( + comparison.maxError, + JSON.stringify(comparison.worst), + ).toBeLessThanOrEqual(0.01); + // The pop completed: the row is deselected and its own control has focus, + // where a screen reader lands back, with no keyboard ring after a pointer's + // pop (UIKit draws none for a finger). + await expect.poll(async () => pressState(target)).toBeNull(); + expect(await layerOpacity(target)).toBe(0); + expect(await target.evaluate(el => el.contains(document.activeElement))).toBe( + true, + ); + expect( + await page.evaluate( + () => document.activeElement?.matches(':focus-visible') ?? null, + ), + ).toBe(false); +}); + +test("under reduced motion a pop's deselect cross-fades: opacity only, within 200 ms", async ({ + page, +}) => { + // design:reduced-motion DR1 and DR2, under a finger. + await page.emulateMedia({reducedMotion: 'reduce'}); + const {target} = await push(page); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const held = await holdAnimations(page, target); + expectReducedMotionCrossFade(held); + await releaseAnimations(page, {finish: true}); + await expect.poll(async () => layerOpacity(target)).toBe(0); +}); + +test('a pop from the keyboard returns focus to the row with the keyboard ring', async ({ + page, +}) => { + const {target} = await push(page); + const back = page.getByRole('button', {name: 'Back', exact: true}); + await back.focus(); + await page.keyboard.press('Enter'); + await expect.poll(async () => pressState(target)).toBeNull(); + await expect(page.getByTestId('pushed-screen')).toHaveCount(0); + expect(await target.evaluate(el => el.contains(document.activeElement))).toBe( + true, + ); + expect( + await page.evaluate( + () => document.activeElement?.matches(':focus-visible') ?? null, + ), + ).toBe(true); +}); + +test('a scrub sets the highlight from the progress; a cancel selects the row again at once', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByLabel('Pop progress').fill('0.4'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.6, 2); + await page.getByLabel('Pop progress').fill('0.7'); + await expect.poll(async () => layerOpacity(target)).toBeCloseTo(0.3, 2); + await page.getByRole('button', {name: 'Cancel pop'}).click(); + // Back to selected once the cancel settles, and still held for the next pop. + await expect.poll(async () => layerOpacity(target)).toBe(1); + expect(await pressState(target)).toBe('held'); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + await expect.poll(async () => pressState(target)).toBeNull(); +}); + +test('while the deselect runs, the slider shows where it is, so a finger takes it over from the value on screen', async ({ + page, +}) => { + const {target} = await push(page); + await page.getByRole('button', {name: 'Back, slowly'}).click(); + // About a third of the way through the slow, linear 3 s deselect. + await page.waitForTimeout(1000); + const slider = page.getByLabel('Pop progress'); + const read = async () => { + const [value, opacity] = await Promise.all([ + slider.evaluate(el => Number((el as HTMLInputElement).value)), + layerOpacity(target), + ]); + return {value, opacity}; + }; + const {value, opacity} = await read(); + // The thumb sits at the deselect's progress (1 − the highlight), not at 0, + // where a finger landing on it would snap the highlight back to full. + expect(value).toBeGreaterThan(0.15); + expect(value).toBeLessThan(0.85); + expect(value).toBeCloseTo(1 - opacity, 1); + // And it keeps following. + await page.waitForTimeout(400); + const later = await read(); + expect(later.value).toBeGreaterThan(value); + expect(later.value).toBeCloseTo(1 - later.opacity, 1); +}); + +/** + * Pops a held row and blocks the main thread for 600 ms while its deselect + * runs, capturing the frames the screencast delivers. The proof is judged on + * the delivered frames inside the block, up to the deselect's own end (the + * animation holds its end value past it, and the Linux WebKit build hands an + * animation that ends inside a block back to its last committed value), and + * counts none: a loaded machine's screencast delivers fewer frames than the + * compositor presents, so a missing frame is not a skipped one. + */ +async function popThroughBlock(page: Page, everyNthFrame = 1) { + const {target, probe} = await push(page); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(100); + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const [started] = await waitForAnimation(target, {minProgressMs: 16}); + const deselect = {from: started.from, to: started.from + POP_MS}; + const block = await blockMainThread(page, 600); + await page.waitForTimeout(150); + const frames = await cast.stop(); + const pixels = await samplePixel(page, frames, probe); + const selected = pixels[0].value; + const after = pixels.at(-1)?.value ?? 255; + const highlight = (v: number) => (after - v) / (after - selected); + const end = Math.min(block.to, deselect.to) - 10; + const highlights = pixels + .filter(p => p.t > block.from && p.t <= end) + .map(p => ({ + t: +(p.t - block.from).toFixed(1), + highlight: +highlight(p.value).toFixed(3), + })); + return { + frames, + pixels, + block, + deselect, + selected, + after, + highlight, + highlights, + }; +} + +/** + * The deselect advanced while script could not run: selected before the pop + * and deselected after; inside the block, two delivered frames at least (a + * change needs two), each as far along as the one before it, and the row well + * lighter by the end. + */ +function expectAdvancedThroughBlock({ + selected, + after, + highlights, +}: Awaited>): void { + const summary = JSON.stringify(highlights); + expect(after - selected).toBeGreaterThan(10); + expect(highlights.length, summary).toBeGreaterThanOrEqual(2); + for (let i = 1; i < highlights.length; i++) { + expect(highlights[i].highlight, summary).toBeLessThanOrEqual( + highlights[i - 1].highlight + 0.01, + ); + } + const last = highlights.at(-1); + expect(last, summary).toBeDefined(); + expect(highlights[0].highlight, summary).toBeGreaterThan( + (last?.highlight ?? 1) + 0.3, + ); +} + +test('the deselect keeps advancing while the main thread is blocked', async ({ + page, +}, testInfo) => { + const run = await popThroughBlock(page); + const {frames, block, deselect, selected, after, highlight, highlights} = run; + await writeEvidence( + 'busy-main-thread/list-selection-hold-pop', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {block, deselect, selected, after, highlights}, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, frames, { + name: `list-selection-hold-pop-busy-main-thread-${testInfo.project.name}`, + zero: block.from, + from: deselect.from - 40, + to: deselect.to + 40, + columns: 8, + cellScale: 0.45, + data: t => { + const p = run.pixels.find(f => f.t === t); + return {highlight: p == null ? -1 : +highlight(p.value).toFixed(3)}; + }, + }); + await film.write(testInfo, {block, deselect}); + expectAdvancedThroughBlock(run); +}); + +test('the same proof holds on a screencast that delivers one frame in four', async ({ + page, +}) => { + // A loaded machine's screencast keeps a fraction of the frames the + // compositor presents; the verdict must not depend on how many arrive. + expectAdvancedThroughBlock(await popThroughBlock(page, 4)); +}); + +test('the deselect does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {target} = await push(page); + let deselect = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + // Back is activated without a pointer press: on a touch-first device a + // mouse press paints Back's own press, and the trace measures the row's + // deselect alone. + await page + .getByRole('button', {name: 'Back', exact: true}) + .dispatchEvent('click'); + const [started] = await waitForAnimation(target); + // The curve's own window (the animation holds its end value past it). + deselect = {from: started.from, to: started.from + POP_MS}; + await page.waitForTimeout(POP_MS + 100); + }); + const during = countBetween(trace, deselect.from + 30, deselect.to - 30); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); +}); + +test( + 'storyboard: a held row deselected by a pop', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {target} = await push(page); + const box = await target.boundingBox(); + if (box == null) { + throw new Error('no row'); + } + await page.getByRole('button', {name: 'Back', exact: true}).click(); + const {storyboard} = await animationStoryboard(page, { + name: 'list-selection-hold-pop', + clip: {x: 0, y: box.y - 8, width: 402, height: box.height + 16}, + scope: target, + times: [0, 50, 100, 175, 250, 300, POP_MS], + columns: 7, + cellScale: 0.5, + measure: async () => ({opacity: await layerOpacity(target)}), + }); + await releaseAnimations(page, {finish: true}); + await storyboard.write(testInfo); + }, +); diff --git a/packages/core/src/List/__tests__/reducedMotion.ts b/packages/core/src/List/__tests__/reducedMotion.ts new file mode 100644 index 0000000000000..bf901dd6b5b5e --- /dev/null +++ b/packages/core/src/List/__tests__/reducedMotion.ts @@ -0,0 +1,102 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file reducedMotion.ts + * @input The interaction harness's held animations + * @output `expectReducedMotionCrossFade`: the browser check of + * `design:reduced-motion` DR1 and DR2 over what a change started + * @position Shared by the List and TreeList interaction specs; each case runs + * under reduced motion with a coarse pointer (a finger) + */ + +import {expect} from '@playwright/test'; +import type {HeldAnimation} from '@astryxdesign/interaction-harness'; + +/** DR2's limit on a touch presentation's cross-fade. */ +export const REDUCED_MOTION_FADE_MS = 200; + +const TIMING_KEYS = new Set([ + 'offset', + 'computedOffset', + 'easing', + 'composite', +]); + +/** Properties that move, turn or scale an element. */ +const TRAVEL = new Set(['translate', 'rotate', 'scale', 'transform']); + +/** The properties an animation's keyframes change. */ +function animatedProperties(animation: HeldAnimation): string[] { + const properties = new Set(); + for (const frame of animation.keyframes) { + for (const key of Object.keys(frame)) { + if (!TIMING_KEYS.has(key)) { + properties.add(key); + } + } + } + return [...properties]; +} + +/** + * Whether the animation only lands `property` at a new value (a step: the + * value changes between keyframes at the same offset, or under a step + * easing) rather than moving it through the values between. + */ +function onlyLands(animation: HeldAnimation, property: string): boolean { + const frames = animation.keyframes.filter(frame => property in frame); + for (let index = 0; index + 1 < frames.length; index += 1) { + const from = frames[index]; + const to = frames[index + 1]; + if (String(from[property]) === String(to[property])) { + continue; + } + const sameOffset = + Number(from.computedOffset) === Number(to.computedOffset); + const easing = typeof from.easing === 'string' ? from.easing : ''; + const stepped = /^step|steps\(/.test(easing); + if (!sameOffset && !stepped) { + return false; + } + } + return true; +} + +/** + * `design:reduced-motion` DR1 and DR2, under a coarse pointer: the change + * cross-fades (at least one opacity animation runs, its curve ending within + * 200 ms), nothing travels, scales or resizes on the way (an element may + * land at its new place in one step, hidden by the fade), and nothing but + * opacity animates. `held` is what `holdAnimations` returned right after the + * change. + */ +export function expectReducedMotionCrossFade( + held: ReadonlyArray, +): void { + const summary = JSON.stringify( + held.map(animation => ({ + target: animation.target, + properties: animatedProperties(animation), + curveEndMs: animation.curveEndMs, + })), + ); + let fades = 0; + for (const animation of held) { + for (const property of animatedProperties(animation)) { + if (property === 'opacity') { + fades += 1; + expect(animation.curveEndMs, summary).toBeGreaterThan(0); + expect(animation.curveEndMs, summary).toBeLessThanOrEqual( + REDUCED_MOTION_FADE_MS + 1, + ); + } else if (TRAVEL.has(property)) { + expect(onlyLands(animation, property), `${property}: ${summary}`).toBe( + true, + ); + } else { + expect(property, summary).toBe('opacity'); + } + } + } + expect(fades, summary).toBeGreaterThan(0); +} diff --git a/packages/core/src/List/__tests__/rowPixels.ts b/packages/core/src/List/__tests__/rowPixels.ts new file mode 100644 index 0000000000000..3dd39d14f5d0b --- /dev/null +++ b/packages/core/src/List/__tests__/rowPixels.ts @@ -0,0 +1,249 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file rowPixels.ts + * @input Playwright pages and screencast frames from the interaction harness + * @output `samplePixel` (one point's value in every screencast frame) and + * WebKit's video evidence: `installBeacon`, `blockBehindBeacon` (a main-thread + * beacon that marks the frames presented while script could not run) and + * `sampleVideo` + * @position Test helper for the List interaction proofs (row press release, + * selection hold), which read a row's press strength from pixels + */ + +import {readFile} from 'node:fs/promises'; +import {chromium, type Page} from '@playwright/test'; +import type {CastFrame} from '@astryxdesign/interaction-harness'; + +export interface PixelFrame { + readonly t: number; + /** Mean of the RGB channels at the probe point, 0–255. */ + readonly value: number; +} + +/** + * The probe point's value in every frame. Decoding runs in a scratch page of + * the same browser (no image decoder in Node), like the harness's own marker + * tracking. + */ +export async function samplePixel( + page: Page, + frames: ReadonlyArray, + probe: {x: number; y: number}, +): Promise { + const scratch = await page.context().newPage(); + try { + const out: PixelFrame[] = []; + for (let i = 0; i < frames.length; i += 12) { + const batch = frames.slice(i, i + 12); + const values = await scratch.evaluate( + async ({items, at}) => { + const results: number[] = []; + for (const item of items) { + const bitmap = await createImageBitmap( + await ( + await fetch(`data:image/${item.format};base64,${item.data}`) + ).blob(), + ); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d'); + if (context == null) { + throw new Error('no 2d context'); + } + context.drawImage(bitmap, 0, 0); + const scale = bitmap.width / item.viewportWidth; + const px = context.getImageData( + Math.round(at.x * scale), + Math.round(at.y * scale), + 1, + 1, + ).data; + results.push((px[0] + px[1] + px[2]) / 3); + } + return results; + }, + { + items: batch.map(f => ({ + data: f.data, + format: f.format, + viewportWidth: f.viewportWidth, + })), + at: probe, + }, + ); + values.forEach((value, k) => out.push({t: batch[k].t, value})); + } + return out; + } finally { + await scratch.close(); + } +} + +/** The beacon's size, CSS px, in the viewport's bottom-right corner. */ +const BEACON = 40; + +/** + * Adds the main-thread beacon: a corner square the page paints grey, blue + * while it is about to block, and green once script runs again. A recorded + * frame showing blue was presented while script could not run. + */ +export async function installBeacon(page: Page): Promise { + await page.evaluate(size => { + const beacon = document.createElement('div'); + beacon.id = 'beacon'; + Object.assign(beacon.style, { + position: 'fixed', + right: '0', + bottom: '0', + width: `${size}px`, + height: `${size}px`, + background: 'rgb(128, 128, 128)', + zIndex: '2147483647', + }); + document.body.append(beacon); + }, BEACON); +} + +/** + * Paints the beacon blue, lets two frames commit it, blocks the main thread + * for `ms`, then paints it green. + */ +export async function blockBehindBeacon(page: Page, ms: number): Promise { + await page.evaluate( + async duration => + new Promise(resolve => { + const beacon = document.getElementById('beacon') as HTMLElement; + beacon.style.background = 'rgb(0, 0, 255)'; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const end = performance.now() + duration; + while (performance.now() < end) { + // blocked + } + beacon.style.background = 'rgb(0, 200, 0)'; + resolve(); + }), + ); + }), + ms, + ); +} + +/** + * Arms the same block in the page for the next `event` (e.g. the lift's + * `touchend`): beacon blue, two frames, the block, beacon green. Armed in + * the page, the block starts two frames after the event with no + * test-runner round trip in between, so a short motion cannot finish first. + */ +export async function blockBehindBeaconOn( + page: Page, + event: string, + ms: number, +): Promise { + await page.evaluate( + ({type, duration}) => { + addEventListener( + type, + () => { + const beacon = document.getElementById('beacon') as HTMLElement; + beacon.style.background = 'rgb(0, 0, 255)'; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const end = performance.now() + duration; + while (performance.now() < end) { + // blocked + } + beacon.style.background = 'rgb(0, 200, 0)'; + }), + ); + }, + {once: true, passive: true, capture: true}, + ); + }, + {type: event, duration: ms}, + ); +} + +export interface VideoSample { + /** Video time, s. */ + readonly t: number; + /** Mean of the RGB channels at the probe point, 0–255. */ + readonly value: number; + readonly beacon: 'before' | 'blocked' | 'after'; +} + +/** + * The probe point's value and the beacon's state every 10 ms of a recorded + * video (Playwright's `recordVideo`, WebKit's frame evidence), decoded in a + * Chromium page. + */ +export async function sampleVideo( + videoPath: string, + probe: {x: number; y: number}, + viewport: {width: number; height: number}, +): Promise { + const webm = (await readFile(videoPath)).toString('base64'); + const decoder = await chromium.launch(); + try { + const player = await decoder.newPage(); + return await player.evaluate( + async ({at, data, beaconAt}) => { + const element = document.createElement('video'); + element.muted = true; + element.src = URL.createObjectURL( + new Blob([Uint8Array.from(atob(data), c => c.charCodeAt(0))], { + type: 'video/webm', + }), + ); + document.body.append(element); + if (element.readyState < 1) { + await new Promise(r => + element.addEventListener('loadedmetadata', r, {once: true}), + ); + } + element.pause(); + const canvas = document.createElement('canvas'); + canvas.width = element.videoWidth; + canvas.height = element.videoHeight; + const context2d = canvas.getContext('2d', { + willReadFrequently: true, + }) as CanvasRenderingContext2D; + const out: { + t: number; + value: number; + beacon: 'before' | 'blocked' | 'after'; + }[] = []; + const duration = Number.isFinite(element.duration) + ? element.duration + : 10; + for (let t = 0; t < duration; t += 0.01) { + element.currentTime = t; + await new Promise(r => + element.addEventListener('seeked', r, {once: true}), + ); + context2d.drawImage(element, 0, 0); + const px = context2d.getImageData(at.x, at.y, 1, 1).data; + const b = context2d.getImageData(beaconAt.x, beaconAt.y, 1, 1).data; + const beacon = + b[2] > 180 && b[0] < 80 + ? 'blocked' + : b[1] > 150 && b[2] < 80 + ? 'after' + : 'before'; + out.push({t, value: (px[0] + px[1] + px[2]) / 3, beacon}); + } + return out; + }, + { + at: probe, + data: webm, + beaconAt: { + x: viewport.width - BEACON / 2, + y: viewport.height - BEACON / 2, + }, + }, + ); + } finally { + await decoder.close(); + } +} diff --git a/packages/core/src/List/__tests__/touchStory.ts b/packages/core/src/List/__tests__/touchStory.ts new file mode 100644 index 0000000000000..e33cdd9f32111 --- /dev/null +++ b/packages/core/src/List/__tests__/touchStory.ts @@ -0,0 +1,44 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchStory.ts + * @input Playwright's Page and a built Storybook's origin + * @output `openStory(page, origin, storyId, options)`: opens a story and + * waits for its first rows, its fonts and two frames + * @position Shared helper for the list specs. Their behaviour is the one + * touch model under any finger (TOUCH_QUERY in utils/touchPresentation.ts): + * the projects' contexts already have the finger, and nothing is emulated, + * so Chromium shows it as Android's engine does and WebKit as Safari's. A + * mouse case opens its own context without touch. + * + * SYNC: When modified, update the specs that import it. + */ + +import type {Page} from '@playwright/test'; + +/** + * Opens a story and waits for its first rows, its fonts and two frames. + * `globals`: Storybook globals such as `direction:rtl`. + */ +export async function openStory( + page: Page, + origin: string, + storyId: string, + {globals}: {globals?: string} = {}, +): Promise { + const params = new URLSearchParams({id: storyId, viewMode: 'story'}); + if (globals != null) { + params.set('globals', globals); + } + await page.goto(`${origin}/iframe.html?${params.toString()}`); + await page + .locator('#storybook-root > *') + .first() + .waitFor({state: 'attached'}); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); +} diff --git a/packages/core/src/List/index.ts b/packages/core/src/List/index.ts index 7126a7fbba4d3..da602926bd17b 100644 --- a/packages/core/src/List/index.ts +++ b/packages/core/src/List/index.ts @@ -5,7 +5,9 @@ /** * @file index.ts * @input Imports from List.tsx and ListItem.tsx - * @output Exports List, ListItem components and their props/types + * @output Exports List, ListItem components and their props/types. The + * selection hold (selectionHold.ts) is internal: no public API records it + * (`architecture:public-component-api` INV12) * @position Package entry point for List * * SYNC: When modified, update /packages/core/src/List/List.doc.mjs diff --git a/packages/core/src/List/selectionHold.test.ts b/packages/core/src/List/selectionHold.test.ts new file mode 100644 index 0000000000000..16909acd84d91 --- /dev/null +++ b/packages/core/src/List/selectionHold.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file selectionHold.test.ts + * @input vitest, Item and TreeList rows, the spring and Bézier solvers, and + * the in-transition deselect clock (motion/uikit/list.ts) + * @output Unit tests for holdSelection: which targets it holds (under any + * finger, never a mouse), the held state it writes and leaves, presentation values + * from each timing's closed-form curve, the deselect's clock and curve as + * internal constants no theme variable reaches, interruption, completion, + * cancel and replacement + * @position Beside List/selectionHold.ts; the compositor and pixel proofs are + * in List/__tests__/ListSelectionHold.interaction.browser.spec.ts + */ + +import {createElement} from 'react'; +import {render, screen} from '@testing-library/react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {Item} from '../Item'; +import {List} from './List'; +import {ListItem} from './ListItem'; +import {TreeList} from '../TreeList'; +import {cubicBezier} from '../motion/bezier'; +import {solveSpring, toSpring} from '../motion/spring'; +import {list} from '../motion/uikit/list'; +import {timing} from '../motion/uikit/timing'; +import { + __resetInteractionModalityForTest, + __startInteractionModalityTrackingForTest, +} from '../utils/interactionModality'; +import { + CELL_PRESS_PROFILE, + PRESSABLE_ATTRIBUTE, + PRESSED_ATTRIBUTE, +} from '../utils/pressFeedback'; +import {DESELECTING_ATTRIBUTE, holdSelection} from './selectionHold'; + +let clock = 0; +const now = () => clock; + +/** + * The page under a finger (a coarse primary pointer: one touch model, iOS + * and Android alike) or a mouse, optionally with the person preferring + * reduced motion. + */ +function stubPointer( + pointer: 'touch' | 'mouse', + {reducedMotion = false}: {reducedMotion?: boolean} = {}, +) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: + (pointer === 'touch' && query.includes('pointer: coarse')) || + (reducedMotion && query.includes('prefers-reduced-motion: reduce')), + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + })), + ); +} + +beforeEach(() => { + clock = 1000; + vi.useFakeTimers(); + stubPointer('touch'); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function listRow(label = 'Inbox') { + render( + createElement( + List, + null, + createElement(ListItem, {label, onClick: () => {}}), + createElement(ListItem, {label: 'Static'}), + ), + ); + const row = screen.getByText(label).closest('li') as HTMLElement; + // The activation's target: the row's own control. + const control = row.querySelector('button') as HTMLElement; + return {row, control}; +} + +describe('what a hold holds', () => { + it('holds the list row around the activation target, marked as a cell', () => { + const {row, control} = listRow(); + expect(row.getAttribute(PRESSABLE_ATTRIBUTE)).toBe(CELL_PRESS_PROFILE); + const hold = holdSelection(control, {now}); + expect(hold?.element).toBe(row); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + }); + + it('holds a TreeList row by its row box', () => { + render( + createElement(TreeList, { + items: [{id: 'a', label: 'App.tsx', onClick: () => {}}], + }), + ); + const box = screen + .getByRole('treeitem') + .querySelector(`[${PRESSABLE_ATTRIBUTE}]`) as HTMLElement; + const hold = holdSelection(screen.getByText('App.tsx'), {now}); + expect(hold?.element).toBe(box); + }); + + it("returns focus in a tree to the row's treeitem, the tree's Tab stop", () => { + render( + createElement(TreeList, { + items: [ + {id: 'a', label: 'App.tsx', onClick: () => {}}, + {id: 'b', label: 'index.tsx', onClick: () => {}}, + ], + }), + ); + const item = screen + .getByText('index.tsx') + .closest('[role="treeitem"]') as HTMLElement; + const hold = holdSelection(screen.getByText('index.tsx'), {now}); + hold?.end(true); + hold?.focus({preventScroll: true}); + expect(document.activeElement).toBe(item); + }); + + it('holds nothing under a mouse: touch work costs nothing there', () => { + stubPointer('mouse'); + expect(matchMedia('(pointer: coarse)').matches).toBe(false); + const {row, control} = listRow('Inbox mouse'); + expect(holdSelection(control, {now})).toBeNull(); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('holds nothing outside an enabled row with an action', () => { + render( + createElement( + 'div', + null, + createElement(Item, {label: 'Static', 'data-testid': 'static'}), + createElement(Item, { + label: 'Off', + onClick: () => {}, + isDisabled: true, + 'data-testid': 'off', + }), + createElement('button', {'data-testid': 'button'}, 'Plain'), + ), + ); + expect(holdSelection(screen.getByTestId('static'))).toBeNull(); + expect(holdSelection(screen.getByTestId('off'))).toBeNull(); + expect(holdSelection(screen.getByTestId('button'))).toBeNull(); + expect(holdSelection(null)).toBeNull(); + expect(holdSelection(window)).toBeNull(); + }); +}); + +describe('presentation follows each timing’s curve', () => { + it('a programmatic pop: UIKit’s in-transition deselect, 350 ms linear', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + expect(hold?.presentation()).toBe(0); + void hold?.animate(1, {startTime: 1000}); + const ms = list.deselect.navigationPopMs; + expect(hold?.presentation(1000)).toBe(0); + expect(hold?.presentation(1000 + ms / 2)).toBeCloseTo(0.5, 6); + expect(hold?.presentation(1000 + ms / 4)).toBeCloseTo(0.25, 6); + expect(hold?.presentation(1000 + ms)).toBe(1); + }); + + it('an interactive finish: the navigation’s spring from the scrubbed value, with its velocity', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.4); + expect(hold?.presentation()).toBe(0.4); + const spring = {response: 0.3, dampingRatio: 0.85}; + void hold?.animate(1, {spring, velocity: 2, startTime: 1000}); + const expected = solveSpring(toSpring(spring), { + from: 0.4, + to: 1, + velocity: 2, + }); + for (const t of [0, 0.05, 0.1, 0.2]) { + expect(hold?.presentation(1000 + t * 1000)).toBeCloseTo( + Math.min(1, Math.max(0, expected.value(t))), + 6, + ); + } + }); + + it("under reduced motion, every deselect takes the animator's cross-fade: no spring, done within 200 ms", () => { + // design:reduced-motion DR1 and DR2: the in-transition deselect, a + // navigation's spring and a caller's own long curve alike. + stubPointer('touch', {reducedMotion: true}); + const timings = [ + {}, + {spring: {response: 0.35, dampingRatio: 1}, velocity: 1}, + {durationMs: 3000, curve: timing.curves.move}, + ] as const; + for (const each of timings) { + const {control} = listRow(`Row ${timings.indexOf(each)}`); + const hold = holdSelection(control, {now}); + void hold?.animate(1, {...each, startTime: 1000}); + expect( + hold?.presentation(1000 + 100), + JSON.stringify(each), + ).toBeGreaterThan(0); + expect(hold?.presentation(1000 + 200), JSON.stringify(each)).toBe(1); + } + }); + + it("a fixed duration on a Bézier curve (a navigation's own fade)", () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const curve = timing.curves.move; + void hold?.animate(1, { + durationMs: timing.reducedMotionCrossFadeMs, + curve, + startTime: 1000, + }); + const ease = cubicBezier(curve); + const d = timing.reducedMotionCrossFadeMs; + expect(hold?.presentation(1000 + d * 0.3)).toBeCloseTo(ease(0.3), 6); + expect(hold?.presentation(1000 + d)).toBe(1); + }); + + it('keeps the in-transition deselect’s own clock and curve whatever a theme writes', () => { + // The deselect's clock and curve are internal constants: variables a + // theme's scope reports for them change nothing. + const theme: Record = { + '--motion-list-deselect-navigation-pop-ms': '700ms', + '--motion-list-deselect-navigation-pop-curve': + 'cubic-bezier(0.42, 0, 0.58, 1)', + }; + const computed = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + (element, pseudo) => { + const style = computed(element, pseudo); + if (!element.hasAttribute('data-astryx-theme')) { + return style; + } + // The theme's scope: only its role variables are read. + return { + getPropertyValue: (name: string) => + theme[name] ?? style.getPropertyValue(name), + } as unknown as CSSStyleDeclaration; + }, + ); + render( + createElement( + 'div', + {'data-astryx-theme': 'slow'}, + createElement( + List, + null, + createElement(ListItem, {label: 'Themed', onClick: () => {}}), + ), + ), + ); + const control = screen + .getByText('Themed') + .closest('li') + ?.querySelector('button') as HTMLElement; + const hold = holdSelection(control, {now}); + void hold?.animate(1, {startTime: 1000}); + const ms = list.deselect.navigationPopMs; + const ease = cubicBezier(list.deselect.navigationPopCurve); + expect(hold?.presentation(1000 + ms * 0.25)).toBeCloseTo(ease(0.25), 6); + expect(hold?.presentation(1000 + ms)).toBe(1); + // A caller's own timing is left as it is. + hold?.setProgress(0); + void hold?.animate(1, { + durationMs: 100, + curve: [0, 0, 1, 1], + startTime: 2000, + }); + expect(hold?.presentation(2050)).toBeCloseTo(0.5, 6); + }); + + it('a pop that starts from a scrubbed value takes its share of the clock', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.5); + void hold?.animate(1, {startTime: 1000}); + const half = list.deselect.navigationPopMs / 2; + expect(hold?.presentation(1000 + half / 2)).toBeCloseTo(0.75, 6); + expect(hold?.presentation(1000 + half)).toBe(1); + }); +}); + +describe('interruption, completion and cancel', () => { + it('resolves true when the deselect arrives', async () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const arrived = hold?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs; + vi.advanceTimersByTime(list.deselect.navigationPopMs); + await expect(arrived).resolves.toBe(true); + expect(hold?.presentation()).toBe(1); + }); + + it('a finger grabbing mid-flight stops it where it is: the promise resolves false', async () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + const arrived = hold?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs * 0.3; + // Where it is on screen, from the curve: the finger starts there. + expect(hold?.presentation()).toBeCloseTo(0.3, 6); + hold?.setProgress(hold.presentation()); + await expect(arrived).resolves.toBe(false); + expect(hold?.presentation()).toBeCloseTo(0.3, 6); + }); + + it('a completed pop deselects the row and drops the hold', () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + // Inert from here on. + hold?.end(false); + hold?.setProgress(0.5); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('a cancelled pop selects the row again at once and keeps the hold', async () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.setProgress(0.6); + const back = hold?.animate(0, { + spring: {response: 0.3, dampingRatio: 0.85}, + startTime: 1000, + }); + hold?.end(false); + await expect(back).resolves.toBe(false); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + expect(hold?.presentation()).toBe(0); + // The next pop still deselects it. + hold?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('a hold taken while the previous one deselects starts fully held, with no deselecting mark', () => { + const {row, control} = listRow(); + const first = holdSelection(control, {now}); + void first?.animate(1, {startTime: 1000}); + clock = 1000 + list.deselect.navigationPopMs * 0.4; + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + // Tapped again mid-deselect: a new push holds the row. + const second = holdSelection(control, {now}); + expect(second).not.toBeNull(); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + expect(second?.presentation()).toBe(0); + // The dividers beside it stay hidden: nothing marks it deselecting. + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + }); + + it("marks the row deselecting from the deselect's first frame until a cancel or the end", async () => { + const {row, control} = listRow(); + const hold = holdSelection(control, {now}); + // Covered: the dividers stay hidden. + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + hold?.setProgress(0.1); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + const back = hold?.animate(0, { + spring: {response: 0.3, dampingRatio: 0.85}, + startTime: 1000, + }); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + hold?.end(false); + await expect(back).resolves.toBe(false); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + void hold?.animate(1, {startTime: 1000}); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(true); + hold?.end(true); + expect(row.hasAttribute(DESELECTING_ATTRIBUTE)).toBe(false); + }); + + it('a second hold on the row takes over; the first goes inert', () => { + const {row, control} = listRow(); + const first = holdSelection(control, {now}); + const second = holdSelection(control, {now}); + first?.end(true); + expect(row.getAttribute(PRESSED_ATTRIBUTE)).toBe('held'); + second?.end(true); + expect(row.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + }); + + it('returns focus to the row’s own control', () => { + const {control} = listRow(); + const hold = holdSelection(control, {now}); + hold?.end(true); + hold?.focus({preventScroll: true}); + expect(document.activeElement).toBe(control); + }); + + it('returns focus with no keyboard ring after a finger’s pop, and with one after a key’s', () => { + __startInteractionModalityTrackingForTest(); + const {control} = listRow(); + const focus = vi.spyOn(control, 'focus'); + const hold = holdSelection(control, {now}); + hold?.end(true); + // A finger pressed Back: focus returns without `:focus-visible`. + document.dispatchEvent(new Event('pointerdown')); + hold?.focus({preventScroll: true}); + expect(focus).toHaveBeenLastCalledWith({ + preventScroll: true, + focusVisible: false, + }); + expect(document.activeElement).toBe(control); + // A key pressed Back: the browser shows the ring where focus went. + control.blur(); + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); + hold?.focus({preventScroll: true}); + expect(focus).toHaveBeenLastCalledWith({preventScroll: true}); + __resetInteractionModalityForTest(); + }); +}); diff --git a/packages/core/src/List/selectionHold.ts b/packages/core/src/List/selectionHold.ts new file mode 100644 index 0000000000000..3084be1e0f16b --- /dev/null +++ b/packages/core/src/List/selectionHold.ts @@ -0,0 +1,387 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file selectionHold.ts + * @input A list row under an activation target, the press layer's held state + * (the `cell` style of interactionOverlay.stylex.ts), the motion + * foundation's animator and Bézier solver (motion/), and the + * in-transition deselect clock and curve + * (motion/uikit/list.ts, internal constants), and the last input's + * modality (focusReturn.ts) for the focus it returns + * @output Exports `holdSelection`, which keeps a tapped list row selected + * while a navigation covers it and deselects it as the navigation pops + * (`SelectionHold`), plus its timing types + * @position Interaction utility. A navigation container (a stack) calls it + * with the target of the activation that pushed; Item and TreeList rows are + * the rows it finds. + * + * UIKit's reference (iOS 26.2): the row a user taps to push stays selected + * while the pushed screen covers it. The deselect starts when the pop does + * (the back button, or the edge swipe beginning) and runs inside the + * navigation transition, linearly over the transition's 0.35 s, so an + * interactive pop scrubs it with its progress. A cancelled pop re-selects the + * row at once. VoiceOver focus returns to the row. + * + * HOW IT PAINTS. The hold writes `data-astryx-press="held"` on the row, which + * shows the press layer (the row's `::before`) at full strength; the touch + * press controller never overwrites a value it did not write. The deselect is + * the layer's opacity, `1 − progress`, driven by the motion foundation's + * animator on the `::before` (Web Animations: the compositor runs it while + * script is busy, on the caller's clock). Its curves are evaluated in closed + * form, never read back from the DOM: `presentation` answers where the + * highlight is at a time from the curve itself, so a finger landing + * mid-animation starts from the value on screen. + * + * The deselect's clock and curve are the list table's internal constants + * (`list.deselect`): no theme variable reaches them. Under reduced motion + * every deselect that `animate` plays takes the animator's own Reduced + * Motion handling (`design:reduced-motion`): no spring, opacity only, 200 ms + * or less under a finger; the hold has no option to keep its curve. + * + * Progress, as the navigation measures it: 0 = the pushed screen covers the + * row, 1 = the row is revealed. Highlight = 1 − progress. + * + * SSR-safe: nothing runs at import time. + * + * SYNC: When modified, update: + * - /packages/core/src/List/selectionHold.test.ts + * - /packages/core/src/List/__tests__/ListSelectionHold.interaction.browser.spec.ts + * - /packages/core/src/utils/interactionOverlay.stylex.ts (the held arm) + */ + +import { + createAnimator, + type Animator, + type MotionCurve, +} from '../motion/animator'; +import {cubicBezier} from '../motion/bezier'; +import {list} from '../motion/uikit/list'; +import {focusForLastInput} from '../utils/focusReturn'; +import { + CELL_PRESS_PROFILE, + PRESSABLE_ATTRIBUTE, + PRESSED_ATTRIBUTE, +} from '../utils/pressFeedback'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; + +/** The press state a hold writes; owned by the hold, never by the controller. */ +export const HELD_PRESS_STATE = 'held'; + +/** + * Written on a held row once its deselect has begun (a pop's animation or an + * interactive pop's first frame) and removed by a cancel: a deselecting UIKit + * cell shows its separators again in the deselect's first frame, while its + * background is still fading, and hides them again when a cancel selects it. + */ +export const DESELECTING_ATTRIBUTE = 'data-astryx-deselecting'; + +interface StartTime { + /** + * Start on the document timeline (ms, `performance.now()` clock). Pass the + * transition's own start to ride its clock. Without one, a deselect from + * rest starts when the browser commits it, so a busy first frame delays it + * rather than skipping into it (motion/animator.ts, start time). + */ + readonly startTime?: number; +} + +/** + * UIKit's in-transition deselect: `list.deselect`, 350 ms linear. + */ +export type HoldTransitionTiming = StartTime; + +/** + * A spring, described the way the navigation describes its own: physically + * (`stiffness`, `damping`, `mass` 1 by default), by its perceptual duration + * and bounce (seconds; 0 settles without overshoot), or by its response and + * damping ratio (seconds; 1 settles without overshoot). + */ +export type HoldSpring = + | { + readonly mass?: number; + readonly stiffness: number; + readonly damping: number; + } + | {readonly duration: number; readonly bounce: number} + | {readonly response: number; readonly dampingRatio: number}; + +/** A cubic Bézier timing curve's two control points: `[x1, y1, x2, y2]`. */ +export type HoldCurve = readonly [number, number, number, number]; + +/** A spring, as the navigation's own (`velocity` in progress per second). */ +export interface HoldSpringTiming extends StartTime { + readonly spring: HoldSpring; + readonly velocity?: number; +} + +/** A fixed duration on a Bézier curve (Reduce Motion's cross-fade). */ +export interface HoldCurveTiming extends StartTime { + readonly durationMs: number; + readonly curve: HoldCurve; +} + +export type HoldTiming = + HoldTransitionTiming | HoldSpringTiming | HoldCurveTiming; + +export interface SelectionHold { + /** The row kept selected. */ + readonly element: HTMLElement; + /** + * Puts the deselect at `progress` (0 covered … 1 revealed) now: the + * per-frame write of an interactive pop. Stops a running `animate`, whose + * promise resolves false. + */ + setProgress(progress: number): void; + /** + * Moves the deselect to `to` from its presentation value: 1 when the pop + * completes, 0 when it cancels. With no spring or duration, UIKit's + * in-transition deselect (350 ms linear). Resolves true when it arrives, + * false when `setProgress`, another `animate` or `end` interrupts it. + */ + animate(to: 0 | 1, timing?: HoldTiming): Promise; + /** The deselect's progress at `time` (default now), from its curve. */ + presentation(time?: number): number; + /** + * Focuses the row's own control (the element a screen reader lands on), + * with a keyboard ring only when the user last used a keyboard. + */ + focus(options?: FocusOptions): void; + /** + * Ends the hold. `true`: the row was revealed for good (a completed pop, + * or the held screen left the stack another way); the row is deselected at + * once, with no animation of its own. `false`: the pop was cancelled; the + * row is selected again at once, and the hold stays for the next pop. + */ + end(completed: boolean): void; +} + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +function defaultNow(): number { + return typeof performance === 'undefined' ? Date.now() : performance.now(); +} + +/** + * A fixed duration on a Bézier curve, from `from` to `to`, as the + * animator plays it (seconds; velocity in units per second). + */ +function bezierCurve( + from: number, + to: number, + durationMs: number, + points: HoldCurve, +): MotionCurve { + const ease = cubicBezier(points); + const duration = Math.max(0, durationMs) / 1000; + const delta = to - from; + const progress = (t: number) => (duration <= 0 ? 1 : clamp01(t / duration)); + return { + duration, + at: t => from + delta * ease(progress(t)), + velocity: t => { + if (duration <= 0) { + return 0; + } + const x = progress(t); + const x0 = Math.max(0, x - 1e-4); + const x1 = Math.min(1, x + 1e-4); + return (delta * (ease(x1) - ease(x0))) / ((x1 - x0) * duration); + }, + }; +} + +/** + * The row's own control: the element focus and a screen reader land on. In a + * tree, that is the row's treeitem, which holds the tree's roving Tab stop, + * not a control inside it. + */ +function focusTarget(row: HTMLElement): HTMLElement { + const item = row.closest('[role="treeitem"]'); + if (item != null) { + return item; + } + if (row.matches('a[href], button, [tabindex]:not([tabindex="-1"])')) { + return row; + } + return ( + row.querySelector( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? row + ); +} + +/** + * The live hold of each row, by its retire function: a second hold on a row + * takes over from the first, which goes inert without touching the row. + */ +const holds = new WeakMap void>(); + +/** A finger is the primary pointer (TOUCH_QUERY, utils/touchPresentation.ts). */ +function isTouch(view: Window | null): boolean { + return ( + view != null && + typeof view.matchMedia === 'function' && + view.matchMedia(TOUCH_QUERY).matches + ); +} + +/** + * Keeps the list row under `target` selected while a navigation covers it, + * and returns the handle that deselects it as the navigation pops. Null when + * `target` is not inside an enabled list row (an Item or TreeList row with an + * action), and under a mouse: a hold is part of the one touch model, under + * any finger (a coarse primary pointer, iOS and Android alike); with a mouse + * the press releases at the lift as before and the caller's `hold?.` calls + * do nothing. + * + * @example + * ``` + * // In the same task as the activation that pushes: + * const hold = holdSelection(event.target); + * // A programmatic pop, on the transition's own clock: + * await hold?.animate(1, {startTime}); + * hold?.end(true); + * hold?.focus({preventScroll: true}); + * ``` + */ +export function holdSelection( + target: EventTarget | null | undefined, + options: {readonly now?: () => number} = {}, +): SelectionHold | null { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return null; + } + const row = target.closest( + `[${PRESSABLE_ATTRIBUTE}="${CELL_PRESS_PROFILE}"]`, + ); + if ( + row == null || + !isTouch(row.ownerDocument.defaultView) || + row.matches(':disabled, [aria-disabled="true"]') || + row.closest('[aria-disabled="true"]') != null + ) { + return null; + } + // A new hold takes the row from any earlier one, even mid-deselect: it + // starts fully held, so nothing marks the row deselecting and the + // dividers beside it stay hidden. + holds.get(row)?.(); + row.removeAttribute(DESELECTING_ATTRIBUTE); + + const now = options.now ?? defaultNow; + let ended = false; + /** + * The layer's opacity (the highlight), once something moves it; until + * then, and after a cancel, the held state shows it at 1. + */ + let animator: Animator | null = null; + const layer = () => { + animator ??= createAnimator(row, { + pseudoElement: '::before', + initial: {opacity: 1}, + now, + }); + return animator; + }; + const drop = () => { + animator?.dispose(); + animator = null; + }; + const retire = () => { + drop(); + ended = true; + }; + + const presentation = (time = now()): number => + animator == null ? 0 : clamp01(1 - animator.get('opacity', time)); + + const hold: SelectionHold = { + element: row, + + setProgress(progress) { + if (!ended) { + row.setAttribute(DESELECTING_ATTRIBUTE, ''); + layer().set({opacity: 1 - clamp01(progress)}); + } + }, + + async animate(to, timing = {}) { + if (ended) { + return false; + } + // The transition's start when the caller has one; otherwise none, so + // a deselect from rest starts with the browser's commit. + const at = timing.startTime; + const start = at == null ? {} : {at}; + const target = 1 - to; + if (to === 1) { + row.setAttribute(DESELECTING_ATTRIBUTE, ''); + } else { + row.removeAttribute(DESELECTING_ATTRIBUTE); + } + const motion = layer(); + if ('spring' in timing) { + return motion.animate( + {opacity: target}, + { + spring: timing.spring, + // Progress per second; the highlight moves the other way. + ...(timing.velocity == null + ? {} + : {velocity: {opacity: -timing.velocity}}), + }, + start, + ).finished; + } + const from = motion.get('opacity', at ?? now()); + const durationMs = + 'durationMs' in timing + ? timing.durationMs + : // The in-transition deselect covers the whole way in its clock; + // a partial way (a pop started from a scrubbed value) takes its + // share. + list.deselect.navigationPopMs * Math.abs(target - from); + const points = + 'curve' in timing ? timing.curve : list.deselect.navigationPopCurve; + return motion.play( + {opacity: bezierCurve(from, target, durationMs, points)}, + start, + ).finished; + }, + + presentation, + + focus(focusOptions) { + if (row.isConnected) { + // The ring follows the last input (architecture:interaction-modality + // INV1): after a finger's pop, focus lands with no keyboard ring, as + // UIKit draws none; after a keyboard's, the ring shows where it went. + focusForLastInput(focusTarget(row), focusOptions); + } + }, + + end(completed) { + if (ended) { + return; + } + drop(); + row.removeAttribute(DESELECTING_ATTRIBUTE); + if (completed) { + ended = true; + if (holds.get(row) === retire) { + holds.delete(row); + } + if (row.getAttribute(PRESSED_ATTRIBUTE) === HELD_PRESS_STATE) { + row.removeAttribute(PRESSED_ATTRIBUTE); + } + } else { + // Selected again at once; the hold stays for the next pop. + row.setAttribute(PRESSED_ATTRIBUTE, HELD_PRESS_STATE); + } + }, + }; + + row.setAttribute(PRESSED_ATTRIBUTE, HELD_PRESS_STATE); + holds.set(row, retire); + return hold; +} diff --git a/packages/core/src/RadioList/RadioListItem.tsx b/packages/core/src/RadioList/RadioListItem.tsx index 683f7dbee9280..d701adf00b727 100644 --- a/packages/core/src/RadioList/RadioListItem.tsx +++ b/packages/core/src/RadioList/RadioListItem.tsx @@ -31,6 +31,7 @@ import {usePressFeedback} from '../hooks/usePressFeedback'; import {useIndicator} from '../Indicator'; import {Item, type ItemAlign} from '../Item'; import {themeProps} from '../utils/themeProps'; +import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; // The touch press's paint, declared by the shared overlay styles on the // element the controller writes to (`pressedAlpha`) at the press's strength, @@ -148,6 +149,10 @@ const rowStyles = stylex.create({ // strength still lands on the row, which is where the layer over the // circle reads it. backgroundImage: 'none', + // ...and the row's touch press layer (Item's `cell`): no box. + '::before': { + content: 'none', + }, }, // The mouse press (a finger's lives on `data-astryx-press`): the row is // pressed, but not through a nested link or button, which keep their own @@ -395,6 +400,10 @@ export function RadioListItem({ // the painting row element (Item), the same element that carries the // theme target, so a theme's hover styling stays in step with the tint. !isDisabled && indicatorScope, + // The row owns the strength the circle's layer reads, and its + // release (Item's touch layer drops the row's own under a finger: + // this row has no layer of its own, so it keeps the release). + !isDisabled && interactionOverlayStyles.pressedAlpha, // Restore the bare default look: zero Item's padding/radius and its // interactive hover/press background. Applied after Item's own base // styles so it wins within the base layer; a `radio-list-item` theme diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 97edf6064d6d6..adc99b1974bd7 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -4,7 +4,8 @@ /** * @file TreeListItem.tsx - * @input Uses React, StyleX, theme tokens, TreeListBranches + * @input Uses React, StyleX, theme tokens, TreeListBranches, the shared press + * overlays and the compositor press layer * @output Exports TreeListItem component (internal, not publicly exported) * @position Internal implementation; consumed by TreeList.tsx * @@ -26,7 +27,10 @@ import { } from '../theme/tokens.stylex'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; +import {cellPressableProps} from '../utils/pressFeedback'; +import {rowPressLayerStyles} from '../utils/rowPressLayer'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import {useInteractionModalityTracking} from '../utils/interactionModality'; import { hasInteractiveAncestor, useClickableContainer, @@ -406,6 +410,9 @@ export function TreeListItem({ isTabbable, }: TreeListItemInternalProps) { const pressable = usePressFeedback(); + // A selection hold returns focus to this row after a pop; its ring follows + // how the user last interacted (List/selectionHold.ts). + useInteractionModalityTracking(); const t = useTranslator(); const labelId = useId(); const descriptionId = useId(); @@ -664,7 +671,7 @@ export function TreeListItem({
path.join(SRC, file))); + return files.map(file => path.relative(SRC, file)); +} + +describe('internal modules', () => { + it('declare no @experimental: only an experimental public API carries it', () => { + const offenders: string[] = []; + for (const file of checkedFiles()) { + fs.readFileSync(path.join(SRC, file), 'utf8') + .split('\n') + .forEach((line, index) => { + if (/@experimental\b/.test(line)) { + offenders.push(`${file}:${index + 1}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it('checks the directories and files it names', () => { + expect(fs.existsSync(path.join(SRC, 'motion'))).toBe(true); + expect(fs.existsSync(path.join(SRC, 'gestures'))).toBe(true); + expect(sourcesUnder(path.join(SRC, 'motion')).length).toBeGreaterThan(5); + for (const file of INTERNAL_FILES) { + expect(fs.existsSync(path.join(SRC, file)), file).toBe(true); + } + }); + + it('leaves the public utils entry alone: of src/utils, only its package-private files are checked', () => { + const utils = checkedFiles().filter(file => + file.startsWith('utils' + path.sep), + ); + expect(utils).toEqual([path.join('utils', 'touchPresentation.ts')]); + }); +}); diff --git a/packages/core/src/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts index 352f450af397d..9ed3a6447b414 100644 --- a/packages/core/src/__tests__/pressState.ts +++ b/packages/core/src/__tests__/pressState.ts @@ -179,7 +179,23 @@ export function declaresPressPaint(el: Element): boolean { /** `.2s`, `200ms`, `0.2s` → 200. */ export function durationToMs(value: string): number { - const match = value.trim().match(/^([\d.]+)(ms|s)$/); + const text = value.trim(); + // StyleX folds a constant cap into `calc(
', + ); + const consumer = new RecordingConsumer(); + detachers.push(host.attachConsumer(button, consumer)); + const clicks: number[] = []; + button.addEventListener('click', () => clicks.push(1)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + // The program scrolls (a transcript pinned to its end, scrollIntoView). + scrollable(list, {scrollTop: 50, scrollHeight: 2000, clientHeight: 300}); + list.dispatchEvent(new Event('scroll')); + expect(consumer.events.some(event => event.startsWith('cancelled'))).toBe( + false, + ); + const end = touch('touchend', button, [finger], [], 120); + expect(end.defaultPrevented).toBe(false); + expect(consumer.events.at(-1)).toMatch(/^ended/); + }); + + it("cancels a finger's moves only when a claimed pen's own stylus touch moves, never a finger nobody owns", () => { + const {pad, list} = mount('
'); + const drawing = new PanGestureRecognizer({name: 'draw'}); + detachers.push( + host.attach(pad, drawing), + host.attach( + list, + new PanGestureRecognizer({name: 'other', direction: 'horizontal'}), + ), + ); + for (const [x, time] of [ + [0, 0], + [5, 16], + [30, 32], + ] as const) { + pointer(time === 0 ? 'pointerdown' : 'pointermove', pad, { + id: 9, + pointerType: 'pen', + x, + y: 0, + buttons: 1, + time, + }); + } + expect(drawing.state).toBe('began'); + // A finger on the list, which no recognizer owns: its moves scroll. + const finger = {id: 4, x: 10, y: 10}; + touch('touchstart', list, [finger], [finger], 40); + const move = touch( + 'touchmove', + list, + [{...finger, y: 40}], + [{...finger, y: 40}], + 56, + ); + expect(move.defaultPrevented).toBe(false); + // The pen's own stylus touch is held: the browser must not pan with it. + const stylus = new Event('touchmove', {bubbles: true, cancelable: true}); + const pen = { + identifier: 77, + clientX: 31, + clientY: 0, + target: pad, + touchType: 'stylus', + }; + Object.defineProperty(stylus, 'changedTouches', {value: [pen]}); + Object.defineProperty(stylus, 'touches', {value: [pen]}); + Object.defineProperty(stylus, 'timeStamp', {value: 60}); + pad.dispatchEvent(stylus); + expect(stylus.defaultPrevented).toBe(true); + pointer('pointerup', pad, { + id: 9, + pointerType: 'pen', + x: 31, + y: 0, + time: 80, + }); + }); + + it('keeps every touchmove listener passive on a finger nothing can claim as it moves', () => { + const {taps, quiet} = mount('
'); + class Watcher extends PanGestureRecognizer { + override get claimsTouches(): boolean { + return false; + } + } + detachers.push( + host.attach(taps, new TapGestureRecognizer({name: 'tap'})), + host.attachConsumer(taps, new RecordingConsumer()), + host.attach(quiet, new Watcher({name: 'watcher'})), + ); + for (const element of [taps, quiet]) { + const added = vi.spyOn(element, 'addEventListener'); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', element, [finger], [finger], 0); + const blocking = added.mock.calls.filter( + ([type, , options]) => + type === 'touchmove' && + (options as AddEventListenerOptions | undefined)?.passive === false, + ); + expect(blocking).toHaveLength(0); + touch('touchend', element, [finger], [], 50); + } + }); + + it('follows the flat tree: content slotted into a shadow scroller scrolls with it', () => { + const {host: shell, item} = mount( + '
row
', + ); + const shadow = shell.attachShadow({mode: 'open'}); + shadow.innerHTML = + '
'; + const scroller = shadow.getElementById('scroller') as HTMLElement; + scrollable(scroller, { + scrollTop: 200, + scrollHeight: 2000, + clientHeight: 300, + }); + const probe = createNativeScrollProbe(item); + expect(probe.canScroll(0, 10)).toBe(true); + expect(probe.isInsideScroller).toBe(true); + }); + + it("writes a touch-action no wider than the caller's own", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + vi.advanceTimersByTime(16); + // manipulation (the hold's) with none, and pan-y pinch-zoom (the + // pan's) with pan-y, allow the caller's own: its rule stays in force. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(window.getComputedStyle(strip).touchAction).toBe('pan-y'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('marks an element with its derived touch-action as it attaches, under a rule any author rule outranks, with no inline write and no style read (spec:AST-094 FR18, DEC-8)', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + const set = vi.spyOn(box.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(box, new PanGestureRecognizer({direction: 'horizontal'})), + ); + expect(box.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(hostRules(document)).toContain( + ':where([data-astryx-touch-action="pan-y pinch-zoom"])', + ); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + expect(read).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("keeps an author rule in force from the first frame, and writes the intersection inline only where it is narrower than the author's (spec:AST-094 FR18)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {both, strip} = mount( + '
', + ); + const setBoth = vi.spyOn(both.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + detachers.push( + host.attach(both, new PanGestureRecognizer({direction: 'horizontal'})), + host.attach(strip, new PanGestureRecognizer({direction: 'horizontal'})), + ); + // Before the host reads anything, the author's value stands. + expect(both.getAttribute('data-astryx-touch-action')).toBe( + 'pan-y pinch-zoom', + ); + expect(window.getComputedStyle(both).touchAction).toBe('pan-x pan-y'); + expect(setBoth).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + vi.advanceTimersByTime(16); + // pan-y pinch-zoom with pan-x pan-y allows pan-y: narrower than the + // author's, so it is written. + expect(setBoth).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // With pan-y it is the author's own value: nothing to write. + expect(setStrip).not.toHaveBeenCalledWith( + 'touch-action', + expect.anything(), + ); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('removes the mark when the last recognizer detaches', () => { + const {box} = mount('
'); + const detach = host.attach( + box, + new PanGestureRecognizer({direction: 'horizontal'}), + ); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(true); + detach(); + expect(box.hasAttribute('data-astryx-touch-action')).toBe(false); + }); + + it('adds the rule to the shadow root an element is in', () => { + const {box} = mount('
'); + const shadow = box.attachShadow({mode: 'open'}); + const inner = document.createElement('div'); + shadow.append(inner); + detachers.push(host.attach(inner, new LongPressGestureRecognizer())); + expect(inner.getAttribute('data-astryx-touch-action')).toBe('manipulation'); + expect(hostRules(shadow)).toContain( + ':where([data-astryx-touch-action="manipulation"])', + ); + }); + + it("is never wider than the author's touch-action, at any frame (spec:AST-094 FR18, DEC-8)", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger} = mount( + '
', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + ); + // The author's none outranks the host's manipulation from the attach + // on, and the read finds nothing to narrow. + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + vi.advanceTimersByTime(16); + expect(window.getComputedStyle(trigger).touchAction).toBe('none'); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('gives an element attached while on screen its touch-action in the attach itself', () => { + const restore = touchActionFromData(); + try { + const {box} = mount('
'); + detachers.push(host.attach(box, new TapGestureRecognizer({name: 'tap'}))); + expect(window.getComputedStyle(box).touchAction).toBe('manipulation'); + } finally { + restore(); + } + }); + + it("reads no computed style while attaching: a caller's inline touch-action applies at once, a stylesheet's is read in the next frame", () => { + vi.useFakeTimers({toFake: ['setTimeout', 'requestAnimationFrame']}); + const restore = touchActionFromData(); + try { + const {trigger, strip} = mount( + '
', + ); + // jsdom keeps no touch-action in a style: this stands in for the + // caller's inline `touch-action: pan-y`. + vi.spyOn(strip.style, 'getPropertyValue').mockImplementation(name => + name === 'touch-action' ? 'pan-y' : '', + ); + const set = vi.spyOn(trigger.style, 'setProperty'); + const setStrip = vi.spyOn(strip.style, 'setProperty'); + const read = vi.mocked(window.getComputedStyle); + read.mockClear(); + detachers.push( + host.attach(trigger, new LongPressGestureRecognizer({name: 'hold'})), + host.attach( + strip, + new PanGestureRecognizer({name: 'h', direction: 'horizontal'}), + ), + ); + // Attaching forces no style recalc over whatever just mounted. + expect(read).not.toHaveBeenCalled(); + // The caller's inline value: pan-y pinch-zoom ∩ pan-y, at once. + expect(setStrip).toHaveBeenLastCalledWith('touch-action', 'pan-y'); + // A stylesheet's value: read once in the next frame, where none is + // already no wider than both allow. + vi.advanceTimersByTime(16); + expect(read).toHaveBeenCalledTimes(1); + expect(set).not.toHaveBeenCalledWith('touch-action', expect.anything()); + } finally { + restore(); + vi.useRealTimers(); + } + }); + + it('releases the same velocity for one stroke from a finger, a pen and a mouse (spec:AST-100 FR1, FR4)', () => { + const velocities: number[] = []; + for (const kind of ['touch', 'pen', 'mouse'] as const) { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: kind}); + let released = 0; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity().x; + } + }); + const detach = host.attach(box, pan); + const at = (x: number, time: number, type: 'down' | 'move' | 'up') => { + if (kind === 'touch') { + const finger = {id: 1, x, y: 0}; + touch( + type === 'down' + ? 'touchstart' + : type === 'move' + ? 'touchmove' + : 'touchend', + box, + [finger], + type === 'up' ? [] : [finger], + time, + ); + } else { + pointer( + type === 'down' + ? 'pointerdown' + : type === 'move' + ? 'pointermove' + : 'pointerup', + box, + { + id: 5, + pointerType: kind, + x, + y: 0, + buttons: type === 'up' ? 0 : 1, + time, + }, + ); + } + }; + // Each stroke later than the last: the arena's clock never goes back. + const start = 1000 * (velocities.length + 1); + at(0, start, 'down'); + for (let step = 1; step <= 10; step++) { + at(step * 12, start + step * 8, 'move'); + } + at(120, start + 80, 'up'); + velocities.push(released); + detach(); + } + expect(velocities[0]).toBeGreaterThan(0); + expect(velocities[1]).toBeCloseTo(velocities[0], 6); + expect(velocities[2]).toBeCloseTo(velocities[0], 6); + }); + + describe('finger scrolls', () => { + it('reads a scroll while a finger that landed inside is down as the finger’s, until the scroller’s scrollend', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The lift starts the momentum: still the finger's scroll. + touch('touchend', row, [finger], [], 120); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('forgets a finger scroll in progress when the watch stops listening, so a later program scroll is not the finger’s', () => { + const pointer = pointerQuery(true); + detachers.push(pointer.restore); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + // The pointer turns fine mid-scroll: the watch stops listening, and its + // scrollend never comes. + pointer.set(false); + pointer.set(true); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads a scroll with no finger down as the program’s, even right after a tap', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + // A tap whose click scrolls the list (a scrollTo after the lift). + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + touch('touchend', row, [finger], [], 80); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('reads the page’s own scroll on the document', () => { + const {row} = mount('
'); + detachers.push(watchFingerScrolls(host, document)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + document.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, document)).toBe(true); + touch('touchend', row, [finger], [], 120); + document.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, document)).toBe(false); + }); + + it('does not count a finger down outside the scroller', () => { + const {list, other} = mount( + '
', + ); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', other, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('keeps a scroll that ends while the finger still rests the finger’s until the lift', () => { + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + list.dispatchEvent(new Event('scrollend')); + expect(isFingerScroll(host, list)).toBe(true); + // Lifting without moving the list again ends it. + touch('touchend', row, [finger], [], 300); + expect(isFingerScroll(host, list)).toBe(false); + }); + + it('ends a finger’s scroll after a quiet spell where the browser sends no scrollend', () => { + vi.useFakeTimers(); + const own = Object.getOwnPropertyDescriptor(window, 'onscrollend'); + const proto = Object.getPrototypeOf(window) as object; + const inherited = Object.getOwnPropertyDescriptor(proto, 'onscrollend'); + Reflect.deleteProperty(window, 'onscrollend'); + Reflect.deleteProperty(proto, 'onscrollend'); + try { + expect('onscrollend' in window).toBe(false); + const {list, row} = mount('
'); + detachers.push(watchFingerScrolls(host, list)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + touch('touchend', row, [finger], [], 120); + // The momentum keeps scrolling: still the finger's. + vi.advanceTimersByTime(100); + list.dispatchEvent(new Event('scroll')); + vi.advanceTimersByTime(100); + expect(isFingerScroll(host, list)).toBe(true); + // Quiet for the fallback spell: the scroll has ended. + vi.advanceTimersByTime(60); + expect(isFingerScroll(host, list)).toBe(false); + // A program's scroll afterwards is not the finger's. + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + } finally { + if (inherited != null) { + Object.defineProperty(proto, 'onscrollend', inherited); + } + if (own != null) { + Object.defineProperty(window, 'onscrollend', own); + } + vi.useRealTimers(); + } + }); + + it('forgets a scroller once nobody watches it', () => { + const {list, row} = mount('
'); + const stop = watchFingerScrolls(host, list); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', row, [finger], [finger], 0); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(true); + stop(); + expect(isFingerScroll(host, list)).toBe(false); + list.dispatchEvent(new Event('scroll')); + expect(isFingerScroll(host, list)).toBe(false); + }); + }); + + it('drives mouse input through Pointer Events', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + let released = {x: 0, y: 0}; + pan.addListener(r => { + if (r.state === 'ended') { + released = r.velocity(); + } + }); + detachers.push(host.attach(box, pan)); + pointer('pointerdown', box, { + pointerType: 'mouse', + x: 0, + y: 0, + buttons: 1, + time: 0, + }); + for (let t = 8; t <= 96; t += 8) { + pointer('pointermove', box, { + pointerType: 'mouse', + x: t * 2, + y: 0, + buttons: 1, + time: t, + }); + } + pointer('pointerup', box, {pointerType: 'mouse', x: 192, y: 0, time: 104}); + expect(pan.state).toBe('possible'); + expect(released.x).toBeCloseTo(2000, 0); + }); + + it('cancels the context menu while a long press owns the touch', () => { + const {box} = mount('
'); + const press = new LongPressGestureRecognizer({name: 'press'}); + detachers.push(host.attach(box, press)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + const before = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(before); + expect(before.defaultPrevented).toBe(false); + host.arena.advanceTime(500); + expect(press.state).toBe('began'); + const during = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + box.dispatchEvent(during); + expect(during.defaultPrevented).toBe(true); + }); + + it('holds the click of a tap a double tap may claim, and replays it when the double tap fails', () => { + const {row, button} = mount( + '
', + ); + const double = new TapGestureRecognizer({ + name: 'double', + numberOfTapsRequired: 2, + }); + detachers.push(host.attach(row, double)); + const clicks: {target: EventTarget | null; detail: number}[] = []; + button.addEventListener('click', event => + clicks.push({target: event.target, detail: event.detail}), + ); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', button, [finger], [finger], 0); + const end = touch('touchend', button, [finger], [], 60); + expect(end.defaultPrevented).toBe(true); + expect(clicks).toEqual([]); + const interval = UIKIT_GESTURES.tap.maximumIntervalBetweenTaps.value; + host.arena.advanceTime(60 + interval); + expect(clicks).toEqual([{target: button, detail: 1}]); + }); + + it('collects recognizers across shadow roots', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(outer, tap)); + const finger = {id: 1, x: 5, y: 5, target: inner}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root its finger: a touch reaches the page retargeted to the shadow host, and the composed path names what it landed on', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + // At the document, a touch's target is the shadow host, as the event's. + const finger = {id: 1, x: 5, y: 5, target: outer}; + touch('touchstart', inner, [finger], [finger], 0); + touch('touchend', inner, [finger], [], 50); + expect(actions).toEqual(['ended']); + }); + + it('gives a recognizer inside a shadow root a mouse press too, the pointer event retargeted to the shadow host at the document', () => { + const {outer} = mount('
'); + const shadow = outer.attachShadow({mode: 'open'}); + shadow.innerHTML = 'x'; + const inner = shadow.getElementById('inner') as HTMLElement; + const tap = new TapGestureRecognizer({name: 'tap'}); + const actions: string[] = []; + tap.addListener(r => actions.push(r.state)); + detachers.push(host.attach(inner, tap)); + pointer('pointerdown', inner, { + pointerType: 'mouse', + x: 5, + y: 5, + buttons: 1, + time: 0, + }); + pointer('pointerup', inner, {pointerType: 'mouse', x: 5, y: 5, time: 50}); + expect(actions).toEqual(['ended']); + }); + + it('drives hover recognizers from a hovering mouse', () => { + const {box, other} = mount('
'); + const hover = new HoverGestureRecognizer(); + const states: string[] = []; + hover.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, hover)); + pointer('pointermove', box, {pointerType: 'mouse', x: 1, y: 1, time: 0}); + pointer('pointermove', box, {pointerType: 'mouse', x: 2, y: 1, time: 16}); + pointer('pointermove', other, { + pointerType: 'mouse', + x: 300, + y: 1, + time: 32, + }); + expect(states).toEqual(['began', 'changed', 'ended']); + // A finger never hovers. + pointer('pointermove', box, {pointerType: 'touch', x: 1, y: 1, time: 48}); + expect(states).toHaveLength(3); + }); + + it('cancels every touch in flight when the window loses focus', () => { + const {box} = mount('
'); + const pan = new PanGestureRecognizer({name: 'pan'}); + const states: string[] = []; + pan.addListener(r => states.push(r.state)); + detachers.push(host.attach(box, pan)); + const finger = {id: 1, x: 10, y: 10}; + touch('touchstart', box, [finger], [finger], 0); + touch('touchmove', box, [{...finger, x: 40}], [{...finger, x: 40}], 16); + window.dispatchEvent(new Event('blur')); + expect(states).toEqual(['began', 'cancelled']); + }); + + it('remembers a finger the window blur cancelled until it lifts, and refuses that lift its click: nothing acts (spec:AST-094 FR28)', () => { + const {box, page} = mount('

text

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

plain

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

plain

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

row

+
+ ) : ( +
+

row

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

row

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