diff --git a/.changeset/action-group-tracker-one-writer.md b/.changeset/action-group-tracker-one-writer.md new file mode 100644 index 0000000000000..381e886486e0a --- /dev/null +++ b/.changeset/action-group-tracker-one-writer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The internal action-group tracker paints its highlight through the one press writer in `utils/pressFeedback.ts`, as the document's press controller does, so neither cuts the other's fade short and a `held` paint is left alone. No component uses the tracker yet, and no package entry point exports it. + +@vjeux diff --git a/.changeset/alertdialog-input-hold.md b/.changeset/alertdialog-input-hold.md new file mode 100644 index 0000000000000..e96cee26d6d02 --- /dev/null +++ b/.changeset/alertdialog-input-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] AlertDialog: under a finger, its presentation and dismissal hold input through the shared Layer input hold instead of its own guards. While it fades out its content is inert and focus leaves it, then returns to the opener as before. + +@vjeux diff --git a/.changeset/alertdialog-touch-action-tracking.md b/.changeset/alertdialog-touch-action-tracking.md new file mode 100644 index 0000000000000..1bf5c0fa668c6 --- /dev/null +++ b/.changeset/alertdialog-touch-action-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] AlertDialog: its two actions are tracked as one group under every pointer, a finger, a pen or a mouse, the way iOS tracks an alert's actions. A press that lands on one action and slides to the other now runs the action it lifts on. Before, iOS Safari ran the action the finger first touched (Delete, when the finger slid to Cancel), and other browsers ran nothing and left the alert open. The pressed action highlights as soon as it is pressed instead of 150 ms later, the highlight follows the pointer between the actions, and lifting off both still runs nothing; a second finger or the window losing focus ends the press with nothing run. Only the actions are tracked: a press on the title or the message is not, and their text stays selectable with a mouse. The keyboard and assistive technology keep each button's own activation. + +@vjeux diff --git a/.changeset/alertdialog-touch-motion.md b/.changeset/alertdialog-touch-motion.md new file mode 100644 index 0000000000000..bd5e80fd711b4 --- /dev/null +++ b/.changeset/alertdialog-touch-motion.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] AlertDialog: under a finger the alert appears and leaves the way an iOS alert does. It scales down into place from 1.2 while it and the dimming fade in, and it fades out with the dimming when it closes, on the alert's measured spring; the animation keeps running when the page is busy. While it appears a touch does nothing (keys and assistive technology still act), and while it leaves nothing reaches it: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only cross-fades. Defaults under a finger: on a phone (a compact width) the width with no `width` prop goes from 400 px to UIKit's 320 px in every theme, and this motion replaces the Dialog's entrance (rising 16 px and scaling up from 0.95 while it fades in) and its exit. With a mouse nothing changes. A `width` you pass still wins on a phone. The actions are tracked as one group under every pointer. + +@vjeux diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..95611b3f4619c --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. The `{stylexOptions}` form now passes StyleX `treeshakeCompensation: true` by default (before, its babel pass ran with StyleX's default, `false`), as the plugin form already does; pass `treeshakeCompensation: false` in `stylexOptions` to restore the old behavior. + +@vjeux diff --git a/.changeset/click-guard.md b/.changeset/click-guard.md new file mode 100644 index 0000000000000..7376b26fef796 --- /dev/null +++ b/.changeset/click-guard.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted, swallowing it for 400ms or until the next press. Keyboard and assistive-technology clicks (`detail` 0) always pass. + +@vjeux diff --git a/.changeset/dropdownmenu-action-sheet-card.md b/.changeset/dropdownmenu-action-sheet-card.md new file mode 100644 index 0000000000000..4a0a6f59f531f --- /dev/null +++ b/.changeset/dropdownmenu-action-sheet-card.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] On a compact touch screen (a phone, iOS or Android), DropdownMenu's `presentation="bottom-sheet"` with plain actions presents the way iOS 26 presents an action sheet: a centered, alert-style card that scales down from 1.2 and fades in with its dimming on the alert's spring, holds the actions and Cancel in one stack, tracks a finger across the actions as one group (the lift runs the action under it), and dismisses with a fade, on Cancel, Escape or a tap outside, returning focus to the trigger. A menu with sub-menus, links, rich labels, descriptions, end content, rows that stay open, a header or a footer keeps the bottom sheet, as do larger screens and mouse presentations. + +@vjeux diff --git a/.changeset/dropdownmenu-submenu-in-place.md b/.changeset/dropdownmenu-submenu-in-place.md new file mode 100644 index 0000000000000..6d6572f6a887f --- /dev/null +++ b/.changeset/dropdownmenu-submenu-in-place.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] On a phone, a DropdownMenu sub-menu opens in place as UIMenu's does on iOS 26. The sub-menu's card grows down from the row it opened from, and the menu's box grows with it from the edge it is anchored by. The parent's rows stay under the card, faded to half and scaled to 97%. The sub-menu's header keeps the row's label, now semibold, with its chevron turned down, and is named by that label and then "Back to ". Going back reverses it. The box plays UIKit's sub-menu springs. Under Reduce Motion the views cross-fade. + +@vjeux diff --git a/.changeset/dropdownmenu-uimenu-placement.md b/.changeset/dropdownmenu-uimenu-placement.md new file mode 100644 index 0000000000000..aaa5ee0fcdbab --- /dev/null +++ b/.changeset/dropdownmenu-uimenu-placement.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] DropdownMenu and MoreMenu opened with a mouse no longer start the touch presentation's motion code; on a phone the menu keeps opening beside its trigger, as on a larger screen. + +@vjeux diff --git a/.changeset/dropdownmenu-uimenu-touch.md b/.changeset/dropdownmenu-uimenu-touch.md new file mode 100644 index 0000000000000..d9e6fa36685c3 --- /dev/null +++ b/.changeset/dropdownmenu-uimenu-touch.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger, on every touch device, DropdownMenu and MoreMenu grow out of their trigger on UIKit's open spring and collapse back into it on the close spring, as UIMenu does. Both run on the compositor and can be interrupted either way. Under Reduce Motion the menu fades in place (the animator's policy). A pan that starts outside the open menu dismisses it as the page scrolls, and focus that was in the menu returns to the trigger. Under a mouse, or with `presentation="popover"`, the menu keeps the layer's entrance and the theme's motion. + +@vjeux diff --git a/.changeset/gesture-arena-moving-cancel.md b/.changeset/gesture-arena-moving-cancel.md new file mode 100644 index 0000000000000..35dbb9e8e9ad6 --- /dev/null +++ b/.changeset/gesture-arena-moving-cancel.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a claimed gesture cancelled while one of its fingers is moving keeps that moving finger claimed until it lifts, so the page never takes the drag over midway; a resting finger is still given back when its gesture is cancelled. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..1930e34496d7e --- /dev/null +++ b/.changeset/gesture-browser-negotiation.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: touch-action derivation, the native scroll probe and haptics for the gesture system. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-hook-fixes.md b/.changeset/gesture-hook-fixes.md new file mode 100644 index 0000000000000..87f08a0948e31 --- /dev/null +++ b/.changeset/gesture-hook-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture hooks call nothing back after unmount; `usePressTracker` brakes with the host by default and keeps one tracker, so an option change or a new ref never drops a press in progress; the recognizer's arena plumbing is `protected`. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-fixes.md b/.changeset/gesture-host-fixes.md new file mode 100644 index 0000000000000..2fc0915c974a6 --- /dev/null +++ b/.changeset/gesture-host-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture host fixes from review. A page scroll under a still finger keeps the press and its click; watchers and touches nothing can claim get passive listeners only; a claimed pen holds only its own stylus touches; an element's own `touch-action` is never widened; slotted content scrolls with its shadow-tree scroller. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host-owner.md b/.changeset/gesture-host-owner.md new file mode 100644 index 0000000000000..ea6ee44bda14a --- /dev/null +++ b/.changeset/gesture-host-owner.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture host becomes the one document-level touch owner: the finger count, touch gates, and a kept press for continuous-tracking controls, whose element gets `touch-action: manipulation` where the pointer is coarse. A scroll lock's page hold and the finger-scroll watch keep their own listeners and ask the host only about the touches it owns. A second finger fails a one-finger pan that has not claimed, so the page can pinch. A scroll-edge pan holds a lone finger from the first move that pulls away from an edge every scroller under it is at, so those moves are cancelled before it begins; with a second finger anywhere on the screen the page keeps its pinch. The host follows the primary pointer when it changes; what it attaches under a fine pointer is in the every-pointer changeset (`gestures-every-pointer.md`). A press tracker whose hold acts (a stepper's hold-repeat) can bring a selection lock for its presses, as a recognizer can. Internal, for the touch components that adopt them; not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-host.md b/.changeset/gesture-host.md new file mode 100644 index 0000000000000..eb9f2fec42db2 --- /dev/null +++ b/.changeset/gesture-host.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's DOM host (claims, click suppression, the browser's takeover, the pointer behind a contact). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-pan.md b/.changeset/gesture-pan.md index 12eb02fc42391..908da1b367575 100644 --- a/.changeset/gesture-pan.md +++ b/.changeset/gesture-pan.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the pan gesture recognizer, with UIKit's measured hysteresis and velocity filter. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-press-tracking.md b/.changeset/gesture-press-tracking.md new file mode 100644 index 0000000000000..7a6fb875d74c1 --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks. `paintPress` in `utils/pressFeedback.ts` writes `data-astryx-press` for every press it tracks; the action-group tracker writes its groups' presses itself. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-readme.md b/.changeset/gesture-readme.md new file mode 100644 index 0000000000000..9a6293b811b68 --- /dev/null +++ b/.changeset/gesture-readme.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the gesture system's README for component authors, with its browser proof in Chromium and WebKit. + +@vjeux diff --git a/.changeset/gesture-recognizer-core.md b/.changeset/gesture-recognizer-core.md index 0b0bfc389235f..28caef0d5579c 100644 --- a/.changeset/gesture-recognizer-core.md +++ b/.changeset/gesture-recognizer-core.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point; no component uses it yet. +[chore] Internal: the gesture recognizer core for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-recognizer-fixes.md b/.changeset/gesture-recognizer-fixes.md new file mode 100644 index 0000000000000..9e6754a78d733 --- /dev/null +++ b/.changeset/gesture-recognizer-fixes.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gesture recognizer and arena fixes from review; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (`spec:AST-100`: UIKit's measured blend, a scrolling surface's release through the pan's `scrollVelocity()`, and FR2's release rules). Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..ffeadde679ef5 --- /dev/null +++ b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: swipe, pinch, rotation, screen-edge pan and hover gesture recognizers. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gesture-tap-long-press.md b/.changeset/gesture-tap-long-press.md index 9ef786da65842..f1efef8d29014 100644 --- a/.changeset/gesture-tap-long-press.md +++ b/.changeset/gesture-tap-long-press.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point; no component uses them yet. +[chore] Internal: tap and long-press gesture recognizers with UIKit's measured thresholds. Not exported from the package entry point. @vjeux diff --git a/.changeset/gesture-watcher.md b/.changeset/gesture-watcher.md new file mode 100644 index 0000000000000..bec7cd286e49d --- /dev/null +++ b/.changeset/gesture-watcher.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: a recognizer created with `claimsTouches: false` is a watcher: passive listeners only, simultaneous with every recognizer, and it follows a touch the browser takes for a scroll to the lift. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/gestures-every-pointer.md b/.changeset/gestures-every-pointer.md new file mode 100644 index 0000000000000..205a9cde31590 --- /dev/null +++ b/.changeset/gestures-every-pointer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: gestures attach for every pointer, a mouse, a pen and a finger alike (AST-094 FR43). Under a fine primary pointer an element that uses a gesture keeps one passive `pointerdown` listener (a recognizer that claims a moving finger also keeps its blocking `touchmove`, and a kept press gets its own only during a press), the page's listeners last only a press and go at a window blur, and a hovering mouse or pen reaches a hover recognizer through two passive listeners on its element. A pen's barrel button, like a right click, starts no press. A right click or a ctrl-click on macOS starts no press. A finger's or a pen's hold that does something keeps text unselected under any primary pointer, a touchscreen laptop's included, while a mouse keeps its released behavior (FR41). A mouse or pen drag that a recognizer claims takes no text selection from then on, anywhere on the page, and a finger is read once where the browser sends Touch Events without exposing `ontouchstart`. A press tracker takes a mouse press as a press, painting at once with no scroll brake. The screen-edge pan is a finger's only: it stays off under a mouse, a trackpad and a pen, and takes no `allowedPointerTypes`, and `perEventPointer` is accepted and does nothing. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/input-stream.md b/.changeset/input-stream.md new file mode 100644 index 0000000000000..d34b681ddf710 --- /dev/null +++ b/.changeset/input-stream.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] The touch press feedback and the gesture recognizers now hear page-wide input through one shared stream on the window's capture phase, so an app's window-capture `stopPropagation`, or a document-capture `stopImmediatePropagation` registered before Core, no longer hides a touch from them. A recognizer that must claim a touch mid-gesture still needs the touch to reach its element. Each event type's listener is passive unless a subscriber cancels it (the gesture host's `touchend` and `contextmenu` while a claiming recognizer or press tracker is attached, the page hold's `touchmove` while it holds, and the click guard's `click` once it first arms), so a page of pressables registers no listener that can hold a scroll. + +@vjeux diff --git a/.changeset/layer-exit-copy.md b/.changeset/layer-exit-copy.md new file mode 100644 index 0000000000000..441788643f6d2 --- /dev/null +++ b/.changeset/layer-exit-copy.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the layer runtime gains one exit copy for touch presentations that animate a popover out: a static, inert copy of the closing popover, shown only in the top layer where the popover was drawn, for the close to play on. + +@vjeux diff --git a/.changeset/layer-input-hold.md b/.changeset/layer-input-hold.md new file mode 100644 index 0000000000000..3acd4e377646f --- /dev/null +++ b/.changeset/layer-input-hold.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: one input hold for every layer (`Layer/inputHold`). While a layer presents, a touch, a press or a click anywhere reaches nothing (keys and assistive technology still act, and a pinch still zooms the page); while it dismisses, the same, and the layer's content is inert. The gesture host holds the layer's touches through its touch gates, and `isInputShield` lets a hit test see past a hold's shield. Not exported from the package entry point; no component uses it yet. + +@vjeux diff --git a/.changeset/long-press-one-source.md b/.changeset/long-press-one-source.md new file mode 100644 index 0000000000000..a73cf7b961b6d --- /dev/null +++ b/.changeset/long-press-one-source.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useLongPress` (ContextMenu's long press, and the held finger that opens a menu from its trigger) takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer. No behavior changes. + +@vjeux diff --git a/.changeset/menu-press-handoff.md b/.changeset/menu-press-handoff.md new file mode 100644 index 0000000000000..0e9cc37349e6b --- /dev/null +++ b/.changeset/menu-press-handoff.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger, on every device, the finger that opened a DropdownMenu or MoreMenu now picks a row only after it has moved 10 px (`spec:AST-094` FR29, FR43), so a menu that appears under a resting finger never picks the row beneath it, however long the finger rests. To keep the previous behavior, where that finger's lift picked once the press had lasted 300 ms, pass `presentation="popover"`. A mouse and the public `useMenuPress` hook are unchanged. + +@vjeux diff --git a/.changeset/menu-press-uimenu.md b/.changeset/menu-press-uimenu.md new file mode 100644 index 0000000000000..5773b203c08af --- /dev/null +++ b/.changeset/menu-press-uimenu.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger, on every device, a DropdownMenu or MoreMenu trigger now opens its menu as the finger presses, instead of after a 500 ms hold (`spec:AST-094` FR29, FR43). Inside a scroll view it opens once the 150 ms content-touch delay has passed with no scroll, so a scroll that starts on it still scrolls. To keep the previous behavior, a tap that opens at the lift and a 500 ms hold, pass `presentation="popover"`. A mouse is unchanged. + +@vjeux diff --git a/.changeset/menu-trigger-drift.md b/.changeset/menu-trigger-drift.md new file mode 100644 index 0000000000000..98ca1acc9096b --- /dev/null +++ b/.changeset/menu-trigger-drift.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A finger held on a DropdownMenu trigger may drift up to 13px on each axis and still open the menu under every primary pointer, a phone's, a tablet's and a touchscreen laptop's alike, as AST-094 FR29 allows; it cancelled past 10px. No menu, row or trigger reads the pointer query. + +@vjeux diff --git a/.changeset/presentation-resolver.md b/.changeset/presentation-resolver.md new file mode 100644 index 0000000000000..21e19a3382f8b --- /dev/null +++ b/.changeset/presentation-resolver.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] An internal `usePresentation` / `readPresentation` resolves a component's `presentation` prop into the surface to render and whether its touch input modes apply, keeping an omitted value distinct from an explicit `'popover'` so a caller's choice always wins over a touch default. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/presented-hit-testing.md b/.changeset/presented-hit-testing.md new file mode 100644 index 0000000000000..e27c857277aad --- /dev/null +++ b/.changeset/presented-hit-testing.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] On iOS, a touch on an element the compositor animator is moving reaches what is drawn: the animator reports each moving element's offset from its layout box, and the gesture host sends the touch, press bounds and the tap's click there instead of to the layout position iOS WebKit hit-tests. + +@vjeux diff --git a/.changeset/press-group-item-marker.md b/.changeset/press-group-item-marker.md new file mode 100644 index 0000000000000..45ce7e702d69f --- /dev/null +++ b/.changeset/press-group-item-marker.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch press controller leaves the press of an action marked as tracked by its group (`data-astryx-press-group-item`) to that group's own tracker, so one writer paints each press. No component carries the marker yet, so nothing visible or behavioral changes. + +@vjeux diff --git a/.changeset/reduced-motion-record.md b/.changeset/reduced-motion-record.md new file mode 100644 index 0000000000000..2ec44f4c1f9c8 --- /dev/null +++ b/.changeset/reduced-motion-record.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: Reduce Motion for the touch presentations follows design:reduced-motion beyond the animator too. `reducedMotionFade()` gives code beside the animator the animator's own fade (an opacity-only 200 ms cross-fade on a coarse pointer or after a touch release, at once on any other pointer), `animator.reducedMotion()` reports which form a change takes for feedback that plays no fade of its own, and `reducedMotion.stylex.ts` holds the fade's constants for StyleX transitions. The fade's length is not a theme motion role. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/touch-motion-theme-roles.md b/.changeset/touch-motion-theme-roles.md index 02eb1ec1717cb..c334517aba9ab 100644 --- a/.changeset/touch-motion-theme-roles.md +++ b/.changeset/touch-motion-theme-roles.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the touch motion tables register their springs, durations and curves as internal motion roles, each read once when a motion starts, so every component keeps its measured value. Nothing public changes: themes do not set motion roles, and no shipped component reads the roles yet. +[chore] Internal: the touch motion tables register their springs, durations and curves as internal motion roles, each read once when a motion starts, so every component keeps its measured value. Nothing public changes: themes do not set motion roles. DropdownMenu's touch grow and collapse read `springs.menuOpen` and `springs.menuClose`. @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..44a8454bbbbed 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; DropdownMenu's touch presentation uses it. @vjeux diff --git a/.changeset/uikit-motion-use-animator.md b/.changeset/uikit-motion-use-animator.md new file mode 100644 index 0000000000000..b234a4e987752 --- /dev/null +++ b/.changeset/uikit-motion-use-animator.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: useAnimator, a React wrapper around the touch presentations' animator that never re-renders per frame, and browser proofs on the interaction harness that the animator's motion keeps running through a blocked main thread in Chromium and WebKit. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/use-finger-scroll.md b/.changeset/use-finger-scroll.md new file mode 100644 index 0000000000000..e48f0b171df03 --- /dev/null +++ b/.changeset/use-finger-scroll.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: `useFingerScroll(scroller)` tells a finger's scroll from the program's, and the gestures README gains a guide to migrating a raw-touch drag onto the recognizers. Internal to the touch components: not exported from the package entry point or the gestures barrel. + +@vjeux diff --git a/apps/docsite/src/__tests__/data-extraction.test.ts b/apps/docsite/src/__tests__/data-extraction.test.ts index d996628d2874d..e29bee009d69b 100644 --- a/apps/docsite/src/__tests__/data-extraction.test.ts +++ b/apps/docsite/src/__tests__/data-extraction.test.ts @@ -1428,7 +1428,10 @@ describe('DropdownMenu adaptive-presentation example', () => { ); expect(presentation?.type).toBe("'popover' | 'bottom-sheet' | 'adaptive'"); - expect(presentation?.default).toBe("'popover'"); + // Unset by default: the menu is anchored, with the touch modes under a + // finger (spec:AST-094 FR29); 'popover' pins the pointer behavior. + expect(presentation?.default).toBeUndefined(); + expect(presentation?.description).toContain("Pass 'popover'"); const defaults = dropdownMenu?.playground?.defaults as Record | undefined; diff --git a/apps/storybook/stories/DropdownMenu.stories.tsx b/apps/storybook/stories/DropdownMenu.stories.tsx index 93639a6f58308..80fd46efae01f 100644 --- a/apps/storybook/stories/DropdownMenu.stories.tsx +++ b/apps/storybook/stories/DropdownMenu.stories.tsx @@ -1077,6 +1077,40 @@ export const ActionSheetPresentation: Story = { }, }; +export const ActionSheetCardPresentation: Story = { + name: 'Presentation / action sheet card', + parameters: { + layout: 'fullscreen', + docs: { + story: {inline: false, height: '560px'}, + description: { + story: + 'Plain actions, a label and an icon each, in the bottom-sheet presentation. On a compact touch screen they present the way iOS 26 presents an action sheet: an alert-style card. Rows with descriptions keep the sheet (Presentation / action sheet).', + }, + }, + }, + globals: {viewport: {value: 'mobile1', isRotated: false}}, + render: () => ( +
+ ({ + label, + icon, + onClick: () => console.log(`${label} selected`), + }))} + /> +
+ ), + play: async ({canvasElement}) => { + const trigger = canvasElement.querySelector('button'); + if (trigger instanceof HTMLElement) { + trigger.click(); + } + }, +}; + export const ActionSheetCustomTrigger: Story = { name: 'Presentation / action sheet, custom trigger', parameters: { diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 0fab0e43de33e..34e64a643f960 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -33,6 +33,8 @@ verified_by: packages/core/src/Layer/anchorName.test.ts, packages/core/src/Layer/useLayerDismissal.test.tsx, packages/core/src/Layer/layerDismissalInvariants.test.tsx, + packages/core/src/Layer/layerExitCopy.test.ts, + packages/core/src/Layer/__tests__/layerExitCopy.interaction.browser.spec.ts, packages/core/src/Popover/Popover.test.tsx, packages/core/src/DropdownMenu/DropdownMenu.test.tsx, packages/core/src/ContextMenu/ContextMenu.test.tsx, @@ -62,6 +64,7 @@ deciding_specs: [spec:AST-038, spec:AST-059] "INV7", "INV11", "INV12", + "INV13", "INV14", "INV15" ], @@ -297,6 +300,25 @@ during a press so native light dismissal does not mistake them for outside interaction. Their click action is cancelled as an invoker toggle so the control can perform its own action without closing the layer. +### Exit copy + +The browser hides a popover the moment it closes, whether by a pick, Escape, or +its own light dismiss. A touch presentation whose close stays on screen plays it +on an exit copy: a static copy of the layer that `placeLayerExitCopy` takes as +the layer closes. The copy is laid out on the layer's own box and carries the +transform and opacity the layer is drawn with, so a copy taken while the layer +is still entering holds where it was drawn. It carries no ids, form names, or +form owner; it is `inert`, hidden from assistive technology, and out of pointer +reach; it keeps the layer's scroll positions. It is shown only in the top layer, +as a manual popover, so a clipping, transformed, or sticky ancestor neither cuts +nor moves it; a fixed box or a larger `z-index` is no substitute +(`spec:AST-027` FR8). Where the browser refuses that show during the closing +popover's own toggle, the copy waits, hidden, and enters the top layer once the +toggle is over: after the script that closed the layer, or in the next frame +before it paints. Where there is no top layer (no popover API), or the browser +still refuses, there is no copy and the close is immediate. The presentation +that placed the copy animates it and removes it when its close settles. + ### Current dismissal plumbing `family:overlay-dismissal` owns the shipped Escape/platform-close membership @@ -480,6 +502,12 @@ layers use Layer rendering without joining Escape/platform dismissal. correct, and the runtime polls nothing: an anchor that a scrolled container hides is followed again when an intersection observer reports it back in view. +- **INV13 — One exit copy per closing layer.** A layer's close plays on at most + one exit copy, made by `placeLayerExitCopy`: drawn where the layer was drawn, + inert, hidden from assistive technology, without ids, form names, or form + owner, shown only in the top layer, and removed by its owner when the close + settles. Placing a second copy for the same layer removes the first; another + layer's copy stays. - **INV14 — The runtime owns every layer's shared mechanism.** Internally, the layer runtime owns the shared mechanism layers use: top-layer mounting, close-request delivery, the presentation hold (keeping a layer's chosen presentation from the open until @@ -533,11 +561,12 @@ Current gaps are observable facts, not current target behavior: migration, verification, and completion criteria for these gaps. This record must be updated only as that work ships. -INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them: no -shared presentation hold, exit copy, runtime backdrop tint, or lift module -exists, and Dialog, Drawer, BottomSheet, Lightbox, MobileNav, ToastViewport, -and Selector each build their own top-layer mounting, exit, or backdrop, and -Popover builds its own presentation hold for its sheet presentation. +INV14 and INV15 are not met yet, and `spec:AST-003` does not cover them. The +runtime owns the shared exit copy (INV13, `placeLayerExitCopy`); no shared +presentation hold, runtime backdrop tint or lift module exists, Dialog, Drawer, +BottomSheet, Lightbox, MobileNav, ToastViewport, and Selector each build their +own top-layer mounting, exit, or backdrop, and Popover builds its own +presentation hold for its sheet presentation. BottomSheet's own includes its sheet stack's mounting — the re-show that keeps a document's sheets in open order, a step dialog's modal present, and the non-modal sheet's popover host — alongside its scrim; the ordering rules @@ -568,6 +597,12 @@ above are the stack's, the mounting under them is this gap's. entry/return, and controlled close behavior. - A browser fallback change updates public `useLayer` and Popover docs and tests for both the native path and actual fallback. +- An exit-copy change verifies, where each can be observed: the copy's reach, + accessibility and form exclusion in jsdom; its top-layer show (from script + and by light dismiss), its place mid-entrance, and that a layer keeps one + copy, in jsdom and in Chromium and WebKit; and the refused show's wait in + jsdom and on Android Chromium, the engine that refuses a show during another + popover's toggle. ## Owning code @@ -589,6 +624,8 @@ above are the stack's, the mounting under them is this gap's. transitions of a layer and its content across the re-placement's display reset. - `Layer/gestureCounter.ts` owns physical pointer/key gesture identity. +- `Layer/layerExitCopy.ts` owns the exit copy a touch presentation's close plays + on. - `Layer/layerStack.ts`, `Layer/useLayerDismissal.ts`, and `Layer/LayerDepthContext.tsx` own current registration, presence, ordering, and Escape/platform routing. @@ -638,6 +675,7 @@ this current architecture record. | INV3–INV5 | `useLayer.test.tsx` and `anchorName.test.ts` | A mode leaks geometry, RTL resolves from the wrong context, fallback clips, or a sibling anchor is lost | | INV11 | `layerViewportInset.test.ts`, `useLayer.test.tsx`, and the `Core/Layer` viewport-inset stories under the story play guard at a desktop and a phone viewport | A second gutter definition, a `100%`-of-span cap, a layer narrower than its explicit size, a layer box wider than the viewport, a layer inside the gutter, or a layer that slides toward an off-screen anchor | | INV12 | The `Core/Layer` viewport-inset stories' first-frame comparison, and `useLayer.test.tsx`'s synchronous anchor read | A layer whose first painted rectangle differs from its settled one | +| INV13 | `layerExitCopy.test.ts` and `layerExitCopy.interaction.browser.spec.ts` | A copy with an id, a form name, a form owner, or pointer reach; a copy off the layer's drawn box; a copy outside the top layer; a second copy for one layer | | INV6, INV7 | `useLayer.test.tsx`, `Popover.test.tsx`, `DropdownMenu.test.tsx`, and `useMenuHover.test.tsx` | Duplicate close callback or the same press/re-hover reopens a surface | | INV8 | `useLayerDismissal.test.tsx`, `layerDismissalInvariants.test.tsx`, and `useFocusTrap.test.tsx` | Current top registered layer is skipped, two layers close, or a blocker leaks through | | INV9 | Representative Dialog, ContextMenu, Tooltip/HoverCard, and BottomSheet source/tests | A current local channel silently changes ownership or policy | diff --git a/docs/specs/AST-057-row-actions/spec.md b/docs/specs/AST-057-row-actions/spec.md index 2b3f6c57495e1..4c1ed5a60d229 100644 --- a/docs/specs/AST-057-row-actions/spec.md +++ b/docs/specs/AST-057-row-actions/spec.md @@ -131,10 +131,11 @@ and its panels stay put, so no element is added to any row. - **A render prop for a custom panel.** Deferred, not foreclosed: a `render*`-shaped addition can land when a caller needs something the vocabulary cannot say (DEC-1). -- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity, slide - and spring durations, resistance past the panel: behavior constants in - source, not public API and not design tokens (the same ruling the touch - press model received for its clocks). +- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity, + resistance past the panel: behavior constants in source, not public API and + not design tokens (the same ruling the touch press model received for its + clocks). The row's slide and spring motion plays on theme motion roles + (`spec:AST-094` FR4). - **Vertical swipes**, and a swipe whose only meaning is dismissal (Toast already has that in `useToastGesture`). - **Public out-of-flow slots on `Item`.** Deliberately not admitted: the @@ -219,9 +220,10 @@ and its panels stay put, so no element is added to any row. | open state | closed, resting open | The row's own. A pointer landing anywhere outside a resting row closes it, which is also how a drag on a neighbour closes it; no group state exists. | closed | `spec:AST-057` | proposed | | the modality split | internal | A mouse drag never starts the swipe; a finger's or a pen's pointer event or a horizontal wheel gesture does, read from the input event, not a prop or a media query (`architecture:interaction-modality` INV6). | — | `spec:AST-057` | proposed | -Not public: axis-lock distance, commit ratio, fling velocity, durations, -resistance, panel widths, the custom property's name. They are behavior -constants. +Not public: axis-lock distance, commit ratio, fling velocity, resistance, +panel widths, the custom property's name. They are behavior constants. The +row's slide and spring motion plays on theme motion roles (`spec:AST-094` +FR4). ## Requirements diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 41244a1b58789..6b3ec2e418b55 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -130,25 +130,27 @@ a measured, sourced difference exists. wherever the native motion settles with an overshoot or stretches with a rubber band. - | Role | Default | Source | - | -------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | - | `springs.fingerTracking` | response 0.15 s, damping ratio 0.86 | Apple: SwiftUI `interactiveSpring()` | - | `springs.emphasis` | duration 0.5 s, bounce 0.3 | Apple: SwiftUI `.bouncy` | - | `springs.settle` | duration 0.5 s, bounce 0 | Apple: SwiftUI `.smooth` | - | `springs.quickChange` | duration 0.5 s, bounce 0.15 | Apple: SwiftUI `.snappy` | - | `springs.viewChange` | mass 3, stiffness 1000, damping 500 | measured: UIKit `UISpringTimingParameters()`, iOS 26.2 simulator, 2026-10-23 | - | `springs.timedChange` | duration 0.5 s, bounce 0 | Apple: UIKit `UIView.animate(springDuration:bounce:)` | - | `springs.stateChange` | response 0.55 s, damping ratio 1 | Apple: SwiftUI `Animation.default` | - | `springs.directChange` | response 0.5 s, damping ratio 0.825 | Apple: SwiftUI `.spring(response:dampingFraction:)` | - | `springs.menuOpen` | damping ratio 0.8, response 0.32 s | measured: UIMenu opening, iOS 26.2 simulator, 2026-10-23 | - | `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 | - | `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 | - | `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 | - | `timing.stateChangeMs` | 250 ms | measured: Core Animation's implicit transaction, iOS 26.2 simulator, 2026-10-23 | - | `timing.curves.viewChange` | cubic-bezier(0.25, 0.1, 0.25, 1) | Apple: `CAMediaTimingFunctionName.default` | - | `timing.curves.exit` | cubic-bezier(0.42, 0, 1, 1) | measured: UIKit's ease-in, iOS 26.2 simulator, 2026-10-23 | - | `timing.curves.entrance` | cubic-bezier(0, 0, 0.58, 1) | measured: UIKit's ease-out, iOS 26.2 simulator, 2026-10-23 | - | `timing.curves.move` | cubic-bezier(0.42, 0, 0.58, 1) | measured: UIKit's ease-in-out, iOS 26.2 simulator, 2026-10-23 | + | Role | Default | Source | + | -------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- | + | `springs.fingerTracking` | response 0.15 s, damping ratio 0.86 | Apple: SwiftUI `interactiveSpring()` | + | `springs.emphasis` | duration 0.5 s, bounce 0.3 | Apple: SwiftUI `.bouncy` | + | `springs.settle` | duration 0.5 s, bounce 0 | Apple: SwiftUI `.smooth` | + | `springs.quickChange` | duration 0.5 s, bounce 0.15 | Apple: SwiftUI `.snappy` | + | `springs.viewChange` | mass 3, stiffness 1000, damping 500 | measured: UIKit `UISpringTimingParameters()`, iOS 26.2 simulator, 2026-10-23 | + | `springs.timedChange` | duration 0.5 s, bounce 0 | Apple: UIKit `UIView.animate(springDuration:bounce:)` | + | `springs.stateChange` | response 0.55 s, damping ratio 1 | Apple: SwiftUI `Animation.default` | + | `springs.directChange` | response 0.5 s, damping ratio 0.825 | Apple: SwiftUI `.spring(response:dampingFraction:)` | + | `springs.menuOpen` | damping ratio 0.8, response 0.32 s | measured: UIMenu opening, iOS 26.2 simulator, 2026-10-23 | + | `springs.menuClose` | damping ratio 0.85, response 0.35 s | measured: UIMenu closing, iOS 26.2 simulator, 2026-10-23 | + | `springs.subMenuOpen` | damping ratio 0.85, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu opening, iOS 26.2 simulator, 2026-10-24 | + | `springs.subMenuBack` | damping ratio 1, response 0.4 s | measured: UIKit's own spring for UIMenu's sub-menu header back, iOS 26.2 simulator, 2026-10-24 | + | `springs.alertPresent` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert appearing, iOS 26.2 simulator, 2026-10-23 | + | `springs.alertDismiss` | mass 1, stiffness 522.35, damping 45.71 | measured: UIAlertController alert dismissing, iOS 26.2 simulator, 2026-10-23 | + | `timing.stateChangeMs` | 250 ms | measured: Core Animation's implicit transaction, iOS 26.2 simulator, 2026-10-23 | + | `timing.curves.viewChange` | cubic-bezier(0.25, 0.1, 0.25, 1) | Apple: `CAMediaTimingFunctionName.default` | + | `timing.curves.exit` | cubic-bezier(0.42, 0, 1, 1) | measured: UIKit's ease-in, iOS 26.2 simulator, 2026-10-23 | + | `timing.curves.entrance` | cubic-bezier(0, 0, 0.58, 1) | measured: UIKit's ease-out, iOS 26.2 simulator, 2026-10-23 | + | `timing.curves.move` | cubic-bezier(0.42, 0, 0.58, 1) | measured: UIKit's ease-in-out, iOS 26.2 simulator, 2026-10-23 | ### Motion @@ -468,7 +470,11 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and own record. Keyboard and assistive-technology selection are unchanged. A pen's hold acts as a finger's; a mouse makes the context request by a right click, not a long press (FR43), and otherwise keeps its released behavior - (`spec:AST-017` FR59; DEC-9). + (`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims + takes no new text selection for that drag only. A claim made after the + drag has moved clears the page's selection; a press claimed at its own + pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its + released behavior. Guidance: a caller whose menu element carries text a person may want offers a Copy action in its menu. @@ -656,7 +662,7 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and | FR31–FR33 | Browser tests and storyboards under a finger | a menu, a Popover and a ContextMenu lift: open, close, a pan outside, a tap outside, a pinch over the surface; a mouse | a surface that does not grow from and shrink into its trigger, a pan outside that leaves a menu open, a Popover whose dismissal changed, a pinch blocked other than by the author's `touch-action`, a tap outside that reaches the page, or a changed mouse path fails | | FR34, FR35 | Browser tests and storyboards under a finger | a sub-menu drilling in and its Back row returning; a tap and a drag during a popover's and a menu's growth; a pinch during growth; a mouse; a tap and a drag during an alert's, a sheet's, and a dialog's entrance; a key and a screen reader during an entrance; a pinch during an alert's and a sheet's entrance; a tap and a scroll on the page beside an entering non-modal sheet, which pass | a drill-in that leaves the parent's rows in place, a change between levels that does not play on its roles, a tap during growth that closes, scrolls or moves focus, a pinch blocked other than by the author's `touch-action`, or a changed mouse path, a held key or assistive-technology activation, a hidden content tree, or a tap or drag during an entrance that closes the presentation, scrolls a held page, moves focus or reaches anything beneath, fails | | FR36–FR40 | Release tests on synthetic touch streams with explicit timestamps, per use | below and past each commit point; a flick that projects past it; a slow drag past it; a release moving back; Reduce Motion; keyboard and mouse paths | a decision that changes mid-settle, a release moving back that commits, a settle that does not start at the release velocity, or an action with no keyboard or mouse path fails | -| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, or a change to native selection elsewhere or to mouse, keyboard, or assistive-technology selection fails | +| FR41 | Browser tests with real touch input (`spec:AST-013` FR10) | a tap; a long press on a ContextMenu trigger, a DropdownMenu trigger the caller renders, a row with a context menu, and a slider thumb; a long press on a checkbox row's label and on plain text; a cancelled press; mouse and keyboard selection; a pen long press; a mouse long press, which makes no context request; a mouse drag a recognizer claims after it moves, and a click on an action group's button with text selected | a tap that selects, a hold with an action that starts a selection or callout, a label or plain text whose long press does not select natively, a suppression that outlives the press, a change to native selection elsewhere, to keyboard or assistive-technology selection, or to mouse selection outside a drag a recognizer claims, or a claimed press that clears the page's selection before it moves fails | | FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails | | FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, an idle listener in a component with no gesture, or added stylesheet bytes, fails | | all | iOS simulator: the UIKit reference capture and the same gesture in Safari; real iOS Safari and Android Chrome devices | per component | an unmatched curve or timing is a parity defect | diff --git a/internal/interaction-harness/README.md b/internal/interaction-harness/README.md index 4c67256a261b7..27e054e9beb71 100644 --- a/internal/interaction-harness/README.md +++ b/internal/interaction-harness/README.md @@ -10,9 +10,10 @@ pixels per CSS px, touch, mobile viewport rules), in Chromium and WebKit. The harness proves its own instruments in [`src/harness.interaction.browser.spec.ts`](src/harness.interaction.browser.spec.ts). -The busy-main-thread proof has a negative control beside its positive check -(a rAF animation that freezes under a blocked main thread); other self-tests -check an instrument against a known answer. +The busy-main-thread proof and the rendering-trace counts have a negative +control beside their positive checks (a rAF animation that freezes under a +blocked main thread, a `top` animation that lays out every frame); other +self-tests check an instrument against a known answer. ## Running @@ -34,6 +35,12 @@ under a rootful docker daemon the container runs as your user so its node internal/interaction-harness/scripts/webkit-container.mjs [playwright test args…] ``` +WebKit composites through Mesa's software GL there, which compiles each shader +the first time a page draws it (450–700 ms for the first frame of a blurred or +newly animated layer). The script keeps Mesa's shader cache on the host +(`~/.cache/astryx-webkit-shaders/`, or `ASTRYX_WEBKIT_SHADER_CACHE`), so +only the first run on a machine pays for them. + Evidence (storyboards, curve traces) is written to `test-results/interaction-evidence/` (override with `ASTRYX_INTERACTION_EVIDENCE`) and attached to the test report. It is never @@ -41,17 +48,18 @@ committed: a pull request links or attaches it. ## Instruments -| Module | What it gives a spec | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | -| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | -| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `frameStats`. | -| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | -| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | -| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | -| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event): PNG contact sheet + JSON trace. | -| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | -| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | +| Module | What it gives a spec | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts.ts` | Touch scripts as data: `tap`, `drag` (hold, rest), `flick` (exact final-move speed, velocity profiles), `holdThenDrag`, `pinch`, `rotate`, `path`, `merge` (a second finger mid-gesture), `releaseVelocity`, `toTouchEvents`. | +| `touch.ts` | `touchDriver(page).play(script)`: the script as real engine input. `installInputLog` / `readInputLog`: what the page received. | +| `frames.ts` | `startScreencast` (timestamped composited frames), `markElement` (a marker chip or outline), `trackMarker` (marker box per frame), `sampleRects` (per-rAF rects), `frameStats`. | +| `busy.ts` | `blockMainThread(page, ms)` and `busyWindowProof(tracked, window)`: frames presented, frames that moved, longest stall, a verdict; `presented` adds the compositor's own frames. | +| `trace.ts` | `traceRendering(page, body)`: main-thread counts of UpdateLayoutTree / Layout / PrePaint / Paint / Layerize / UpdateLayer / ScrollLayer / HitTest, `countBetween`, long tasks; `installLongTaskLog` / `readLongTasks`; `startCompositorTrace`: when the compositor presents. | +| `curves.ts` | `holdAnimations`, `seekAnimations`, `sampleCurve` (presented transform / opacity at any t), `waitForAnimation`, `watchAnimations`, `waitForRest`, `releaseAnimations`. | +| `math.ts` | Oracles independent of the code under test: `cubicBezier`, `timingFunction`, `springStep` (SwiftUI response / dampingFraction, initial velocity), `projectDecay`, `rubberBand`, `parseTransform`, `compareCurve`, `timeline`. | +| `storyboard.ts` | `animationStoryboard` (seek to t = 0, 16.7, 33.3 … ms), `touchStoryboard` (a frame per scripted event), `castStoryboard` (real screencast frames): PNG contact sheet + JSON trace. | +| `output.ts` | `evidenceDir`, `writeEvidence`: where evidence goes and how it is attached to the report. | +| `story.ts` | `phone`, `withStorybook`, `openStory`, `frames`, and the timer gate (`installTimerGate`, `freezeTimers`, `thawTimers`). | ## The proofs @@ -65,6 +73,14 @@ dispatch latency, and the events go through the compositor's gesture detection the limits below. Read positions a frame after a move: Chromium delivers `pointermove` aligned to animation frames. +**Stories, not Storybook.** `openStory` turns Storybook's highlight module off +on every page it opens, and checks that its root element is absent. Left on, +the module observes `#storybook-root` and, on every insertion, reads the +computed style of every element on the page inside the inserting task (117 +reads and two forced style recalculations for one exit copy in a Button story), +so a proof would measure Storybook as well as the component. +`openStory(page, origin, id, args, {storybookHighlight: true})` keeps it on. + **Curve sampling.** Pause every running animation (CSS transitions and animations are Web Animations too), seek to a time, read `getComputedStyle`: the presented value at that exact time, independent of frame timing. Finished @@ -138,7 +154,8 @@ every frame, so one late frame is not "1 of 1 moved"). Pass `maxMedianFrameMs` (20 at 60 Hz) only in a proof that claims display rate. `verdict.failures` names each miss with its numbers, and `summary` carries the raw numbers for the report. Frames are judged in presentation order: a loaded -machine can deliver them out of order. +machine can deliver them out of order. `castStoryboard` turns the frames of the +block into a filmstrip. A just-started animation can reach the screen after the block began (50–100 ms later in WebKit on a loaded machine). When the cast saw the element at rest @@ -155,8 +172,11 @@ two steps per frame, which never fails it. Move a proof's element at least two steps a frame: the self-tests move 560 px in 1,600 ms, about 4.8 px a frame. On a loaded machine Chromium's screencast can skip frames the compositor -still presents. Pass when the compositor presented each frame (epoch ms on the -page clock, from a trace of `Display::FrameDisplayed`) as `presented`. A gap between two moved +still presents. `startCompositorTrace(page)`, started with the screencast and +stopped after the block, records when the compositor presented each frame +(`Display::FrameDisplayed`); pass it as `presented`. The record is +browser-wide (its frames carry no page identity), so it throws when another +page is open as it starts or stops, or opened while it ran. A gap between two moved frames with nothing delivered inside it is then measured between the compositor's own frames: the frames it presented there count as presented (`recorderSkipped`, in the summary), and a gap it did not present through is @@ -165,8 +185,41 @@ delivered frame that did not move, is judged from the screencast, so a frozen element fails whatever else the compositor draws (a rAF animation's block presents no compositor frame at all). With the record, the 90% counts the compositor's frames from the first to the last moved frame; the gaps before and -after are the stall check's. Without a record (WebKit has no such trace), the -screencast is judged alone. +after are the stall check's. WebKit has no such trace: +`stop()` resolves to `[]` and the screencast is judged alone. + +**No Layout / Paint.** `traceRendering` records a Chromium trace around the +interaction; `countBetween` counts the steady middle of an animation (its +first and last frames may commit). A transform/opacity animation does no +Layout or Paint there; a layout-property animation does one per frame. The +harness's own animation recorder stands down while the trace runs, since its +frame loop would make every frame a main-thread one, restyling each running +animation's target and laying out an anchored one: it reads only the element a +CSS animation or transition event names, once, when that animation starts, and +catches up afterwards. A `waitForRest` awaited inside the traced body stands +down too: it waits on timers for the scheduled ends of the element's curves +(looking `frames` times a frame apart before settling on none, so an +animation the action starts a frame or two later is waited for) and polls no +style until the trace ends (in the anchored fixture's steady middle, +24 Layouts before, 0 now). Its one read of the element's animations, as it is +called, restyles the element (2 Layouts and 2 style recalculations in the +anchored fixture when called 200 ms into its animation), so await it right +after the action that starts the motion, before the window a proof counts. So do `waitForAnimation` and a watch's +`started()`: they take the element's animations from the recorder's log, +judge a started one by its own times, and reach the element through a handle, +since each `locator.evaluate` makes a main-thread frame (there too, 24 +Layouts before, 0 now). That holds for script animations and CSS +transitions. On a CSS `@keyframes` animation (and, unwatched, a cancelled CSS +transition), `waitForAnimation` and `started()` read the animation's timing, +which flushes its style and adds Layouts to the trace: wait on one with +`waitForRest`, or after the trace. Taking a watch (`watchAnimations`) inside +a trace reads the watched element's animations, which restyles it (and lays +out an anchored one), so a proof takes its traced watch before the window it +counts. The trace's `longTasks` are the page's own: only a +task the harness marked as its own read (`waitForAnimation` describing +keyframes, `blockMainThread` without `during`) is in `devtoolsTasks` instead, +and only when no page script ran inside it. A test's own `page.evaluate` stays a +long task, whether it calls page code directly or sets it off. **Storyboards.** Deterministic: frames come from seeking held animations or from stepping scripted input, never from wall-clock capture. The JSON trace @@ -176,14 +229,14 @@ backstop, a cleanup render) would do so behind a held storyboard's back: `installTimerGate` before the animation starts and `freezeTimers` while photographing. The page can still cancel a held callback, a held frame callback gets the time it actually runs, and the harness's own waits (its frame waits, -`blockMainThread` with `during`) are not held. +`blockMainThread` with `during`, `sampleRects`) are not held. An animation that repeats forever does not set a storyboard's default length; with nothing else to film, pass `times`. A touch filmstrip keeps the scripted event `timeStamp`s (Chromium), but a handler that reads `performance.now()` or `Date.now()` sees one event per photograph, about 100 ms apart, so a clock-timed release flings slower when filmed than when played: a storyboard cannot show such a gesture; record it in real time instead, with -`startScreencast`. Chromium can return an empty copy of +`startScreencast` and `castStoryboard`. Chromium can return an empty copy of the page's surface right after it changes ("Unable to capture screenshot"); a storyboard takes that photograph again two frames later, up to three attempts in all, and lists each retake under `recaptures` in the trace. @@ -200,6 +253,8 @@ in all, and lists each retake under `recaptures` in the trace. | Click synthesised from a touch | yes | only from `driver.tap` (WebKit's tap gesture), not from a raw touch sequence | | Curve sampling, storyboards, input log | yes | yes | | Screencast frame evidence (busy main thread) | yes | yes (WebKit's own screencast; frames keep coming while the main thread is blocked) | +| Rendering trace, long tasks | yes | no (Playwright exposes neither) | +| Compositor presentation record | yes | no: `stop()` resolves to `[]`; the busy verdict judges the screencast alone | What WebKit cannot show here — native scroll hand-off, flings, a finger lifting while another stays down, finger tracking under a busy main thread, and diff --git a/internal/interaction-harness/scripts/webkit-container.mjs b/internal/interaction-harness/scripts/webkit-container.mjs index cb20fd44aa725..a077eae415ed5 100644 --- a/internal/interaction-harness/scripts/webkit-container.mjs +++ b/internal/interaction-harness/scripts/webkit-container.mjs @@ -16,10 +16,19 @@ * docker daemon the container runs as the invoking user (rootless podman and * rootless docker already map it), so the `test-results/` it writes into the * mounted checkout stay the host user's and the next host run can clear them. + * + * WebKit composites through Mesa's software GL in the image, which compiles + * each shader variant the first time a page draws it: 450–700 ms for the + * first frame of a blurred or newly animated layer, long enough for a short + * animation to end before it is ever presented. Mesa caches what it compiles, + * and the cache is kept on the host (`~/.cache/astryx-webkit-shaders/`, + * or `ASTRYX_WEBKIT_SHADER_CACHE`), so a run starts with every shader an + * earlier run compiled instead of paying for them in its first tests. */ import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import {createRequire} from 'node:module'; +import os from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -42,6 +51,21 @@ if (engine == null) { process.exit(1); } const args = process.argv.slice(2); +// Where the container sees the host's shader cache (see the header). +const containerShaderCache = '/astryx-shader-cache'; +// Resolved to an absolute path: a bare relative value would be mounted as a +// named volume rather than a host directory. An empty value means the default. +const shaderCache = process.env.ASTRYX_WEBKIT_SHADER_CACHE + ? path.resolve(process.env.ASTRYX_WEBKIT_SHADER_CACHE) + : path.join( + os.homedir(), + '.cache', + 'astryx-webkit-shaders', + image.replace(/[^\w.-]+/g, '_'), + ); +fs.mkdirSync(shaderCache, {recursive: true}); +// The harness self-test reads this marker inside the container. +fs.writeFileSync(path.join(shaderCache, 'ASTRYX-SHADER-CACHE'), `${image}\n`); const env = [ 'ASTRYX_STORYBOOK_DIR', 'ASTRYX_INTERACTION_EVIDENCE', @@ -79,6 +103,10 @@ const command = [ '--ipc=host', '-v', `${root}:${root}`, + '-v', + `${shaderCache}:${containerShaderCache}`, + '-e', + `MESA_SHADER_CACHE_DIR=${containerShaderCache}`, '-w', root, ...env, diff --git a/internal/interaction-harness/src/busy.skip.fixture.ts b/internal/interaction-harness/src/busy.skip.fixture.ts new file mode 100644 index 0000000000000..409886f12e6f8 --- /dev/null +++ b/internal/interaction-harness/src/busy.skip.fixture.ts @@ -0,0 +1,125 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file busy.skip.fixture.ts + * @input One recorded busy-main-thread run in headless Chromium under + * parallel load: a compositor coast through a 566 ms block + * @output `frames`: the screencast frames the marker was tracked in, as + * [ms after the block started, y in CSS px, or null where the marker was not + * found]; `presented`: when the compositor presented each frame + * (`Display::FrameDisplayed`), ms after the block started; `block`: the + * block's span on the same clock. The screencast skipped frames at about + * +366 and +466 ms while the compositor kept presenting every 16.7 ms. + * @position Fixture for busy.test.ts + */ + +export const block = {from: 0, to: 566.1}; + +export const frames: ReadonlyArray = [ + [-1145.8, null], + [-1114, null], + [-1063.9, null], + [-1031.1, null], + [-996.6, null], + [-964.3, null], + [-930.7, null], + [-896.7, null], + [-864, null], + [-830.6, null], + [-797.8, null], + [-762.7, null], + [-731.3, null], + [-686.5, null], + [-680.8, null], + [-651, null], + [-634.1, null], + [-620.7, null], + [-615.8, null], + [-514.4, null], + [-495, null], + [-472.4, null], + [-465.3, null], + [-448.5, null], + [-430.3, null], + [-414.8, null], + [-398.3, null], + [-379.5, null], + [-364.2, null], + [-348.3, null], + [-330.6, null], + [-315, null], + [-297.6, null], + [-281.9, null], + [-265.1, null], + [-247.6, null], + [-230.8, null], + [-215.3, null], + [-198.6, null], + [-181, null], + [-165.3, null], + [-147.7, null], + [-132, null], + [-115.4, null], + [-98.5, null], + [-81.2, 504], + [-65.2, 500], + [-48.5, 496], + [-30.9, 491], + [-15.2, 487], + [1.3, 484], + [32.8, 480], + [49.8, 476], + [66.3, 473], + [82.8, 469], + [99.7, 466], + [116.1, 463], + [132.9, 460], + [149.5, 457], + [166.3, 454], + [182.9, 451], + [199.4, 448], + [216.2, 446], + [232.8, 443], + [249.4, 441], + [266.1, 438], + [282.7, 436], + [299.5, 434], + [316, 432], + [332.7, 430], + [349.5, 428], + [366.1, 426], + [433.1, 418], + [449.7, 417], + [466.2, 415], + [516.2, 411], + [532.8, 409], + [549.5, 408], + [566.2, 407], + [650.6, 401], + [666.2, 400], + [682.9, 399], + [699.6, 398], + [716.3, 397], + [816.1, 391], + [832.8, 391], + [849.5, 390], + [866.2, 389], + [882.9, 388], + [899.5, 388], + [916.1, 387], + [1033.8, 383], + [1050.1, 383], + [1066.7, 382], + [1083.3, 382], + [1099.5, 381], + [1117.3, 381], + [1134.2, 380], + [1149.5, 380], +]; + +export const presented: readonly number[] = [ + -98.7, -81.3, -65.3, -48.7, -31.1, -15.3, 1.3, 32.5, 49.6, 66.1, 82.7, 99.6, + 115.9, 132.6, 149.3, 166.1, 182.6, 199.3, 216, 232.6, 249.3, 266, 282.6, + 299.2, 315.8, 332.6, 349.3, 366, 381.5, 398.3, 415.1, 432.9, 449.6, 466.1, + 481.5, 498.3, 516, 532.6, 549.4, 566.1, 581.7, 598.4, 615, 631.6, 650.3, 666, +]; diff --git a/internal/interaction-harness/src/busy.test.ts b/internal/interaction-harness/src/busy.test.ts index 372860125fe6a..44a2446ecca18 100644 --- a/internal/interaction-harness/src/busy.test.ts +++ b/internal/interaction-harness/src/busy.test.ts @@ -14,6 +14,7 @@ import {describe, expect, it} from 'vitest'; import {busyWindowProof} from './busy'; +import * as skip from './busy.skip.fixture'; import {inPresentationOrder, type TrackedFrame} from './frames'; const FRAME = 1000 / 60; @@ -361,6 +362,15 @@ function drawn( return draws; } +const skipFrames = (): TrackedFrame[] => + skip.frames.map( + ([t, y]) => + ({ + t, + box: y == null ? null : {x: 0, y, width: 10, height: 10}, + }) as TrackedFrame, + ); + describe('busyWindowProof with the compositor record', () => { it('with a compositor record, judges a sparse cast from its first to its last moved frame', () => { // One screencast frame in six, as on a loaded machine, with the compositor @@ -402,6 +412,29 @@ describe('busyWindowProof with the compositor record', () => { expect(clean.movedFraction).toBeLessThanOrEqual(1); }); + it('reads frames the screencast skipped while the compositor presented them as presented: a recorded run that fails without the record', () => { + const frames = skipFrames(); + const alone = busyWindowProof(frames, skip.block, {settledWithin: 1}); + expect(alone.verdict.failures.join('\n')).toMatch( + /longest stall 6\d\.\d ms = 4 intervals > 3/, + ); + // Every frame the screencast delivered moved: only the stall remains, + // which the record resolves. + expect(alone.verdict.failures.join('\n')).not.toMatch( + /expected frames moved/, + ); + const proof = busyWindowProof(frames, skip.block, { + settledWithin: 1, + presented: skip.presented, + }); + expect(proof.verdict.failures).toEqual([]); + expect(proof.recorderSkipped).toBe(5); + expect(proof.longestStallFrames).toBeLessThanOrEqual(2); + expect(proof.summary).toMatch( + /the screencast skipped 5 frames the compositor presented \(at \+3\d\d ms, \+4\d\d ms\)/, + ); + }); + it('counts a gap the compositor drew through as skipped, and one it did not draw through as a stall', () => { const k = at(1300); const gap: [number, number] = [ diff --git a/internal/interaction-harness/src/busy.ts b/internal/interaction-harness/src/busy.ts index 20c3d86a1184e..55fd9d670d52a 100644 --- a/internal/interaction-harness/src/busy.ts +++ b/internal/interaction-harness/src/busy.ts @@ -67,6 +67,8 @@ export async function blockMainThread( const minProgress = Math.max(minProgressMs, ONE_FRAME_MS); if (during == null) { return page.evaluate(duration => { + // The harness's own work: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const from = performance.timeOrigin + performance.now(); const end = performance.now() + duration; let spins = 0; @@ -252,8 +254,8 @@ export interface BusyProof extends FrameStats { * no step and read as not moved, which the proof notes (never a failure). * * A loaded machine's screencast can skip frames the compositor still presents - * (Chromium). Given the compositor's record (`presented`: when it presented - * each frame), a gap between two moved frames with no frame + * (Chromium). Given the compositor's record (`presented`, from + * `startCompositorTrace`), a gap between two moved frames with no frame * delivered inside it is measured between the compositor's own frames: it is * a stall only where the compositor presented nothing, and the frames it did * present there are neither stalls nor misses (`recorderSkipped`). A gap @@ -310,8 +312,8 @@ export function busyWindowProof( settledAt?: number; /** * When the compositor presented each frame, epoch ms on the page clock - * (Chromium's `Display::FrameDisplayed`). Empty or absent, the screencast - * is judged alone. + * (`startCompositorTrace`; Chromium). Empty or absent, the screencast is + * judged alone. */ presented?: readonly number[]; } = {}, diff --git a/internal/interaction-harness/src/curves.ts b/internal/interaction-harness/src/curves.ts index 69b9908066f62..4a36629d1f0a7 100644 --- a/internal/interaction-harness/src/curves.ts +++ b/internal/interaction-harness/src/curves.ts @@ -134,17 +134,61 @@ function animationRecorder(): void { return entry.startedAt == null && entry.animation.playState !== 'idle'; }); }; + // While a rendering trace runs (traceRendering sets + // `__harnessRenderingTrace`), the recorder stands down. Its per-frame + // callback would make every frame a main-thread frame, in which Blink + // restyles each running animation's target, a composited one included (and + // lays out an anchored one), and document.getAnimations() flushes style: + // exactly what the trace counts. So it runs no frame callback, and a CSS + // animation or transition event reads only the animations of the element it + // names, once, at that animation's start (one that ends inside the trace is + // otherwise never seen). The animate() wrapper below still records script + // animations (it reads no style), and traceRendering resumes the recorder + // once the trace ends. + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + let looping = false; const tick = () => { + if (tracing()) { + looping = false; + return; + } record(); frame(tick); }; + const loop = () => { + if (!looping) { + looping = true; + frame(tick); + } + }; // What already runs when the recorder starts (a spinner on a page the // harness touches for the first time) is seen now, before any watch arms; // first seen a frame later, it would read as started by the watched action. + // (A recorder that starts during a trace reads once here, then waits.) record(); - frame(tick); + loop(); for (const type of ['animationstart', 'transitionrun', 'transitionstart']) { - document.addEventListener(type, record, true); + document.addEventListener( + type, + event => { + if (!tracing()) { + record(); + return; + } + const target = event.target; + if (target instanceof Element) { + // A pseudo-element's animation (`::backdrop`) is in the subtree. + const pseudo = (event as AnimationEvent | TransitionEvent) + .pseudoElement; + for (const animation of target.getAnimations({subtree: !!pseudo})) { + remember(animation); + } + } + }, + true, + ); } // A script animation is recorded as it is made: WebKit can start and finish // a short one between two frames of a fresh page. @@ -159,12 +203,36 @@ function animationRecorder(): void { }; w.__harnessAnimations = { log, - record, + // A read from outside the recorder (a wait's poll) stands down while a + // trace runs, as the frame loop does: document.getAnimations() flushes + // style. CSS start events and the animate() wrapper still log what + // starts, and resume() catches up. + record: () => { + if (!tracing()) { + record(); + } + }, + // Catches up on what started during a trace, then runs its frames again. + resume: () => { + record(); + loop(); + }, // Record first, then stamp strictly after the newest entry: a step that // changed style just before has its transition created by that read, - // and it started before the arm. - arm: () => { - record(); + // and it started before the arm. While a trace runs the page-wide read + // stands down, as record() does; the watched element's own animations + // are still read, which resolves its pending style change. That read + // restyles the element (and lays out an anchored one) in the task that + // takes the watch, so a traced proof takes its watch before the window + // it counts (README: No Layout / Paint). + arm: (element?: Element) => { + if (!tracing()) { + record(); + } else if (element != null) { + for (const animation of element.getAnimations({subtree: true})) { + remember(animation); + } + } const newest = log.length === 0 ? 0 : log[log.length - 1].seenAt; armedAt = Math.max(performance.now(), newest + 0.001); return armedAt; @@ -197,10 +265,47 @@ async function installCurveEnd(page: Page): Promise { await page.evaluate(() => { const w = window as unknown as { __harnessCurveEnd?: (animation: Animation) => number; + __harnessTiming?: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; + }; + // An effect's timing as getComputedTiming() reports it. Inside a rendering + // trace it is worked out from getTiming() instead: getComputedTiming() + // updates a running animation on demand, which restyles its target in the + // next frame (and lays out an anchored one), exactly what a trace counts. + w.__harnessTiming ??= effect => { + if (effect == null) { + return null; + } + if ( + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace !== true + ) { + return effect.getComputedTiming(); + } + const timing = effect.getTiming(); + const duration = + typeof timing.duration === 'number' ? timing.duration : 0; + const iterations = Number(timing.iterations ?? 1); + const delay = Number(timing.delay ?? 0); + const endDelay = Number(timing.endDelay ?? 0); + const activeDuration = + duration === 0 || iterations === 0 ? 0 : duration * iterations; + const derived: ComputedEffectTiming = { + ...timing, + duration, + iterations, + delay, + endDelay, + activeDuration, + endTime: Math.max(delay + activeDuration + endDelay, 0), + }; + return derived; }; if (w.__harnessCurveEnd) { return; } + const timingOf = w.__harnessTiming; const timingKeys = new Set([ 'offset', 'computedOffset', @@ -216,7 +321,7 @@ async function installCurveEnd(page: Page): Promise { ); w.__harnessCurveEnd = animation => { const effect = animation.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = timingOf(effect); const delay = Number(timing?.delay ?? 0); const active = Number(timing?.activeDuration ?? 0); if ( @@ -826,6 +931,47 @@ export interface ActiveAnimation { readonly finishedAt: number | null; } +/** + * Runs `fn` on `locator`'s element: through the locator, or, `quietly`, + * through a handle to the element it matches now (none yet: `[]`). Each + * locator.evaluate makes a main-thread frame, which a trace counts; + * `elementHandles()` and a handle's evaluate make none. + */ +async function readElement( + locator: Locator, + quietly: boolean, + fn: (element: Element, arg: A) => R[], + arg: A, + timeout: number, +): Promise { + const run = fn as unknown as (element: Element, arg: unknown) => R[]; + if (!quietly) { + return locator.evaluate(run, arg as unknown, {timeout}); + } + const handles = await locator.elementHandles(); + try { + if (handles.length > 1) { + throw new Error( + `waitForAnimation: ${String(locator)} matches ${handles.length} elements`, + ); + } + return handles.length === 0 + ? [] + : await handles[0].evaluate(run, arg as unknown); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } +} + +/** The rejection for a wait whose element is not in the document. */ +function notInDocument(name: string, cause?: unknown): Error { + return new Error( + `${name}: the element is not in the document. An exit that removes ` + + 'its element is read through its container (watch the parent).', + cause === undefined ? undefined : {cause}, + ); +} + /** * Runs a locator read within the wait's own deadline. A locator waits for * its element with no timeout of its own, so one the page removed (an exit @@ -847,11 +993,7 @@ async function withinTimeout( if ((await locator.count()) > 0) { return null; } - throw new Error( - `${name}: the element is not in the document. An exit that removes ` + - 'its element is read through its container (watch the parent).', - {cause: error}, - ); + throw notInDocument(name, error); } throw error; } @@ -876,6 +1018,15 @@ async function withinTimeout( * recorder keeps one entry per `Animation` object, so an animation reversed * or replayed mid-run is misread: a watch taken before the reversal rejects, * and an unwatched wait returns at once with a curve dated before it. + * + * Started inside a `traceRendering` body, it stands down as the recorder + * does, so it adds no frame the trace counts: it takes the element's + * animations from the recorder's log, judges a started one by its own times, + * and reaches the element through a handle to what the locator matches at + * each look. That holds for script animations and CSS transitions. On a CSS + * `@keyframes` animation (and, unwatched, a cancelled CSS transition) it reads + * the animation's timing, which flushes its style and adds Layouts to the + * trace: wait on one with `waitForRest`, or after the trace. */ export async function waitForAnimation( locator: Locator, @@ -891,17 +1042,34 @@ export async function waitForAnimation( } = {}, ): Promise { const deadline = Date.now() + timeout; - await installCurveEnd(locator.page()); + const page = locator.page(); + await installCurveEnd(page); + // Inside a rendering trace the wait stands down (see the page function), + // and it reads the element through handles: each locator.evaluate makes a + // main-thread frame (a Layout of an anchored element), where a handle's + // evaluate adds none. + const traced = await page.evaluate( + () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true, + ); for (;;) { const read = await withinTimeout( 'waitForAnimation', locator, deadline, evaluateTimeout => - locator.evaluate( + readElement( + locator, + traced, (element, {minProgress, since: watchedFrom}) => { + // The harness's own read: a trace sets it apart (trace.ts HARNESS_READ). + console.timeStamp('harness-read'); const w = window as unknown as { __harnessCurveEnd: (a: Animation) => number; + __harnessTiming: ( + effect: AnimationEffect | null | undefined, + ) => ComputedEffectTiming | null; __harnessAnimations: { log: { animation: Animation; @@ -916,6 +1084,13 @@ export async function waitForAnimation( }; const curveEnd = w.__harnessCurveEnd; const recorder = w.__harnessAnimations; + // Inside a rendering trace the wait stands down as the recorder does: + // record() reads nothing, the animations come from the recorder's log + // rather than getAnimations(), and a started one runs by its own + // times, since playState flushes a CSS animation's style. + const traced = + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; recorder.record(); const describe = ( a: Animation, @@ -923,7 +1098,7 @@ export async function waitForAnimation( recordedStart: number | null, ) => { const effect = a.effect as KeyframeEffect | null; - const timing = effect?.getComputedTiming(); + const timing = w.__harnessTiming(effect); // The recorded start stands in once the page has cancelled it. A // run going backward began at its end: its startTime is where its // current time would reach 0, a whole run later. @@ -994,24 +1169,36 @@ export async function waitForAnimation( // finished must not answer for it. Only document-timeline animations // count, as for holdAnimations: a scroll- or view-timeline one moves // only when something scrolls and never progresses on its own. + const runs = (a: Animation) => { + if (!(a.timeline instanceof DocumentTimeline)) { + return false; + } + if (!traced || a.startTime == null) { + return a.playState === 'running'; + } + const time = Number(a.currentTime ?? 0); + return a.playbackRate < 0 + ? time > 0 + : time < Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + }; + const onElement = () => + recorder.log.filter( + e => inside(e.animation) || e.ancestors.includes(element), + ); const live = ( - watchedFrom == null + watchedFrom == null && !traced ? element.getAnimations({subtree: true}) - : recorded + : (watchedFrom == null ? onElement() : recorded) .filter(e => e.finishedAt == null) .map(e => e.animation) - ).filter( - a => - a.playState === 'running' && - a.timeline instanceof DocumentTimeline, - ); + ).filter(runs); const running = live.filter(a => a.startTime != null); // Progress along the curve, on the timeline: a delay presents nothing // (going backward, the end delay plays first), a run going backward // (`reverse()`) progresses from its end, and a faster rate presents // more curve per ms. const progressed = running.filter(a => { - const timing = a.effect?.getComputedTiming(); + const timing = w.__harnessTiming(a.effect); const time = Number(a.currentTime ?? 0); const local = a.playbackRate < 0 @@ -1036,13 +1223,20 @@ export async function waitForAnimation( return e.finishedAt; } const a = e.animation; - if (a.playState !== 'finished' || a.startTime == null) { + if (a.startTime == null) { + return null; + } + const end = Number(w.__harnessTiming(a.effect)?.endTime ?? 0); + // Traced, by its own times; otherwise by its state. + const time = Number(a.currentTime ?? 0); + const done = traced + ? a.playbackRate < 0 + ? time <= 0 + : time >= end + : a.playState === 'finished'; + if (!done) { return null; } - const end = Number( - (a.effect as KeyframeEffect | null)?.getComputedTiming() - .endTime ?? 0, - ); return a.playbackRate < 0 ? Number(a.startTime) : Number(a.startTime) + end / Math.abs(a.playbackRate || 1); @@ -1052,7 +1246,7 @@ export async function waitForAnimation( .map(e => describe(e.animation, finishedAt(e), e.startedAt)); }, {minProgress: minProgressMs, since: since ?? null}, - {timeout: evaluateTimeout}, + evaluateTimeout, ), ); const found = read ?? []; @@ -1061,6 +1255,12 @@ export async function waitForAnimation( return found; } if (Date.now() > deadline) { + // A traced read goes through handles, which find no element rather + // than wait for one: a missing element is reported as missing, as the + // untraced read reports it. + if (traced && (await locator.count()) === 0) { + throw notInDocument('waitForAnimation'); + } throw new Error( 'waitForAnimation: no animation started on the element, none running ' + 'and none recorded since the last wait. If the action is the ' + @@ -1072,13 +1272,31 @@ export async function waitForAnimation( } } -/** Marks now as the point later waits count animations from. */ -async function armRecorder(page: Page): Promise { - return page.evaluate(() => - ( - window as unknown as {__harnessAnimations: {arm: () => number}} - ).__harnessAnimations.arm(), - ); +/** + * Marks now as the point later waits count animations from. With a locator, + * its element's pending style change is resolved first, also inside a trace + * (reached through a handle, which adds no main-thread frame). + */ +async function armRecorder(page: Page, locator?: Locator): Promise { + const handles = locator == null ? [] : await locator.elementHandles(); + try { + if (handles.length === 1) { + return await handles[0].evaluate(element => + ( + window as unknown as { + __harnessAnimations: {arm: (element?: Element) => number}; + } + ).__harnessAnimations.arm(element as Element), + ); + } + return await page.evaluate(() => + ( + window as unknown as {__harnessAnimations: {arm: () => number}} + ).__harnessAnimations.arm(), + ); + } finally { + await Promise.all(handles.map(handle => handle.dispose())); + } } export interface AnimationWatch { @@ -1096,7 +1314,9 @@ export interface AnimationWatch { /** * Starts watching `locator`'s element before an action, so the animation the * action starts is caught even when it ends before the test looks (a short - * entrance on a cold page): + * entrance on a cold page). Taking the watch reads the element's animations, + * which restyles it, inside a rendering trace too: take a traced watch before + * the window the proof counts: * * const watch = await watchAnimations(sheet); * await driver.play(tap(button)); @@ -1106,7 +1326,7 @@ export async function watchAnimations( locator: Locator, ): Promise { await installCurveEnd(locator.page()); - const since = await armRecorder(locator.page()); + const since = await armRecorder(locator.page(), locator); return { started: options => waitForAnimation(locator, {...options, since}), }; @@ -1125,6 +1345,19 @@ export async function watchAnimations( * forward run under a linear or cubic-bézier effect easing); under any other * easing or direction, waitForRest waits the hold out, and a hold longer than * `timeout` rejects with "still moving". + * + * While a rendering trace runs (`traceRendering`), it stands down as the + * animation recorder does: a per-frame callback would make every frame a + * main-thread frame, which the trace counts. It then waits on timers for the + * scheduled ends of the curves on the element instead (a run going backward, + * until it finishes), so motion that no animation drives (a script writing + * styles) is not seen until the trace ends. Its one read of the element's + * animations, as it is called, restyles the element (and lays out an anchored + * one) when an animation is already running there: await it right after the + * action that starts the motion, before the window a proof counts, not in its + * middle (README: No Layout / Paint). Finding no animation, it looks again `frames` times a frame + * apart before settling, so one the action starts a few frames later is + * waited for. */ export async function waitForRest( locator: Locator, @@ -1157,15 +1390,94 @@ export async function waitForRest( backdrop, ].join(' | '); }; - // The page's real frames, even while a timer gate holds its callbacks. - const frame = + // The page's real frames and timers, even while a timer gate holds + // its callbacks: a frozen gate never holds the wait itself. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame: typeof requestAnimationFrame; + timeout?: typeof setTimeout; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? requestAnimationFrame; + const timeout = gate?.timeout ?? setTimeout; + // Set by traceRendering while it traces (see the recorder above). + const tracing = () => + (window as unknown as {__harnessRenderingTrace?: boolean}) + .__harnessRenderingTrace === true; + const settle = () => { + // Later waits count animations from here. ( - window as unknown as { - __harnessTimerGate?: {frame: typeof requestAnimationFrame}; - } - ).__harnessTimerGate?.frame ?? requestAnimationFrame; - let last = read(); + window as unknown as {__harnessAnimations?: {arm: () => number}} + ).__harnessAnimations?.arm(); + resolve(); + }; + let last = ''; let still = 0; + const frames = () => { + last = read(); + still = 0; + frame(tick); + }; + // While traced: one look at the animations, then a timer to the latest + // scheduled curve end. One not started yet is looked at again a frame + // later; a trace that has ended hands over to the per-frame check. + // Finding nothing settles only after `frames` looks a frame apart, as + // the per-frame check needs `frames` still frames: an animation the + // action starts a frame or two later is still waited for. + let idle = 0; + const scheduled = () => { + let wait = 0; + for (const a of element.getAnimations({subtree: true})) { + // A scroll- or view-timeline animation moves only when something + // scrolls: it has no scheduled end to wait for (holdAnimations + // skips it the same way). + if (!(a.timeline instanceof DocumentTimeline)) { + continue; + } + if ( + a.pending || + (a.playState === 'running' && a.startTime == null) + ) { + wait = Math.max(wait, 16); + } else if (a.playState === 'running') { + // The curve's time left through the playback rate: forward, to + // its (held) end; backward (`reverse()`), to local time 0, + // where it finishes, as the untraced path waits it out. + const rate = a.playbackRate || 1; + const at = Number(a.currentTime ?? 0); + const left = + rate > 0 ? (curveEnd(a) - at) / rate : at / -rate; + wait = Math.max(wait, left); + } + } + if (wait <= 0) { + idle += 1; + if (idle >= need) { + settle(); + return; + } + wait = 16; + } else { + idle = 0; + } + const room = deadline - performance.now(); + timeout( + () => { + if (performance.now() > deadline) { + reject( + new Error('waitForRest: still moving at the deadline'), + ); + } else if (tracing()) { + scheduled(); + } else { + frames(); + } + }, + Math.min(wait, Math.max(0, room)) + 1, + ); + }; const tick = () => { const now = read(); const moving = element.getAnimations({subtree: true}).some( @@ -1182,20 +1494,20 @@ export async function waitForRest( still = !moving && now === last ? still + 1 : 0; last = now; if (still >= need) { - // Later waits count animations from here. - ( - window as unknown as { - __harnessAnimations?: {arm: () => number}; - } - ).__harnessAnimations?.arm(); - resolve(); + settle(); } else if (performance.now() > deadline) { reject(new Error(`waitForRest: still moving (${now})`)); + } else if (tracing()) { + scheduled(); } else { frame(tick); } }; - frame(tick); + if (tracing()) { + scheduled(); + } else { + frames(); + } }), {ms: timeout, need: frames}, {timeout: evaluateTimeout}, diff --git a/internal/interaction-harness/src/frames.ts b/internal/interaction-harness/src/frames.ts index c61ea7e171c6f..83841d259c17f 100644 --- a/internal/interaction-harness/src/frames.ts +++ b/internal/interaction-harness/src/frames.ts @@ -7,11 +7,12 @@ * `Screencast` domain) * @output `startScreencast` (timestamped composited frames), `markElement` * (a marker outline the frames can find), `trackMarker` (per-frame marker - * box), `frameStats`. + * box), `sampleRects` (per-rAF rect sampling), `frameStats`. * @position Frame evidence for motion proofs. The screencast is captured off * the page's main thread, from what the compositor presents, so it keeps * delivering frames while a script blocks the main thread: that is what makes - * the busy-main-thread proof possible. + * the busy-main-thread proof possible. Rect sampling is the cheap path when + * the main thread is free. * * Marker tracking needs no image library: frames are decoded and scanned in a * scratch page of the same browser (createImageBitmap + canvas), so the @@ -399,6 +400,71 @@ export async function trackMarker( } } +export interface RectSample { + /** Epoch ms of the rAF callback. */ + readonly t: number; + readonly box: Box | null; + readonly transform: string; + readonly opacity: number; +} + +/** + * Samples the element's rect, transform and opacity on every animation frame + * for `durationMs`. Main-thread sampling: it stops while the main thread is + * blocked, which is the point of the screencast path above. + */ +export async function sampleRects( + locator: Locator, + durationMs: number, +): Promise { + return locator.evaluate( + (element, duration) => + new Promise< + { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] + >(resolve => { + // The harness's own waits run on the real timers: a frozen timer gate + // (story.ts) holds only the page's. + const gate = ( + window as unknown as { + __harnessTimerGate?: { + frame?: typeof requestAnimationFrame; + }; + } + ).__harnessTimerGate; + const frame = gate?.frame ?? window.requestAnimationFrame.bind(window); + const samples: { + t: number; + box: {x: number; y: number; width: number; height: number} | null; + transform: string; + opacity: number; + }[] = []; + const end = performance.now() + duration; + const tick = (now: number) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + samples.push({ + t: performance.timeOrigin + now, + box: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + transform: style.transform, + opacity: Number(style.opacity), + }); + if (now < end) { + frame(tick); + } else { + resolve(samples); + } + }; + frame(tick); + }), + durationMs, + ); +} + export interface FrameStats { readonly frames: number; /** Frames whose tracked value differs from the frame before. */ diff --git a/internal/interaction-harness/src/harness.interaction.browser.spec.ts b/internal/interaction-harness/src/harness.interaction.browser.spec.ts index a6c73fdf88362..b1a50ebc8c9e3 100644 --- a/internal/interaction-harness/src/harness.interaction.browser.spec.ts +++ b/internal/interaction-harness/src/harness.interaction.browser.spec.ts @@ -4,12 +4,17 @@ * @file harness.interaction.browser.spec.ts * @input Fixture pages written inline (no Storybook), real Chromium/WebKit * @output Proof that each harness instrument measures what it claims (the - * busy-main-thread proof with a negative control beside its positive - * check): scripted timestamps reach the + * busy-main-thread proof and the rendering-trace counts with a negative + * control beside their positive checks): scripted timestamps reach the * page; a flick flings natively (Chromium); a second finger and a pinch * arrive as distinct pointers; the screencast shows a compositor animation * advancing through a blocked main thread while a rAF animation freezes; - * sampled curves + * the trace counts zero Layout for a transform animation and one per frame + * for a `top` animation, and none from the harness's own animation recorder + * while an anchored element's composited animation runs (a page that asks + * for a frame every frame lays it out in each); the long-task log records + * the page's own long task but not a DevTools evaluate, which the trace + * counts; sampled curves * match the oracles, individual transform properties included; a sample at * an end reports the time it was read at; holding takes only running * document-timeline animations, each at its own offset, holds each where it @@ -18,23 +23,32 @@ * animation-state waits report a running transition and its end, and judge * rest by presented values even while an animation holds its end, and a * watch taken on first contact ignores an animation already running and - * keeps the start of one the page cancels when it ends; storyboards write a + * keeps the start of one the page cancels when it ends, and the recorder + * reads only at CSS starts while a rendering trace runs and catches up + * after it; + * storyboards write a * sheet and a trace that follows the curve, an endless animation does not * set their length, and a photograph Chromium returns empty is taken again * two frames later; a touch filmstrip keeps scripted event times while a * handler clock sees its pacing; the timer gate holds a component timer * until the filmstrip is done, drops callbacks the page cancels, stamps held - * frames with the time they run, and leaves the harness its own frames. + * frames with the time they run, and leaves the harness its own frames; a + * cast filmstrip keeps exactly the presented frames of its window, labelled + * from its zero, as PNG. * @position The harness's own test. A component spec trusts these * instruments; this file is why it may. */ +import fs from 'node:fs'; +import path from 'node:path'; import {expect, test, type Page} from '@playwright/test'; import { animationStoryboard, + castStoryboard, blockMainThread, busyWindowProof, compareCurve, + countBetween, cubicBezier, curveOf, drag, @@ -43,6 +57,7 @@ import { freezeTimers, holdAnimations, installInputLog, + installLongTaskLog, installTimerGate, markElement, merge, @@ -50,10 +65,13 @@ import { PARTIAL_LIFT_SKIP, pinch, readInputLog, + readLongTasks, releaseAnimations, sampleCurve, + sampleRects, seekAnimations, springStep, + startCompositorTrace, startScreencast, Storyboard, tap, @@ -61,6 +79,7 @@ import { timeline, touchDriver, touchStoryboard, + traceRendering, trackMarker, waitForAnimation, waitForRest, @@ -68,6 +87,16 @@ import { withFinger, } from './index'; +// An element positioned against an anchor: a composited animation of it lays +// out in every main-thread frame, so it shows any per-frame main-thread work. +const ANCHORED = ` + +
`; + const FIXTURE = `
', + ); + await expect(startCompositorTrace(page)).rejects.toThrow( + /another page is open as the trace starts/, + ); + await other.close(); + // A page opened mid-trace: stop() refuses too, whether it is still open + // or closed again before the record stops. + const record = await startCompositorTrace(page); + const late = await page.context().newPage(); + await expect(record.stop()).rejects.toThrow( + /another page opened during the record/, + ); + await late.close(); + const brief = await startCompositorTrace(page); + const spinner = await page.context().newPage(); + await spinner.setContent( + '
', + ); + await page.waitForTimeout(200); + await spinner.close(); + await expect(brief.stop()).rejects.toThrow( + /another page opened during the record/, + ); + // In another context too. + const browser = page.context().browser(); + if (browser != null) { + const elsewhere = await startCompositorTrace(page); + const context = await browser.newContext(); + await (await context.newPage()).close(); + await context.close(); + await expect(elsewhere.stop()).rejects.toThrow( + /another page opened during the record/, + ); + } + // Alone, it records. + const alone = await startCompositorTrace(page); + await page.waitForTimeout(100); + await expect(alone.stop()).resolves.toBeInstanceOf(Array); + }); + + test('the long-task log fails closed when it never ran on the document', async ({ + page, + }) => { + // No installLongTaskLog before navigating: an empty answer would read as + // "no long task", so it throws instead. + await fixture(page); + await expect(readLongTasks(page)).rejects.toThrow( + /not running on this document/, + ); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); }); test.describe('curve sampling', () => { @@ -1143,6 +1905,91 @@ test.describe('animation state', () => { expect(started.map(a => a.durationMs)).toEqual([300]); }); + test('while a rendering trace runs the recorder reads only at CSS starts, and catches up after it', async ({ + page, + }) => { + await fixture(page); + const box = page.locator('#box'); + const watch = await watchAnimations(box); + // Counts the recorder's reads of the page's animations (each one flushes + // style): the whole document's, and one element's. Then flags a trace, as + // traceRendering does. + await page.evaluate(() => { + const w = window as unknown as { + __reads: number; + __elementReads: number; + __harnessRenderingTrace?: boolean; + }; + w.__reads = 0; + w.__elementReads = 0; + const read = document.getAnimations.bind(document); + document.getAnimations = () => { + w.__reads += 1; + return read(); + }; + const readElement = Element.prototype.getAnimations; + Element.prototype.getAnimations = function ( + this: Element, + ...args: Parameters + ) { + w.__elementReads += 1; + return readElement.apply(this, args); + }; + w.__harnessRenderingTrace = true; + }); + // During the trace: a CSS transition and a CSS animation that both end + // inside it, and a script animation still running after it. + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + await box.evaluate(el => { + const box = el as HTMLElement; + box.style.transition = 'transform 120ms linear'; + void box.offsetWidth; + box.style.transform = 'translateX(100px)'; + box.style.animation = 'harness-fade 120ms linear'; + box.animate([{scale: '1'}, {scale: '0.9'}], {duration: 600}); + }); + await page.waitForTimeout(300); + const reads = () => + page.evaluate(() => (window as unknown as {__reads: number}).__reads); + // No frame loop: no document-wide read. One element read per CSS start + // event (transitionrun, transitionstart, animationstart), not per frame. + expect(await reads()).toBe(0); + expect( + await page.evaluate( + () => (window as unknown as {__elementReads: number}).__elementReads, + ), + ).toBeLessThanOrEqual(3); + // The trace ends: traceRendering clears the flag and resumes the recorder, + // which reads again, every frame. + await page.evaluate(() => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations: {resume: () => void}; + }; + w.__harnessRenderingTrace = false; + w.__harnessAnimations.resume(); + }); + await frames(page, 3); + expect(await reads()).toBeGreaterThan(2); + // All three are reported, the two CSS ones that ended inside the trace + // with the time they finished. + await waitForRest(box); + const started = await watch.started(); + expect(started.map(a => a.kind).sort()).toEqual([ + 'Animation', + 'CSSAnimation', + 'CSSTransition', + ]); + expect( + started + .filter(a => a.kind !== 'Animation') + .map(a => a.finishedAt != null), + ).toEqual([true, true]); + }); + test('a wait answers with the animation the action started, not one that finished before it', async ({ page, }) => { @@ -1207,6 +2054,28 @@ test.describe('animation state', () => { await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); }); + test('inside a rendering trace too, a watch taken right after a style change does not report that change', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + await fixture(page); + const box = page.locator('#box'); + await waitForRest(box); + await traceRendering(page, async () => { + // The step: a transition the next style read creates. + await box.evaluate(el => { + el.style.transition = 'opacity 300ms linear'; + el.style.opacity = '0.5'; + }); + const watch = await watchAnimations(box); + await expect(watch.started({timeout: 200})).rejects.toThrow( + /waitForAnimation/, + ); + }); + await box.evaluate(el => el.getAnimations().forEach(a => a.finish())); + }); + test('an exit whose element the page removes at its end is still reported', async ({ page, }) => { @@ -1258,6 +2127,43 @@ test.describe('animation state', () => { } }); + test('inside a rendering trace too, a wait on an element no longer in the document rejects as missing', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + await page.evaluate(() => { + const gone = document.createElement('div'); + gone.id = 'gone'; + document.body.append(gone); + }); + const gone = page.locator('#gone'); + const watch = await watchAnimations(gone); + await page.evaluate(() => document.getElementById('gone')?.remove()); + const outcomes: string[] = []; + await traceRendering(page, async () => { + for (const wait of [ + () => watch.started({timeout: 300}), + () => waitForAnimation(gone, {timeout: 300}), + ]) { + outcomes.push( + await wait().then( + () => 'resolved', + (error: Error) => error.message, + ), + ); + } + }); + expect(outcomes).toHaveLength(2); + for (const outcome of outcomes) { + expect(outcome).toMatch(/not in the document/); + } + }); + test('a watch reports only what started after it', async ({page}) => { await fixture(page); const box = page.locator('#box'); @@ -1611,7 +2517,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); - test("the harness's own waits run on the real timers: a frozen timer gate does not hold blockMainThread's block", async ({ + test("the harness's own waits run on the real timers: a frozen timer gate holds neither blockMainThread's block nor sampleRects", async ({ page, }) => { await fixture(page); @@ -1635,6 +2541,7 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect( await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); await thawTimers(page); }); @@ -1823,4 +2730,58 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect(state).toMatchObject({timer: false, frame: false}); expect(state.heldFor).toBeGreaterThanOrEqual(140); }); + + test('a cast filmstrip keeps exactly the presented frames of its window, labelled from its zero', async ({ + page, + }, testInfo) => { + await fixture(page); + const box = page.locator('#box'); + // JPEG frames (WebKit's are always JPEG) take the conversion path. + const cast = await startScreencast(page, {format: 'jpeg'}); + await box.evaluate(el => + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600}, + ), + ); + await page.waitForTimeout(500); + const presented = await cast.stop(); + expect(presented.length).toBeGreaterThan(8); + // A window of the middle frames, zeroed on one of them. + const zero = presented[2].t; + const from = presented[3].t; + const to = presented[presented.length - 3].t; + const inside = presented.filter(f => f.t >= from && f.t <= to); + const seen: number[] = []; + const film = await castStoryboard(page, presented, { + name: 'harness-self-test-cast', + zero, + from, + to, + data: t => { + seen.push(t); + return {t}; + }, + }); + expect(film.frames.map(f => f.t)).toEqual(inside.map(f => f.t - zero)); + expect(film.frames.map(f => f.label)).toEqual( + inside.map(f => `t=${(f.t - zero).toFixed(1)} ms`), + ); + expect(seen).toEqual(inside.map(f => f.t)); + // Every cell is a PNG, whatever the screencast delivered. + for (const frame of film.frames) { + expect([...frame.png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + } + const files = await film.write(testInfo); + expect(files['sheet.png']).toMatch(/sheet\.png$/); + // A window with no presented frame in it films nothing. + const empty = await castStoryboard(page, presented, { + name: 'harness-self-test-cast-empty', + zero, + from: to + 1, + to: to + 2, + }); + expect(empty.frames).toEqual([]); + await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); + }); }); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 2bcb127f36d88..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -21,3 +21,4 @@ export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 5a1f2237c6725..cb57d59b50be2 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -6,7 +6,8 @@ * static server over a built Storybook * @output `phone` (the touch-phone browser context every interaction proof * runs in), `withStorybook` (serve the built Storybook for a spec file), and - * `openStory` (load one story and wait until it has rendered). + * `openStory` (load one story, with Storybook's highlight module off, and + * wait until it has rendered). * @position Stories are the reproduction path, as for every other browser * check in this repository: a scenario used for verification is checked in, * not built in a one-off page. @@ -59,13 +60,47 @@ export function withStorybook(test: TestType): () => string { }; } -/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +/** Pages whose Storybook highlight module `openStory` turned off. */ +const highlightOff = new WeakSet(); + +export interface OpenStoryOptions { + /** + * Keeps Storybook's highlight module on. It is off by default: when on, it + * observes `#storybook-root` and, on every insertion, reads the computed + * style of every element on the page inside the inserting task, so a proof + * would measure Storybook as well as the component. + */ + readonly storybookHighlight?: boolean; +} + +/** + * Loads a story in the page and waits for its first render, its fonts and two + * frames. Storybook's highlight module is off (see `OpenStoryOptions`), and + * that is checked: its root element must be absent once the story renders. + */ export async function openStory( page: Page, origin: string, storyId: string, args?: Readonly>, + {storybookHighlight = false}: OpenStoryOptions = {}, ): Promise { + if (storybookHighlight) { + if (highlightOff.has(page)) { + throw new Error( + "openStory: this page already turned Storybook's highlight module off; open the story in a new page to keep it on", + ); + } + } else if (!highlightOff.has(page)) { + highlightOff.add(page); + // Storybook's highlight module returns at once when this is set before + // its preview runs. + await page.addInitScript(() => { + ( + globalThis as unknown as {__STORYBOOK_HIGHLIGHT_INITIALIZED?: boolean} + ).__STORYBOOK_HIGHLIGHT_INITIALIZED = true; + }); + } const params = new URLSearchParams({id: storyId, viewMode: 'story'}); if (args != null) { params.set( @@ -86,6 +121,14 @@ export async function openStory( requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); + if ( + !storybookHighlight && + (await page.locator('#storybook-highlights-root').count()) > 0 + ) { + throw new Error( + "openStory: Storybook's highlight module is on (#storybook-highlights-root exists), so every insertion would also read the style of every element on the page; this Storybook may no longer read __STORYBOOK_HIGHLIGHT_INITIALIZED", + ); + } } /** Resolves after `count` animation frames have been presented in the page. */ @@ -110,8 +153,9 @@ export async function frames(page: Page, count = 2): Promise { // A held callback is queued under its timer or frame id, so the page's own // clearTimeout and cancelAnimationFrame still cancel it while frozen; a held -// frame callback gets the time it actually runs at. `frame` stays the real -// requestAnimationFrame, for the harness's own waits. +// frame callback gets the time it actually runs at. `frame` and `timeout` +// stay the real requestAnimationFrame and setTimeout, for the harness's own +// waits. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); diff --git a/internal/interaction-harness/src/storyboard.ts b/internal/interaction-harness/src/storyboard.ts index 2dc22fdc8cf04..a49207c4f81c4 100644 --- a/internal/interaction-harness/src/storyboard.ts +++ b/internal/interaction-harness/src/storyboard.ts @@ -336,7 +336,8 @@ export interface TouchStoryboardOptions extends StoryboardOptions { * photograph per event, about 100 ms between moves instead of the script's * 16.7. Its release velocity is that much lower, so a flick that dismisses * when played can settle back when filmed: a storyboard cannot show such a - * gesture; record it in real time instead. + * gesture; record it in real time instead (`startScreencast`, + * `castStoryboard`). */ export async function touchStoryboard( page: Page, @@ -364,3 +365,65 @@ export async function touchStoryboard( }); return storyboard; } + +/** + * A filmstrip of screencast frames (real presented frames, e.g. the frames of + * a busy-main-thread window), labelled with their time relative to `zero`. + * Evidence for what the compositor presented, not a deterministic storyboard. + */ +export async function castStoryboard( + page: Page, + frames: readonly { + readonly t: number; + readonly data: string; + readonly format: 'png' | 'jpeg'; + }[], + // A cast is already the screen's frames: no clip is applied to them. + options: Omit & { + readonly zero: number; + readonly from?: number; + readonly to?: number; + readonly data?: (t: number) => Readonly>; + }, +): Promise { + const storyboard = new Storyboard(page, options); + for (const frame of frames) { + if (options.from != null && frame.t < options.from) { + continue; + } + if (options.to != null && frame.t > options.to) { + continue; + } + const png = + frame.format === 'png' + ? Buffer.from(frame.data, 'base64') + : await toPng(page, frame.data); + const t = frame.t - options.zero; + storyboard.frames.push({ + label: `t=${t.toFixed(1)} ms`, + t, + data: options.data?.(frame.t) ?? {}, + png, + }); + } + return storyboard; +} + +async function toPng(page: Page, jpegBase64: string): Promise { + const scratch = await page.context().newPage(); + try { + const url = await scratch.evaluate(async data => { + const bitmap = await createImageBitmap( + await (await fetch(`data:image/jpeg;base64,${data}`)).blob(), + ); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')?.drawImage(bitmap, 0, 0); + return canvas.toDataURL('image/png'); + }, jpegBase64); + return Buffer.from(url.slice(url.indexOf(',') + 1), 'base64'); + } finally { + await scratch.close(); + } +} diff --git a/internal/interaction-harness/src/storybook.interaction.browser.spec.ts b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a711feb62cb84 --- /dev/null +++ b/internal/interaction-harness/src/storybook.interaction.browser.spec.ts @@ -0,0 +1,104 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file storybook.interaction.browser.spec.ts + * @input The built Storybook (`withStorybook`) and one of its stories + * @output `openStory` turns Storybook's highlight module off, so an insertion + * in a story carries no `getComputedStyle` scan of the page; with the module + * on (the control), every insertion carries one. Both engines; Chromium's + * trace also counts the style recalculations the scan forces + * @position Self-test for story.ts; it needs the built Storybook, as every + * component proof does + */ + +import {expect, test, type Page} from '@playwright/test'; +import {openStory, withStorybook} from './story'; +import {countBetween, traceRendering} from './trace'; + +const storybook = withStorybook(test); + +/** Counts the page's `getComputedStyle` calls, from before Storybook loads. */ +function countStyleReads(): void { + const w = window as unknown as {__styleReads: number}; + w.__styleReads = 0; + const read = window.getComputedStyle.bind(window); + window.getComputedStyle = ((element: Element, pseudo?: string | null) => { + w.__styleReads += 1; + return read(element, pseudo); + }) as typeof window.getComputedStyle; +} + +/** + * Inserts an exit copy of the story's first element into `#storybook-root`, + * as an exit animation's copy is, lets the insertion's mutation microtasks run, + * and reports what ran between its marks. + */ +async function insertCopy(page: Page) { + return page.evaluate(async () => { + const w = window as unknown as {__styleReads: number}; + const root = document.getElementById('storybook-root') as HTMLElement; + const reads = w.__styleReads; + const from = + performance.timeOrigin + performance.mark('copy:start').startTime; + const copy = (root.firstElementChild as HTMLElement).cloneNode( + true, + ) as HTMLElement; + copy.setAttribute('aria-hidden', 'true'); + root.appendChild(copy); + // A MutationObserver's callback is a microtask the insertion queued: + // these run after it. + await Promise.resolve(); + await Promise.resolve(); + const to = performance.timeOrigin + performance.mark('copy:end').startTime; + const scanned = w.__styleReads - reads; + const elements = document.body.querySelectorAll('*').length; + copy.remove(); + return {from, to, scanned, elements}; + }); +} + +test("openStory turns Storybook's highlight module off: an insertion in a story carries no getComputedStyle scan of the page", async ({ + page, + browserName, +}) => { + // The insertion's reads in every engine, and in Chromium (whose trace the + // harness reads) the style recalculations between its marks. + const measure = async (p: Page) => { + if (browserName !== 'chromium') { + return {copy: await insertCopy(p), styleRecalc: null}; + } + let copy = {from: 0, to: 0, scanned: 0, elements: 0}; + const trace = await traceRendering(p, async () => { + copy = await insertCopy(p); + }); + return { + copy, + styleRecalc: + countBetween(trace, copy.from, copy.to).UpdateLayoutTree ?? 0, + }; + }; + // The harness default. + await page.addInitScript(countStyleReads); + await openStory(page, storybook(), 'core-button--primary'); + expect(await page.locator('#storybook-highlights-root').count()).toBe(0); + const off = await measure(page); + expect(off.copy.scanned).toBe(0); + if (off.styleRecalc != null) { + expect(off.styleRecalc).toBe(0); + } + + // Control: Storybook's module kept on reads the style of every element on + // the page inside the inserting task. + const control = await page.context().newPage(); + await control.addInitScript(countStyleReads); + await openStory(control, storybook(), 'core-button--primary', undefined, { + storybookHighlight: true, + }); + expect(await control.locator('#storybook-highlights-root').count()).toBe(1); + const on = await measure(control); + expect(on.copy.scanned).toBeGreaterThanOrEqual(on.copy.elements); + if (on.styleRecalc != null) { + expect(on.styleRecalc).toBeGreaterThan(0); + } + await control.close(); +}); diff --git a/internal/interaction-harness/src/trace.ts b/internal/interaction-harness/src/trace.ts new file mode 100644 index 0000000000000..ce6a3d853d2cd --- /dev/null +++ b/internal/interaction-harness/src/trace.ts @@ -0,0 +1,466 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file trace.ts + * @input Uses a Playwright `Page` (Chromium: CDP `Tracing`) and the page's + * PerformanceObserver for long tasks + * @output `traceRendering` — counts of the main thread's rendering work + * (Layout, Paint, UpdateLayoutTree, PrePaint, …) between two marks — and + * `installLongTaskLog` / `readLongTasks`. + * @position The "no Layout/Paint during a transform/opacity animation" proof. + * A compositor animation costs the main thread nothing per frame; one that + * animates a layout property, or writes styles from JavaScript each frame, + * shows a Layout (and usually a Paint) per frame. Chromium only: WebKit + * exposes no comparable trace to Playwright. + * + * Counting is scoped to the renderer main thread that ran the page's own + * `performance.mark` calls, and to the time between the start and end marks, + * so other pages, the browser process and setup work are excluded. + * + * SYNC: Keep RENDERING_EVENTS equal to the README's list. + */ + +import type {CDPSession, Page} from '@playwright/test'; + +/** + * The mark a harness function puts on its own page-side work + * (`console.timeStamp('harness-read')` at the start of the function it + * evaluates in the page), so a trace can tell the harness's reads from page + * code a test's own evaluate runs. + */ +export const HARNESS_READ = 'harness-read'; + +/** Trace events that run page script: a listener, a timer, a frame callback. */ +const PAGE_SCRIPT_EVENTS = new Set([ + 'EventDispatch', + 'FunctionCall', + 'TimerFire', + 'FireAnimationFrame', + 'FireIdleCallback', +]); + +/** Main-thread trace events that are rendering work, by their trace names. */ +export const RENDERING_EVENTS = [ + 'UpdateLayoutTree', // style recalculation + 'Layout', + 'PrePaint', + 'Paint', + 'Layerize', + 'UpdateLayer', + 'ScrollLayer', + 'HitTest', +] as const; + +export interface RenderingTrace { + /** Count per trace event name inside the window, on the page's main thread. */ + readonly counts: Readonly>; + /** Shorthand for the four that matter most. */ + readonly layout: number; + readonly paint: number; + readonly styleRecalc: number; + readonly prePaint: number; + /** Window length, ms. */ + readonly durationMs: number; + /** + * The page's own main-thread tasks longer than 50 ms inside the window (long + * tasks), epoch ms. A task that ran a test's own DevTools command (a + * `page.evaluate`) is one, whether it calls page code directly or sets it + * off; only the harness's own marked reads are in `devtoolsTasks`. + */ + readonly longTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** + * Main-thread tasks longer than 50 ms inside the window that ran a DevTools + * command the harness marked as its own read (`HARNESS_READ`) and no page + * script, epoch ms: `waitForAnimation` describing every animation's + * keyframes, or `blockMainThread(page, ms)` without `during`. + */ + readonly devtoolsTasks: readonly { + readonly start: number; + readonly durationMs: number; + }[]; + /** Every counted rendering event with its epoch-ms time, for sub-window counts. */ + readonly events: readonly {readonly name: string; readonly t: number}[]; + /** The window, epoch ms on the page's clock. */ + readonly window: {readonly from: number; readonly to: number}; +} + +/** Counts rendering events by name inside `[from, to]` (epoch ms). */ +export function countBetween( + trace: RenderingTrace, + from: number, + to: number, +): Record { + const counts: Record = {}; + for (const e of trace.events) { + if (e.t >= from && e.t <= to) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + } + } + return counts; +} + +interface TraceEvent { + name: string; + cat: string; + ph: string; + ts: number; + dur?: number; + pid: number; + tid: number; + args?: Record; +} + +/** + * Traces the page while `body` runs and counts the rendering work its main + * thread did between a start mark (placed just before `body`) and an end mark + * (just after). The harness's animation recorder, `waitForRest`, + * `waitForAnimation` and a watch's `started()` stand down meanwhile, so a body + * may wait with any of them and add no frame of its own, with one gap: on a + * CSS `@keyframes` animation (and, for an unwatched `waitForAnimation`, a + * cancelled CSS transition) the wait reads its timing, which flushes that + * animation's style, so wait on such an animation with `waitForRest`, or + * after the trace. Taking a watch, or calling `waitForRest` while an + * animation already runs on the element, reads its animations once, which + * restyles it: do either before the window a proof counts. + */ +export async function traceRendering( + page: Page, + body: () => Promise, +): Promise { + const cdp: CDPSession = await page.context().newCDPSession(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'blink.user_timing', + 'toplevel', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-${Math.random().toString(36).slice(2, 8)}`; + const mark = (name: string) => + page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + name, + ); + // The harness's animation recorder (curves.ts) runs a callback every frame, + // which makes each frame a main-thread one where Blink restyles every running + // animation's target (and lays out an anchored one), and it reads the page's + // animations, which flushes style: exactly what this counts. It stands down + // while this flag is set, a recorder the body itself starts included, and + // resumes once the trace ends. + const standDown = (on: boolean) => + page.evaluate(value => { + const w = window as unknown as { + __harnessRenderingTrace?: boolean; + __harnessAnimations?: {resume?: () => void}; + }; + w.__harnessRenderingTrace = value; + if (!value) { + w.__harnessAnimations?.resume?.(); + } + }, on); + await standDown(true); + let fromEpoch: number; + let toEpoch: number; + try { + fromEpoch = await mark(`${tag}:start`); + await body(); + toEpoch = await mark(`${tag}:end`); + } finally { + // Its catch-up read lands after the end mark, outside the counted window. + // A failure here would hide the body's own error, which is the one to see. + await standDown(false).catch(() => {}); + } + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + + const start = events.find(e => e.name === `${tag}:start`); + const end = events.find(e => e.name === `${tag}:end`); + if (start == null || end == null) { + throw new Error('traceRendering: the window marks were not traced'); + } + const onMain = (e: TraceEvent) => + e.pid === start.pid && + e.tid === start.tid && + e.ts >= start.ts && + e.ts <= end.ts; + // Trace time (µs, monotonic) -> epoch ms, anchored on the start mark. + const toEpochMs = (ts: number) => fromEpoch + (ts - start.ts) / 1000; + const counts: Record = {}; + const counted: {name: string; t: number}[] = []; + for (const e of events) { + if (!onMain(e) || e.ph === 'E') { + continue; + } + if ((RENDERING_EVENTS as readonly string[]).includes(e.name)) { + counts[e.name] = (counts[e.name] ?? 0) + 1; + counted.push({name: e.name, t: toEpochMs(e.ts)}); + } + } + // A task that ran a DevTools command received it over the DevTools + // session's pipe; page script it set off shows as its listener, timer or + // frame callback. + const main = events.filter(e => onMain(e) && e.ph !== 'E'); + const longTasks: {start: number; durationMs: number}[] = []; + const devtoolsTasks: {start: number; durationMs: number}[] = []; + for (const task of main) { + if (task.name !== 'RunTask' || (task.dur ?? 0) <= 50_000) { + continue; + } + const end = task.ts + (task.dur ?? 0); + const inside = main.filter( + e => e !== task && e.ts >= task.ts && e.ts <= end, + ); + const devtools = inside.some( + e => + e.name === 'Receive mojo message' && + ( + e.args?.chrome_mojo_event_info as + {mojo_interface_tag?: unknown} | undefined + )?.mojo_interface_tag === 'blink.mojom.DevToolsSession', + ); + // Only the harness's own reads are set apart: a test's evaluate may call + // page code directly, which leaves no listener or timer in the trace. + const harnessRead = inside.some( + e => + e.name === 'TimeStamp' && + (e.args?.data as {message?: unknown} | undefined)?.message === + HARNESS_READ, + ); + const pageScript = inside.some(e => PAGE_SCRIPT_EVENTS.has(e.name)); + (devtools && harnessRead && !pageScript ? devtoolsTasks : longTasks).push({ + start: toEpochMs(task.ts), + durationMs: (task.dur ?? 0) / 1000, + }); + } + return { + counts, + layout: counts.Layout ?? 0, + paint: counts.Paint ?? 0, + styleRecalc: counts.UpdateLayoutTree ?? 0, + prePaint: counts.PrePaint ?? 0, + durationMs: (end.ts - start.ts) / 1000, + longTasks, + devtoolsTasks, + events: counted, + window: {from: fromEpoch, to: toEpoch}, + }; +} + +/** + * The browser's open pages. The compositor record is browser-wide: its + * `Display::FrameDisplayed` events carry no page identity. + */ +function openPages(page: Page): Page[] { + const browser = page.context().browser(); + return browser == null + ? page.context().pages() + : browser.contexts().flatMap(context => context.pages()); +} + +/** + * Watches for a page that opens while the record runs, in any context (it + * may close again before the record stops). Returns whether one opened. + */ +async function watchForOtherPages( + page: Page, +): Promise<{stop(): Promise}> { + let opened = false; + const onPage = (other: Page) => { + if (other !== page) { + opened = true; + } + }; + // The page's own context, through Playwright (a persistent context has no + // browser to ask). + page.context().on('page', onPage); + // Every context, through the browser's target discovery: a page in a + // context created during the record is a new target too. + let targets: CDPSession | null = null; + const browser = page.context().browser(); + if (browser != null) { + try { + targets = await browser.newBrowserCDPSession(); + const {targetInfos} = await targets.send('Target.getTargets'); + const known = new Set(targetInfos.map(info => info.targetId)); + targets.on('Target.targetCreated', ({targetInfo}) => { + if (targetInfo.type === 'page' && !known.has(targetInfo.targetId)) { + opened = true; + } + }); + await targets.send('Target.setDiscoverTargets', {discover: true}); + } catch { + targets = null; + } + } + return { + async stop() { + page.context().off('page', onPage); + await targets?.detach().catch(() => {}); + return opened; + }, + }; +} + +function assertOnlyPage(page: Page, when: string): void { + if (openPages(page).some(other => other !== page && !other.isClosed())) { + throw new Error( + `startCompositorTrace: another page is open ${when}; the compositor record is browser-wide (its frames carry no page identity), so another page's frames would be credited to this one`, + ); + } +} + +/** + * Records when the compositor presents each frame, for `busyWindowProof`'s + * `presented`: a Chromium trace of `Display::FrameDisplayed`, on the page + * clock. Start it with the screencast, before the block, and stop it after. + * Engines without a CDP trace (WebKit) resolve `stop()` to [], and the + * verdict then judges the screencast alone. It runs its own trace, so not + * inside `traceRendering`. The record is browser-wide, so it throws when + * another page is open as it starts or stops, or opened while it ran. + */ +export async function startCompositorTrace( + page: Page, +): Promise<{stop(): Promise}> { + let cdp: CDPSession; + try { + cdp = await page.context().newCDPSession(page); + } catch { + return {stop: async () => []}; + } + try { + assertOnlyPage(page, 'as the trace starts'); + } catch (error) { + await cdp.detach().catch(() => {}); + throw error; + } + const others = await watchForOtherPages(page); + const events: TraceEvent[] = []; + cdp.on('Tracing.dataCollected', ({value}) => { + events.push(...(value as unknown as TraceEvent[])); + }); + const complete = new Promise(resolve => + cdp.once('Tracing.tracingComplete', () => resolve()), + ); + await cdp.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { + includedCategories: [ + 'disabled-by-default-display.framedisplayed', + 'blink.user_timing', + ], + excludedCategories: ['*'], + }, + }); + const tag = `harness-compositor-${Math.random().toString(36).slice(2, 8)}`; + const epoch = await page.evaluate( + n => performance.timeOrigin + performance.mark(n).startTime, + tag, + ); + return { + async stop() { + await cdp.send('Tracing.end'); + await complete; + await cdp.detach().catch(() => {}); + if (await others.stop()) { + throw new Error( + "startCompositorTrace: another page opened during the record; the compositor record is browser-wide (its frames carry no page identity), so that page's frames would be credited to this one", + ); + } + assertOnlyPage(page, 'as the trace stops'); + const mark = events.find(e => e.name === tag); + if (mark == null) { + throw new Error('startCompositorTrace: the clock mark was not traced'); + } + // Trace time (µs, monotonic) -> epoch ms, anchored on the mark. + return events + .filter(e => e.name === 'Display::FrameDisplayed') + .map(e => epoch + (e.ts - mark.ts) / 1000) + .sort((a, b) => a - b); + }, + }; +} + +const LONG_TASK_SCRIPT = `(() => { + if (window.__harnessLongTasks) return; + const tasks = []; + const log = {tasks, supported: false}; + window.__harnessLongTasks = log; + if (typeof PerformanceObserver === 'undefined' || !(PerformanceObserver.supportedEntryTypes || []).includes('longtask')) return; + try { + new PerformanceObserver(list => { + for (const entry of list.getEntries()) tasks.push({start: performance.timeOrigin + entry.startTime, durationMs: entry.duration, name: entry.name}); + }).observe({type: 'longtask', buffered: true}); + log.supported = true; + } catch {} +})()`; + +/** + * Records every long task (> 50 ms) the page runs (install before navigation; + * Chromium), through the Long Tasks API. That sees the tasks of the page's own + * event loop: a timer, an event handler, `blockMainThread(page, ms, {during})`. + * A DevTools `page.evaluate`, such as `blockMainThread(page, ms)` without + * `during`, is not one of them and is never recorded here; `traceRendering` + * reports it in `devtoolsTasks`, apart from the page's `longTasks`. + */ +export async function installLongTaskLog(page: Page): Promise { + await page.addInitScript(LONG_TASK_SCRIPT); +} + +export interface LongTask { + /** Epoch ms. */ + readonly start: number; + readonly durationMs: number; + readonly name: string; +} + +/** + * Long tasks recorded since navigation, optionally only those overlapping a + * window. Fails closed: it throws when the log never ran on this document + * (installed after the page navigated) or the engine has no Long Tasks API + * (WebKit), so an empty answer is evidence of no long task, never of no log. + */ +export async function readLongTasks( + page: Page, + range?: {from: number; to: number}, +): Promise { + const log = await page.evaluate( + () => + ( + window as unknown as { + __harnessLongTasks?: {tasks: LongTask[]; supported: boolean}; + } + ).__harnessLongTasks ?? null, + ); + if (log == null) { + throw new Error( + 'readLongTasks: the long-task log is not running on this document; call installLongTaskLog(page) before navigating', + ); + } + if (!log.supported) { + throw new Error( + 'readLongTasks: this engine has no Long Tasks API, so the log records nothing', + ); + } + const tasks = log.tasks; + return range == null + ? tasks + : tasks.filter( + t => t.start < range.to && t.start + t.durationMs > range.from, + ); +} diff --git a/internal/test-utils/src/setup.ts b/internal/test-utils/src/setup.ts index 440b32ff22b3c..991bb056f0816 100644 --- a/internal/test-utils/src/setup.ts +++ b/internal/test-utils/src/setup.ts @@ -12,6 +12,8 @@ /// import '@testing-library/jest-dom/vitest'; import {configure} from '@testing-library/react'; +import {afterEach} from 'vitest'; +import {__resetClickGuardForTest} from '../../../packages/core/src/utils/clickGuard'; // Text queries (getByText/findByText/…) target VISIBLE text. Astryx's // `useAnnounce` renders a visually-hidden aria-live region @@ -25,6 +27,15 @@ import {configure} from '@testing-library/react'; // Keeps the jsdom defaults (script, style). configure({defaultIgnore: 'script, style, [data-astryx-live-region]'}); +// The click guard (packages/core/src/utils/clickGuard.ts) is armed per +// document by a gesture that already acted and disarms itself 400 ms later. +// A test that ends inside that window would leave the timer to fire after +// the environment tears its window down, during a later file. Disarm every +// guard as each test ends. +afterEach(() => { + __resetClickGuardForTest(); +}); + // Polyfill for matchMedia (not supported in jsdom). // // `(hover: hover)` reports TRUE: jsdom models a desktop browser driven by a diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/core/locales/en.json b/packages/core/locales/en.json index a0e946101a597..d58dd428e5974 100644 --- a/packages/core/locales/en.json +++ b/packages/core/locales/en.json @@ -443,6 +443,10 @@ "defaultMessage": "Back to {parent}", "description": "Menu row that returns from a drilled-in sub-menu to the menu it came from. {parent} is that menu's name (e.g. \"Back to Actions\"). Imperative navigation action." }, + "@astryx.dropdownMenu.subMenuHeader": { + "defaultMessage": "{label}, {back}", + "description": "Screen-reader name of a drilled-in sub-menu's header row on a touch screen. The row shows {label}, the sub-menu row's own label (e.g. \"Move to\"), so the name starts with it; {back} is the \"Back to {parent}\" text saying where activating the row goes (e.g. \"Move to, Back to Actions\")." + }, "@astryx.lightbox.close": { "defaultMessage": "Close", "description": "\"Close\" = shut/dismiss (not \"nearby\"). Screen-reader-only label on the X button that dismisses a Lightbox." diff --git a/packages/core/package.json b/packages/core/package.json index 295c3d98433c4..3bba9ae4f24ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -864,6 +864,7 @@ "devDependencies": { "@astryxdesign/a11y-spec": "workspace:*", "@astryxdesign/cli": "workspace:*", + "@astryxdesign/interaction-harness": "workspace:*", "@babel/cli": "catalog:", "@babel/core": "catalog:", "@babel/preset-react": "catalog:", diff --git a/packages/core/src/AlertDialog/AlertDialog.doc.mjs b/packages/core/src/AlertDialog/AlertDialog.doc.mjs index 135cd637e5ed8..4a2965ae23b1f 100644 --- a/packages/core/src/AlertDialog/AlertDialog.doc.mjs +++ b/packages/core/src/AlertDialog/AlertDialog.doc.mjs @@ -21,7 +21,7 @@ export const docs = { ], usage: { description: - 'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Button labels retain their standard single-line behavior. The breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.', + 'AlertDialog asks the user to confirm a destructive or irreversible action before it happens. Use it for things like deleting content, revoking access, or discarding unsaved changes.\n\nIt implements the WAI-ARIA APG [Alert Dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): `role="alertdialog"`, a title linked by `aria-labelledby`, a consequence description linked by `aria-describedby`, focus moved into the dialog on open and returned to the trigger on close, and no dismissal by clicking outside. Escape cancels.\n\nAlertDialog passes its requested width through to Dialog, which clamps the surface to the container and dynamic viewport with token gutters. Generic Dialog footers should wrap, but Dialog does not own action semantics or order; consumer composition controls that. AlertDialog owns its confirmation semantics: above 640px, actions render horizontally and may move onto another row; at 640px and below, the destructive action appears above Cancel and both buttons fill the footer width. Button labels retain their standard single-line behavior. The breakpoint follows available width, not pointer or hover capability. The body scrolls when block space is constrained.\n\nOn a touch screen the two actions are tracked as one group under a finger or a pen, the way iOS tracks the actions of an alert: the action under the finger highlights as soon as it is touched, the highlight follows the finger from one action to the other, and lifting the finger runs the action under it. Lifting it off both actions runs nothing. A mouse, the keyboard and assistive technology keep the click of each button.\n\nUnder a finger, on any touch device, the alert also appears and leaves the way an iOS alert does: it scales down slightly into place while it and the dimming fade in, and it fades out with the dimming when it closes. While it appears a touch does nothing (keys and assistive technology still act), and while it leaves nothing reaches it: a touch, a click, a key or an assistive-technology activation does nothing. With reduced motion it only cross-fades. With a mouse it keeps the theme entrance and closes at once.\n\nFor cases where you want to show an alert without managing open state, use the `useImperativeAlertDialog` hook: call `alert.show(options)` and render `alert.element` in your tree.', bestPractices: [ { guidance: true, @@ -174,7 +174,7 @@ export const docs = { type: 'number | string', default: '400', description: - 'Requested dialog width. Dialog preserves this preferred width and clamps it to the container and dynamic viewport with token gutters.', + 'Requested dialog width. Dialog preserves this preferred width and clamps it to the container and dynamic viewport with token gutters. Without one, a phone (a compact window under a coarse primary pointer) gets the iOS alert width, 320, in every theme.', }, { name: 'isInline', @@ -196,7 +196,7 @@ export const docsDense = { 'Confirms destructive/irreversible action before it happens (delete, revoke access, discard unsaved changes).', usage: { description: - 'AlertDialog confirms destructive/irreversible action (delete, revoke access, discard changes). Implements WAI-ARIA APG Alert Dialog pattern (https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): role="alertdialog", aria-labelledby title, aria-describedby description, focus into dialog on open + back to trigger on close, no outside-click dismissal, Escape cancels. Dialog preserves requested width + clamps it to container/dynamic viewport gutters. Generic Dialog footers wrap while consumers own action order. AlertDialog owns confirmation action layout: >640px horizontal buttons may move to another row; <=640px puts the destructive action above Cancel and makes both buttons full width regardless of pointer/hover capability. Button labels remain single-line. Body scrolls when height constrained. To show w/o managing open state, use useImperativeAlertDialog hook: call alert.show(options) + render alert.element in tree.', + 'AlertDialog confirms destructive/irreversible action (delete, revoke access, discard changes). Implements WAI-ARIA APG Alert Dialog pattern (https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/): role="alertdialog", aria-labelledby title, aria-describedby description, focus into dialog on open + back to trigger on close, no outside-click dismissal, Escape cancels. Dialog preserves requested width + clamps it to container/dynamic viewport gutters. Generic Dialog footers wrap while consumers own action order. AlertDialog owns confirmation action layout: >640px horizontal buttons may move to another row; <=640px puts the destructive action above Cancel and makes both buttons full width regardless of pointer/hover capability. Button labels remain single-line. Body scrolls when height constrained. Touch (finger or pen): both actions tracked as one group like iOS alerts (action under finger highlights at once, highlight follows finger between actions, lift runs action under finger, lift off both runs nothing); mouse/keyboard/AT keep each button click. Under a finger (any touch device), motion like iOS alerts: scales down into place + fades in with the dim, fades out with the dim on close; touch ignored while it appears (keys/AT still act), no input (touch, click, key, AT) while it leaves; reduced motion = cross-fade only; with a mouse, theme entrance + closes at once. To show w/o managing open state, use useImperativeAlertDialog hook: call alert.show(options) + render alert.element in tree.', bestPractices: [ { guidance: true, diff --git a/packages/core/src/AlertDialog/AlertDialog.public.test.ts b/packages/core/src/AlertDialog/AlertDialog.public.test.ts new file mode 100644 index 0000000000000..1a0a73e0c7433 --- /dev/null +++ b/packages/core/src/AlertDialog/AlertDialog.public.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file AlertDialog.public.test.ts + * @input Imports the AlertDialog public barrel + * @output Locks AlertDialog's public props: the touch presentation adds none + * @position Compatibility test guarding @astryxdesign/core/AlertDialog + * + * The touch presentation changes behavior, not the public contract + * (`component:AlertDialog`: no public API change). Its way back on a phone is + * the existing `width` prop (FR9), and nothing else is added for it. + * + * The `expectTypeOf` assertions here are enforced by `pnpm -F + * @astryxdesign/core typecheck`, not by the test run. A failed negative property + * assertion surfaces as `TS2554: Expected 2 arguments, but got 1`. + */ + +import {describe, expectTypeOf, it} from 'vitest'; + +import type {AlertDialogProps} from './index'; + +describe('AlertDialog public surface', () => { + it('adds no prop for the touch presentation', () => { + expectTypeOf().not.toHaveProperty('presentation'); + }); + + it("keeps a caller's width, the phone width's way back (FR9)", () => { + expectTypeOf().toHaveProperty('width'); + }); +}); diff --git a/packages/core/src/AlertDialog/AlertDialog.test.tsx b/packages/core/src/AlertDialog/AlertDialog.test.tsx index 1c88d6a6d02ef..0b8cfdd45e087 100644 --- a/packages/core/src/AlertDialog/AlertDialog.test.tsx +++ b/packages/core/src/AlertDialog/AlertDialog.test.tsx @@ -9,8 +9,18 @@ * SYNC: When AlertDialog.tsx changes, update tests to match new behavior */ -import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import { + describe, + it, + expect, + vi, + beforeAll, + beforeEach, + afterEach, +} from 'vitest'; +import { + act, + cleanup, render, screen, fireEvent, @@ -18,9 +28,31 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import {StrictMode} from 'react'; +import {getGestureHost} from '../gestures/GestureHost'; +import {alertController} from '../motion/uikit/alert'; +import {PRESS_GROUP_ITEM_ATTRIBUTE} from '../utils/pressFeedback'; import {AlertDialog} from './AlertDialog'; import {useImperativeAlertDialog} from './useImperativeAlertDialog'; +// The gesture host reads `(pointer: coarse)` once per document and keeps that +// query for the document's life: it gets this live one (see beforeAll), whose +// answer each test's media stub sets. +let isCoarsePrimaryPointer = false; +const hostCoarseQuery = { + get matches() { + return isCoarsePrimaryPointer; + }, + media: '(pointer: coarse)', + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +}; + +/** Stubs the media queries AlertDialog reads. */ function stubAlertDialogMedia({ smallScreen, coarsePointer, @@ -32,11 +64,15 @@ function stubAlertDialogMedia({ noHover: boolean; reduceMotion?: boolean; }): void { + isCoarsePrimaryPointer = coarsePointer; vi.stubGlobal( 'matchMedia', vi.fn((query: string) => { let matches = false; - if (query.includes('max-width: 640px')) { + if ( + query.includes('max-width: 640px') || + query.includes('width < 768px') + ) { matches = smallScreen; } else if (query.includes('pointer: coarse')) { matches = coarsePointer; @@ -68,6 +104,15 @@ function stubAlertDialogMedia({ ); } +beforeAll(() => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => hostCoarseQuery), + ); + getGestureHost(document); + vi.unstubAllGlobals(); +}); + beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(function ( this: HTMLDialogElement, @@ -81,6 +126,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + isCoarsePrimaryPointer = false; }); describe('AlertDialog', () => { @@ -168,8 +214,9 @@ describe('AlertDialog', () => { }); render(); + // The layout reads the width alone. The pointer query only picks the + // motion (UIKit's under a finger), never the order or the stacking. expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)'); - expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)'); expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)'); const dialog = screen.getByRole('alertdialog'); const buttons = within(dialog).getAllByRole('button'); @@ -229,8 +276,9 @@ describe('AlertDialog', () => { />, ); + // The layout reads the width alone. The pointer query only picks the + // motion (UIKit's under a finger), never the order or the stacking. expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)'); - expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)'); expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)'); const dialog = screen.getByRole('alertdialog'); const buttons = within(dialog).getAllByRole('button'); @@ -263,12 +311,14 @@ describe('AlertDialog', () => { />, ); + // The layout reads the width alone. The pointer query only picks the + // motion and the width (UIKit's under a finger), never the order or the + // stacking. expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 640px)'); - expect(window.matchMedia).not.toHaveBeenCalledWith('(pointer: coarse)'); expect(window.matchMedia).not.toHaveBeenCalledWith('(hover: none)'); const dialog = screen.getByRole('alertdialog'); const buttons = within(dialog).getAllByRole('button'); - expect(dialog.getAttribute('style')).toContain('--x-width: 400px'); + expect(dialog.getAttribute('style')).toContain('--x-width: 320px'); expect(buttons.map(button => button.textContent)).toEqual([ 'Permanently delete workspace', 'Keep this workspace', @@ -433,6 +483,347 @@ describe('AlertDialog', () => { }); }); +/** + * Touch tracking: a finger is tracked across both actions as one group, as + * UIKit tracks an alert's actions (measured on iOS 26.2). The tracking hook's + * own contract is covered in hooks/useActionGroupTracking.test.tsx; these + * cover AlertDialog's wiring of it. + */ +describe('AlertDialog action tracking', () => { + let touchClock = 10_000; + let touchId = 500; + + function touch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: Element, + point: {x: number; y: number}, + id: number, + ): void { + touchClock += 16; + const event = new Event(type, {bubbles: true, cancelable: true}); + const entry = {identifier: id, clientX: point.x, clientY: point.y, target}; + Object.defineProperty(event, 'changedTouches', {value: [entry]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchend' ? [] : [entry], + }); + Object.defineProperty(event, 'timeStamp', {value: touchClock}); + target.dispatchEvent(event); + } + + function place(element: Element, left: number, top: number): void { + const rect = DOMRect.fromRect({x: left, y: top, width: 120, height: 32}); + element.getBoundingClientRect = () => rect; + } + + /** A mouse's pointer event at (x, y), the primary button held until up. */ + function mouse(target: Element, type: string, y: number, x = 60): void { + touchClock += 16; + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + button: 0, + buttons: type === 'pointerup' ? 0 : 1, + clientX: x, + clientY: y, + }); + Object.defineProperty(event, 'timeStamp', {value: touchClock}); + target.dispatchEvent(event); + } + + /** + * Renders the alert open under a finger by default, in any engine (UIKit's + * motion runs, and touches wait for the presentation), or with a mouse with + * `touch: false` (the theme's motion). + */ + function renderOpen( + props: { + isInline?: boolean; + touch?: boolean; + smallScreen?: boolean; + width?: number; + } = {}, + ) { + const isTouch = props.touch ?? true; + stubAlertDialogMedia({ + smallScreen: props.smallScreen ?? true, + coarsePointer: isTouch, + noHover: isTouch, + }); + const onOpenChange = vi.fn(); + const onAction = vi.fn(); + const element = (isOpen: boolean) => ( + + ); + const view = render(element(true)); + const cancel = screen.getByRole('button', {name: 'Cancel'}); + const action = screen.getByRole('button', {name: 'Delete'}); + // Stacked on a narrow screen: the action above Cancel. + place(action, 16, 100); + place(cancel, 16, 140); + const close = () => view.rerender(element(false)); + return {onOpenChange, onAction, cancel, action, close}; + } + + /** + * Lets the presentation complete: UIKit drops touches until it does. The + * input hold ends UIKit's touch window after the animator reports + * its start. + */ + async function completePresentation(): Promise { + await act(async () => { + await new Promise(resolve => + setTimeout(resolve, alertController.present.ignoresTouchesForMs + 20), + ); + }); + } + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('marks both actions as tracked group items from the first render, keeping their pressable marker', () => { + const {cancel, action} = renderOpen(); + for (const button of [cancel, action]) { + // The document press controller leaves a pointer's press on a marked + // item to the group's tracker, its one painter; it still records the + // touch for the scroll brake, through the pressable marker. + expect(button).toHaveAttribute(PRESS_GROUP_ITEM_ATTRIBUTE, ''); + expect(button).toHaveAttribute('data-astryx-pressable'); + } + }); + + it('leaves the actions to the document press controller in the inline preview, which is not tracked', () => { + const {cancel, action} = renderOpen({isInline: true}); + for (const button of [cancel, action]) { + expect(button).not.toHaveAttribute(PRESS_GROUP_ITEM_ATTRIBUTE); + expect(button).toHaveAttribute('data-astryx-pressable'); + } + }); + + it('cancels when a finger lands on the action and lifts on Cancel', async () => { + const {onOpenChange, onAction, cancel, action} = renderOpen(); + await completePresentation(); + const id = touchId++; + touch('touchstart', action, {x: 60, y: 116}, id); + expect(action).toHaveAttribute('data-astryx-press', 'on'); + touch('touchmove', action, {x: 60, y: 156}, id); + expect(action).not.toHaveAttribute('data-astryx-press'); + expect(cancel).toHaveAttribute('data-astryx-press', 'on'); + touch('touchend', action, {x: 60, y: 156}, id); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onAction).not.toHaveBeenCalled(); + }); + + it('runs the action when a finger slides from Cancel onto it', async () => { + const {onOpenChange, onAction, cancel} = renderOpen(); + await completePresentation(); + const id = touchId++; + touch('touchstart', cancel, {x: 60, y: 156}, id); + touch('touchmove', cancel, {x: 60, y: 116}, id); + touch('touchend', cancel, {x: 60, y: 116}, id); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('drops a touch that lands while the alert is presenting, then tracks once it has', async () => { + const {onOpenChange, onAction, cancel} = renderOpen(); + const early = touchId++; + touch('touchstart', cancel, {x: 60, y: 156}, early); + expect(cancel).not.toHaveAttribute('data-astryx-press'); + touch('touchend', cancel, {x: 60, y: 156}, early); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + await completePresentation(); + const late = touchId++; + touch('touchstart', cancel, {x: 60, y: 156}, late); + expect(cancel).toHaveAttribute('data-astryx-press', 'on'); + touch('touchend', cancel, {x: 60, y: 156}, late); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('with a mouse keeps the theme motion: a click acts at once and a close is immediate', () => { + const {onOpenChange, cancel, close} = renderOpen({touch: false}); + // A fine pointer, before anything is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const dialog = screen.getByRole('alertdialog'); + // No presentation to wait for: the click acts as it lands. + fireEvent.click(cancel); + expect(onOpenChange).toHaveBeenCalledWith(false); + // No fade to wait for either: the dialog closes with the caller. + close(); + expect(dialog).not.toHaveAttribute('open'); + }); + + it("takes iOS's alert width on a phone, a caller's width there, and Dialog's 400 on a wider touch screen or with a mouse (FR9)", () => { + const widthOf = () => screen.getByRole('alertdialog').getAttribute('style'); + // A phone: a compact window under a coarse primary pointer. + renderOpen(); + expect(widthOf()).toContain('--x-width: 320px'); + cleanup(); + vi.unstubAllGlobals(); + // A caller's width still wins on a phone. + renderOpen({width: 360}); + expect(window.matchMedia('(pointer: coarse)').matches).toBe(true); + expect(widthOf()).toContain('--x-width: 360px'); + cleanup(); + vi.unstubAllGlobals(); + // A wider touch screen (a tablet): Dialog's 400. + renderOpen({smallScreen: false}); + expect(window.matchMedia('(pointer: coarse)').matches).toBe(true); + expect(widthOf()).toContain('--x-width: 400px'); + cleanup(); + vi.unstubAllGlobals(); + renderOpen({touch: false}); + // A fine pointer, before the mouse's width is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + expect(widthOf()).toContain('--x-width: 400px'); + }); + + it('under a finger stays open while it fades out after the caller closes it', async () => { + const {close} = renderOpen(); + await completePresentation(); + const dialog = screen.getByRole('alertdialog'); + close(); + expect(dialog).toHaveAttribute('open'); + }); + + it('under a finger, while the alert leaves, holds it inert and ignores Escape', async () => { + const {onOpenChange, onAction, close} = renderOpen(); + await completePresentation(); + const dialog = screen.getByRole('alertdialog', {hidden: true}); + const content = () => + [...dialog.children].filter(child => !child.hasAttribute('popover')); + expect(content().some(child => child.hasAttribute('inert'))).toBe(false); + close(); + // Leaving: still open while it fades out, its content inert under the + // input hold's shield, so no click, Enter or assistive-technology + // activation reaches an action; the browser checks cover the taps. + expect(dialog).toHaveAttribute('open'); + expect(content().length).toBeGreaterThan(0); + expect(content().every(child => child.hasAttribute('inert'))).toBe(true); + expect(dialog.querySelector(':scope > [popover]')).not.toBeNull(); + // The dialog's own Escape still reaches it: ignored while it leaves. + fireEvent.keyDown(dialog, {key: 'Escape'}); + expect(onAction).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('under StrictMode, an alert that mounts open presents and takes touches once the presentation completes', async () => { + stubAlertDialogMedia({ + smallScreen: true, + coarsePointer: true, + noHover: true, + }); + const onOpenChange = vi.fn(); + render( + + {}} + /> + , + ); + const cancel = screen.getByRole('button', {name: 'Cancel'}); + place(cancel, 16, 140); + await completePresentation(); + const id = touchId++; + touch('touchstart', cancel, {x: 60, y: 156}, id); + expect(cancel).toHaveAttribute('data-astryx-press', 'on'); + touch('touchend', cancel, {x: 60, y: 156}, id); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('tracks a mouse too (family:action-groups FR5): a press on the action slid to Cancel cancels, once', () => { + const {onOpenChange, onAction, cancel, action} = renderOpen({ + touch: false, + }); + // A fine primary pointer, before the mouse is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + mouse(action, 'pointerdown', 116); + expect(action).toHaveAttribute('data-astryx-press', 'on'); + mouse(action, 'pointermove', 156); + expect(action).not.toHaveAttribute('data-astryx-press'); + expect(cancel).toHaveAttribute('data-astryx-press', 'on'); + mouse(cancel, 'pointerup', 156); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onAction).not.toHaveBeenCalled(); + }); + + it('runs nothing for a mouse press lifted outside the actions (family:action-groups FR3, FR5)', () => { + const {onOpenChange, onAction, cancel, action} = renderOpen({ + touch: false, + }); + // A fine primary pointer, before the mouse is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const dialog = screen.getByRole('alertdialog'); + mouse(action, 'pointerdown', 116); + expect(action).toHaveAttribute('data-astryx-press', 'on'); + // Off both actions: nothing is highlighted while the button is held. + mouse(action, 'pointermove', 116, 300); + expect(action).not.toHaveAttribute('data-astryx-press'); + expect(cancel).not.toHaveAttribute('data-astryx-press'); + mouse(dialog, 'pointerup', 116, 300); + expect(onAction).not.toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('keeps the keyboard on the buttons under every pointer: Enter runs the action and Space cancels (FR8)', async () => { + for (const touch of [true, false]) { + const user = userEvent.setup(); + const {onOpenChange, onAction, cancel, action} = renderOpen({touch}); + action.focus(); + await user.keyboard('{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + cancel.focus(); + await user.keyboard(' '); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onAction).toHaveBeenCalledTimes(1); + cleanup(); + vi.unstubAllGlobals(); + } + }); + + it('tracks only the actions: a press that lands on the title, the message or the backdrop and slides onto an action runs nothing', async () => { + const {onOpenChange, onAction, cancel} = renderOpen(); + await completePresentation(); + const dialog = screen.getByRole('alertdialog'); + // A touch on ::backdrop targets the dialog element itself. + for (const start of [ + screen.getByText('Delete item?'), + screen.getByText('This cannot be undone.'), + dialog, + ]) { + const id = touchId++; + touch('touchstart', start, {x: 60, y: 40}, id); + touch('touchmove', start, {x: 60, y: 156}, id); + expect(cancel).not.toHaveAttribute('data-astryx-press'); + touch('touchend', start, {x: 60, y: 156}, id); + } + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + }); +}); + describe('useImperativeAlertDialog', () => { const noop = () => {}; @@ -492,7 +883,9 @@ describe('useImperativeAlertDialog', () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', {name: 'Open'})); - await user.click(screen.getByRole('button', {name: 'Cancel'})); + // An activation with no pointer: jsdom lays nothing out, so a pointer + // press on the alert's action group would hit-test empty rects. + fireEvent.click(screen.getByRole('button', {name: 'Cancel'})); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); expect(screen.getByTestId('is-open')).toHaveTextContent('false'); }); @@ -502,7 +895,8 @@ describe('useImperativeAlertDialog', () => { const onAction = vi.fn(); render(); await user.click(screen.getByRole('button', {name: 'Open'})); - await user.click(screen.getByRole('button', {name: 'Delete'})); + // An activation with no pointer (see "closes when the dialog cancels"). + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); expect(onAction).toHaveBeenCalledTimes(1); expect(screen.getByRole('alertdialog')).toBeInTheDocument(); }); diff --git a/packages/core/src/AlertDialog/AlertDialog.tsx b/packages/core/src/AlertDialog/AlertDialog.tsx index 9553b3bc91f03..0efbb3c4a3643 100644 --- a/packages/core/src/AlertDialog/AlertDialog.tsx +++ b/packages/core/src/AlertDialog/AlertDialog.tsx @@ -4,19 +4,42 @@ /** * @file AlertDialog.tsx - * @input Uses React, StyleX, Dialog, Layout, Heading, Text, Button + * @input Uses React, StyleX, Dialog, Layout, Heading, Text, Button, the touch + * presentation resolver, the action-group tracking + * (hooks/useActionGroupTracking), the group item marker + * (utils/pressFeedback), its touch layer (AlertDialogTouch) and the alert + * entries of the UIKit constants table * @output Exports AlertDialog component, AlertDialogProps type * @position Core implementation; consumed by index.ts, tested by AlertDialog.test.tsx * + * The two actions are tracked as one group under every pointer, a finger, a + * pen or a mouse (`family:action-groups`), as UIKit tracks an alert's + * actions: the action under the pointer highlights at once, the highlight + * follows it from one action to the other, and the release runs the action + * under it (none when it is off both). Only the actions are tracked: a press + * on the title or the message is not part of the group. The keyboard and + * assistive technology keep the buttons' own click. + * + * Under a finger (a coarse primary pointer: useTouchPresentation) it also + * appears and leaves the way UIKit's alert does (useAlertMotion): it scales + * down from 1.2 and fades in with the dimming, and leaves with a fade alone, + * both on the compositor; the dialog stays open until the fade ends. A touch + * reaches nothing while it appears (keys and assistive technology still act), + * and no input reaches it while it leaves. One touch model on every touch + * device. With a mouse the theme's entrance plays and the dialog closes at + * once, as before. + * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/AlertDialog/AlertDialog.doc.mjs (props table, features, examples) * - /packages/core/src/AlertDialog/AlertDialog.test.tsx (tests for new/changed behavior) * - /packages/core/src/AlertDialog/index.ts (exports if types change) + * - /packages/core/src/AlertDialog/__tests__/AlertDialog.interaction.browser.spec.ts (touch tracking) + * - /packages/core/src/AlertDialog/__tests__/AlertDialog.motion.interaction.browser.spec.ts (touch motion) * - /apps/storybook/stories/AlertDialog.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/AlertDialog/ (showcase blocks) */ -import React, {useId, useCallback} from 'react'; +import React, {useId, useCallback, useRef} from 'react'; import * as stylex from '@stylexjs/stylex'; import {Dialog} from '../Dialog'; import {Layout} from '../Layout/Layout'; @@ -31,10 +54,43 @@ import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; import {useTranslator} from '../i18n'; import {useMediaQuery} from '../hooks/useMediaQuery'; +import {useActionGroupTracking} from '../hooks/useActionGroupTracking'; +import {useMergedRefs} from '../hooks/useMergedRefs'; +import {alertController} from '../motion/uikit/alert'; +import { + PRESS_GROUP_ITEM_ATTRIBUTE, + pressGroupItemProps, +} from '../utils/pressFeedback'; +import {useTouchPresentation} from '../utils/touchPresentation'; +import {useAlertDialogTouch} from './AlertDialogTouch'; const SMALL_SCREEN_QUERY = '(max-width: 640px)'; const styles = stylex.create({ + // The group tracking claims a press on the actions the moment it lands, as + // a long press does: the browser may pinch-zoom there but never pans (Chrome + // on Android cancels a pointer it pans, which would cancel the group's + // press), and a double tap zooms nothing (`spec:AST-094` FR18: the gesture + // host never allows more than this). A press that slides across the + // actions selects none of their labels, for a mouse too (`spec:AST-094` + // FR41: the hold does something); the title and the message stay + // selectable. + trackingRow: { + touchAction: 'pinch-zoom', + userSelect: 'none', + WebkitUserSelect: 'none', + }, + // UIKit's alert motion drives the dialog (useAlertMotion): the theme's + // entrance keyframes stand down. + uikitMotion: { + animationName: 'none', + // The motion animator promotes the dimming while it moves through this + // custom property on the dialog: Mobile Safari keeps an unlayered + // ::backdrop's animation on the main thread at its first commit. + '::backdrop': { + willChange: 'var(--astryx-motion-will-change-backdrop, auto)', + }, + }, action: { maxWidth: '100%', minWidth: 0, @@ -106,7 +162,9 @@ export interface AlertDialogProps extends BaseProps { /** * The width of the dialog. - * Numbers are treated as pixels, strings are used as-is. + * Numbers are treated as pixels, strings are used as-is. By default 400, + * and on a phone (a compact window under a coarse primary pointer) iOS's + * alert width, 320, in every theme; a width set here wins there too. * @default 400 */ width?: number | string; @@ -150,7 +208,7 @@ export function AlertDialog({ actionVariant = 'destructive', isActionLoading, onAction, - width = 400, + width: widthProp, xstyle, className, style, @@ -166,9 +224,75 @@ export function AlertDialog({ // semantics without coupling action order to pointer or hover capability. const isSmallScreen = useMediaQuery(SMALL_SCREEN_QUERY); + // Under a finger the alert appears and leaves the way UIKit's does (the + // touch layer's useAlertMotion): it stays presented while it fades out. + // Only under a touch presentation (a coarse primary pointer, after + // hydration): where the primary pointer is fine nothing of it attaches + // (`spec:AST-094` FR13, FR19; `component:AlertDialog` FR6, FR7). + const touch = useTouchPresentation(); + const isTouch = touch.isTouch; + // On a phone (a compact window under a finger), iOS's alert width in + // every theme (alertController.width), so the presentation's 1.2 start + // stays on screen; a caller's width wins, and on a wider touch screen and + // with a mouse it stays Dialog's 400 (`component:AlertDialog` FR9). + const width = + widthProp ?? (isTouch && touch.isCompact ? alertController.width : 400); + const {motion} = useAlertDialogTouch({ + isOpen, + isInline: isInline === true, + isTouch, + }); + // The two actions are an action group (`family:action-groups`): a held + // pointer, a finger, a pen or a mouse, is tracked across them the way UIKit + // tracks an alert's actions. The action under it highlights, the highlight + // follows it from one action to the other, and the lift runs the action + // under it, through its own click (none off both). Only the actions are + // tracked: a press on the title or the message is not part of the group, + // so their text stays selectable. Each tracked action carries the group + // item marker from its first render: the document press controller leaves + // a pointer's press there to the tracker, the one painter, and still + // records the touch for the scroll brake. The inline preview is not + // tracked, and its actions keep the controller's press. + // Tracked while presented, leaving included: during either transition the + // touch layer's input hold (useAlertMotion) keeps every press from the + // group and from the buttons under it. + const actionsRef = useRef(null); + const isTracking = motion.isPresented && isInline !== true; + useActionGroupTracking(actionsRef, { + itemSelector: `[${PRESS_GROUP_ITEM_ATTRIBUTE}]`, + highlightOnsetMs: alertController.actions.highlightOnsetMs, + highlightExpansion: alertController.actions.highlightExpansion, + isEnabled: isTracking, + // The row's touch-action is its own style (trackingRow); a held finger + // or pen selects nothing while it holds (`spec:AST-094` FR41), and the + // actions keep their default styles otherwise. + manageStyle: false, + }); + // While the alert leaves (closed by the caller, still presented while it + // fades out under a finger), UIKit delivers no input to it: the touch + // layer's input hold makes its content inert under a shield, so a + // tap, a click, Enter or an assistive-technology activation on an action + // does nothing, and the dialog's own Escape (its cancel event, which inert + // content does not stop) is ignored here until it is hidden. Focus leaves + // the inert content, and Dialog returns it to the opener when the fade + // ends. + const isLeaving = motion.isDriving && motion.isPresented && !isOpen; const handleCancel = useCallback(() => { onOpenChange(false); }, [onOpenChange]); + const handleAction = useCallback(() => { + void onAction(); + }, [onAction]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (!isLeaving) { + onOpenChange(next); + } + }, + [isLeaving, onOpenChange], + ); + const mergedRef = useMergedRefs(ref, motion.ref); + const actionTrackingProps = isTracking ? pressGroupItemProps : undefined; const buttonActionStyle = [ styles.action, @@ -181,6 +305,7 @@ export function AlertDialog({ label={cancelLabel} onClick={handleCancel} xstyle={buttonActionStyle} + {...actionTrackingProps} // Dialog focuses [data-autofocus] itself after showModal(), because // React's autoFocus runs during commit while the dialog is invisible. // Cancel is least destructive, so it remains the autofocus target even @@ -193,19 +318,20 @@ export function AlertDialog({ key="action" variant={actionVariant} label={actionLabel} - onClick={onAction} + onClick={handleAction} isLoading={isActionLoading} xstyle={buttonActionStyle} + {...actionTrackingProps} /> ); return ( => { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +}; + +/** + * Opens the story's alert with a tap on its trigger, waits for the entrance + * to rest, and starts a page-side log: every click an action receives, and + * every write of the press attribute with the animation frame it landed in. + */ +async function openAlert( + page: Page, + story = 'core-alertdialog--delete', +): Promise { + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + await driver.tap( + await centerOf(page.getByRole('button', {name: 'Delete item'})), + ); + const dialog = page.getByRole('alertdialog', {name: 'Delete item?'}); + await expect(dialog).toBeVisible(); + await waitForRest(dialog); + const cancel = dialog.getByRole('button', {name: 'Cancel'}); + const del = dialog.getByRole('button', {name: 'Delete'}); + const message = dialog.getByText('This action cannot be undone', { + exact: false, + }); + await page.evaluate(() => { + const log = { + clicks: [] as string[], + paints: [] as string[], + touchFrames: [] as number[], + frame: 0, + }; + (window as unknown as {__alertLog: typeof log}).__alertLog = log; + const tick = () => { + log.frame += 1; + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + document.addEventListener( + 'click', + event => { + const button = (event.target as Element).closest('button'); + if (button != null) { + log.clicks.push(button.textContent ?? ''); + } + }, + true, + ); + document.addEventListener( + 'touchstart', + () => log.touchFrames.push(log.frame), + true, + ); + new MutationObserver(records => { + for (const record of records) { + const target = record.target as Element; + log.paints.push( + `${target.textContent}=${target.getAttribute('data-astryx-press') ?? '-'}@${log.frame}`, + ); + } + }).observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ['data-astryx-press'], + }); + }); + const box = await dialog.boundingBox(); + if (box == null) { + throw new Error('no dialog box'); + } + return { + driver, + dialog, + cancel, + del, + centers: { + cancel: await centerOf(cancel), + del: await centerOf(del), + message: await centerOf(message), + }, + bottom: box.y + box.height, + }; +} + +const readLog = async (page: Page) => + page.evaluate( + () => + ( + window as unknown as { + __alertLog: { + clicks: string[]; + paints: string[]; + touchFrames: number[]; + }; + } + ).__alertLog, + ); + +const paintOf = async (locator: Locator) => + locator.evaluate(el => el.getAttribute('data-astryx-press')); + +/** A slow slide that rests before the lift, as a finger reconsidering. */ +const slide = (from: Point, to: Point) => + drag(from, to, {durationMs: 240, hz: 60, holdMs: 60, restMs: 120}); + +test('a finger that lands on Delete and lifts on Cancel cancels', async ({ + page, +}) => { + const {driver, dialog, centers} = await openAlert(page); + await driver.play(slide(centers.del, centers.cancel)); + await expect(dialog).toBeHidden(); + expect((await readLog(page)).clicks).toEqual(['Cancel']); +}); + +test("under a finger in Chrome's engine the slide from Delete to Cancel runs Cancel too", async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', "Chrome's engine"); + // One touch model on every touch device: a finger in Chrome (a coarse + // pointer, no -webkit-touch-callout) tracks the same way. + const {driver, dialog, centers} = await openAlert(page); + expect( + await page.evaluate(() => ({ + coarse: matchMedia('(pointer: coarse)').matches, + ios: CSS.supports('(-webkit-touch-callout: none)'), + })), + ).toEqual({coarse: true, ios: false}); + await driver.play(slide(centers.del, centers.cancel)); + await expect(dialog).toBeHidden(); + expect((await readLog(page)).clicks).toEqual(['Cancel']); +}); + +test('the touched action is highlighted in the frame of the touch, and the highlight follows the finger', async ({ + page, +}) => { + const {driver, dialog, cancel, del, centers} = await openAlert(page); + await driver.play(slide(centers.del, centers.cancel), { + onEvent: async (step, index) => { + if (index !== 0) { + return; + } + // Read synchronously after the touch-down was dispatched. + expect(step.type).toBe('touchStart'); + expect(await paintOf(del)).toBe('on'); + expect(await paintOf(cancel)).toBeNull(); + }, + }); + await expect(dialog).toBeHidden(); + const {paints, clicks, touchFrames} = await readLog(page); + // The first write is Delete's, in the animation frame of the touchstart. + expect(touchFrames).toHaveLength(1); + expect(paints[0]).toBe(`Delete=on@${touchFrames[0]}`); + // Delete is cut (no fade) when the finger leaves it; Cancel lights when it + // enters; the lift fades Cancel, and the alert closing (here at once, with + // no exit) ends that fade with the tracking. + const sequence = paints.map(entry => entry.split('@')[0]); + expect(sequence).toEqual([ + 'Delete=on', + 'Delete=-', + 'Cancel=on', + 'Cancel=fading', + 'Cancel=-', + ]); + expect(clicks).toEqual(['Cancel']); +}); + +test('a lift off both actions runs nothing and leaves the alert open', async ({ + page, +}) => { + const {driver, dialog, del, centers, bottom} = await openAlert(page); + await driver.play(slide(centers.del, {x: centers.del.x, y: bottom + 60})); + expect(await paintOf(del)).toBeNull(); + await expect(dialog).toBeVisible(); + expect((await readLog(page)).clicks).toEqual([]); +}); + +test('a press on the actions starts no text selection, by style, while the title and the message stay selectable (spec:AST-094 FR41)', async ({ + page, +}) => { + const {driver, dialog, del, centers, bottom} = await openAlert(page); + const userSelect = async (locator: Locator) => + locator.evaluate(element => { + const style = getComputedStyle(element); + return ( + style.getPropertyValue('user-select') || + style.getPropertyValue('-webkit-user-select') + ); + }); + // The actions' row, the group: its labels are never selected by a press + // that slides across them, whatever the pointer. + expect(await userSelect(del.locator('..'))).toBe('none'); + // The title and the message are outside the group: selectable. + expect(await userSelect(dialog.getByRole('heading'))).not.toBe('none'); + const message = dialog.getByText('This action cannot be undone', { + exact: false, + }); + expect(await userSelect(message)).not.toBe('none'); + // A held finger on the actions changes nothing there. + await driver.play(slide(centers.del, {x: centers.del.x, y: bottom + 60}), { + onEvent: async (step, index) => { + if (index !== 0) { + return; + } + expect(step.type).toBe('touchStart'); + expect(await userSelect(message)).not.toBe('none'); + }, + }); + await expect(dialog).toBeVisible(); +}); + +test("no pan can take the group's press: the actions allow only pinch-zoom, and the rest of the alert keeps its pan", async ({ + page, +}) => { + // Chrome on Android cancels a pointer it pans (pointercancel), which ends + // the long press that tracks the group: where the group owns a press, the + // browser may pinch-zoom but not pan. Only the actions are the group. + const {dialog, cancel} = await openAlert(page); + const touchAction = async (locator: Locator) => + locator.evaluate(element => getComputedStyle(element).touchAction); + // The actions' row, the group's element. + expect(await touchAction(cancel.locator('..'))).toBe('pinch-zoom'); + expect(await touchAction(dialog)).toBe('auto'); + const message = dialog.getByText('This action cannot be undone', { + exact: false, + }); + expect(await touchAction(message.locator('..'))).toBe('auto'); +}); + +test('only the actions are tracked: a touch that lands on the message and slides onto Cancel runs nothing', async ({ + page, +}) => { + const {driver, dialog, centers} = await openAlert(page); + await driver.play(slide(centers.message, centers.cancel)); + await page.waitForTimeout(300); + await expect(dialog).toBeVisible(); + expect((await readLog(page)).clicks).toEqual([]); +}); + +test('a tap runs its action exactly once', async ({page}) => { + const {driver, dialog, centers} = await openAlert(page); + await driver.play(tap(centers.cancel, {holdMs: 80})); + await expect(dialog).toBeHidden(); + // Let any click the browser might still synthesize arrive. + await page.waitForTimeout(400); + expect((await readLog(page)).clicks).toEqual(['Cancel']); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('a press on Delete slid to Cancel runs Cancel once, and a click on Delete runs Delete once (family:action-groups FR5)', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-alertdialog--delete'); + // A fine pointer in this context, before anything is checked. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + const open = async () => { + await page.getByRole('button', {name: 'Delete item'}).click(); + const dialog = page.getByRole('alertdialog', {name: 'Delete item?'}); + await expect(dialog).toBeVisible(); + return dialog; + }; + const clicks: string[] = []; + await page.exposeFunction('__alertClick', (label: string) => { + clicks.push(label); + }); + await page.evaluate(() => { + document.addEventListener( + 'click', + event => { + const action = (event.target as Element).closest('dialog button'); + if (action != null) { + ( + window as unknown as {__alertClick: (label: string) => void} + ).__alertClick(action.textContent ?? ''); + } + }, + true, + ); + }); + let dialog = await open(); + const del = await centerOf(dialog.getByRole('button', {name: 'Delete'})); + const cancel = await centerOf(dialog.getByRole('button', {name: 'Cancel'})); + await page.mouse.move(del.x, del.y); + await page.mouse.down(); + await page.mouse.move(cancel.x, cancel.y, {steps: 8}); + await page.mouse.up(); + await expect(dialog).toBeHidden(); + expect(clicks).toEqual(['Cancel']); + // A plain click: the group's click runs it, and the browser's own click + // for the same press is swallowed. + clicks.length = 0; + dialog = await open(); + await dialog.getByRole('button', {name: 'Delete'}).click(); + await page.waitForTimeout(300); + expect(clicks).toEqual(['Delete']); + }); + + test('the dialog keeps its base styles: its text stays selectable', async ({ + page, + }) => { + // Only the actions' row, the group, takes no text selection: with a + // mouse the dialog's title and message stay selectable. + await openStory(page, storybook(), 'core-alertdialog--delete'); + // A fine pointer in this context, before anything is checked. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + await page.getByRole('button', {name: 'Delete item'}).click(); + const dialog = page.getByRole('alertdialog', {name: 'Delete item?'}); + await expect(dialog).toBeVisible(); + const styles = await dialog.evaluate(element => { + const title = element.querySelector('h2') as HTMLElement; + return { + dialog: getComputedStyle(element).userSelect, + title: getComputedStyle(title).userSelect, + }; + }); + expect(styles.dialog).not.toBe('none'); + expect(styles.title).not.toBe('none'); + }); +}); + +test('on a touch screen a mouse click runs its action once', async ({page}) => { + const {dialog, centers} = await openAlert(page); + // A mouse on a touch screen: the primary pointer is coarse, and the group + // tracks every pointer, so the click runs Cancel through the group, once. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + await page.mouse.click(centers.cancel.x, centers.cancel.y); + await expect(dialog).toBeHidden(); + expect((await readLog(page)).clicks).toEqual(['Cancel']); +}); + +test( + 'storyboard: slide from Delete to Cancel and lift', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, dialog, centers} = await openAlert(page); + // CSS locators rather than roles: they still resolve once the alert has + // closed, so Cancel's paint can be read after the lift (the group item + // marker leaves with the tracking). + const cancel = page.locator('dialog button', {hasText: 'Cancel'}); + const del = page.locator('dialog button', {hasText: 'Delete'}); + const box = await dialog.boundingBox(); + if (box == null) { + throw new Error('no dialog box'); + } + const clip = { + x: Math.max(0, box.x - 8), + y: Math.max(0, box.y - 8), + width: box.width + 16, + height: box.height + 16, + }; + const board = await touchStoryboard( + page, + driver, + drag(centers.del, centers.cancel, {durationMs: 200, hz: 30, restMs: 100}), + { + name: 'alertdialog-slide-delete-to-cancel', + clip, + measure: async () => ({ + del: (await paintOf(del)) ?? 'none', + cancel: (await paintOf(cancel)) ?? 'none', + }), + columns: 4, + cellScale: 0.6, + }, + ); + await board.write(testInfo); + // The trace is the assertion; the sheet is the evidence. + const frames = board.frames.map( + f => `${String(f.data.del)}/${String(f.data.cancel)}`, + ); + expect(frames[0]).toBe('none/none'); // before the touch + expect(frames[1]).toBe('on/none'); // touch-down on Delete + expect(frames).toContain('none/on'); // over Cancel + // The lift runs Cancel. Under a finger the alert then leaves with a + // fade, and Cancel's release fade plays through it: 100 ms after the + // lift it is still fading, and it ends before the alert is hidden. + expect(frames.at(-1)).toBe('none/fading'); + expect(frames.indexOf('none/on')).toBeGreaterThan(1); + await expect(dialog).toBeHidden(); + const {clicks, paints} = await readLog(page); + expect(clicks).toEqual(['Cancel']); + expect(paints.map(entry => entry.split('@')[0]).slice(-2)).toEqual([ + 'Cancel=fading', + 'Cancel=-', + ]); + }, +); diff --git a/packages/core/src/AlertDialog/__tests__/AlertDialog.motion.interaction.browser.spec.ts b/packages/core/src/AlertDialog/__tests__/AlertDialog.motion.interaction.browser.spec.ts new file mode 100644 index 0000000000000..d32f72b647b6a --- /dev/null +++ b/packages/core/src/AlertDialog/__tests__/AlertDialog.motion.interaction.browser.spec.ts @@ -0,0 +1,1286 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file AlertDialog.motion.interaction.browser.spec.ts + * @input The built "Delete" AlertDialog story on the phone profile (and a + * desktop pointer profile for the unchanged path), real touch input and the + * interaction harness + * @output Animation proofs for the alert's presentation under a finger (a + * coarse pointer, in Chromium and WebKit alike: one touch model on every + * touch device), which follows UIKit's measured alert (iOS 26.2): + * presenting, it scales from 1.2 to 1 and + * fades in while the dimming fades in, all on springs.alertPresent; + * dismissing, both only fade out on springs.alertDismiss, the dialog closes + * when the fade ends and focus returns to the opener; it starts inside the + * viewport (UIKit's 320 pt width); closed mid-presentation it fades from + * where it is: from the presented value at the close, with its velocity, + * never above that fade; both run on the + * compositor (frames keep coming through a blocked main thread, judged + * with the backdrop blur off because this rig composites in software; no + * Layout or Paint per frame); a tap during either transition reaches + * nothing; under + * reduced motion the alert and the dimming cross-fade in and out, opacity + * only, settled within 200 ms (design:reduced-motion DR1, DR2); a finger in + * Chrome's engine gets the same presentation; a mouse keeps the theme's + * entrance and closes at once; storyboards of the present and the + * dismiss. + * @position Motion lane of AlertDialog, beside the touch-tracking spec. Run + * with `pnpm test:interaction AlertDialog`. + */ + +import { + expect, + test, + type ElementHandle, + type Locator, + type Page, +} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + compareCurve, + countBetween, + drag, + freezeTimers, + installTimerGate, + openStory, + pinch, + releaseAnimations, + springStep, + startScreencast, + thawTimers, + timeline, + touchDriver, + traceRendering, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, + type Point, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {solveSpring, toSpring} from '../../motion/spring'; +import {alertController} from '../../motion/uikit/alert'; +import {springs} from '../../motion/uikit/springs'; + +const storybook = withStorybook(test); + +/** Progress of a mass-stiffness-damping spring, from the harness's oracle. */ +function progressOf(spring: { + mass: number; + stiffness: number; + damping: number; +}): (t: number) => number { + const response = (2 * Math.PI) / Math.sqrt(spring.stiffness / spring.mass); + const dampingFraction = + spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass)); + return t => springStep({response, dampingFraction}, t); +} + +const present = progressOf(springs.alertPresent); +const dismiss = progressOf(springs.alertDismiss); +const {scaleFrom} = alertController.present; +const PRESENT_MS = alertController.present.durationMs; +const DISMISS_MS = alertController.dismiss.durationMs; + +// The animator bakes curves into keyframes within 0.002 (opacity) and 0.0002 +// (scale) of the curve; the engines round computed values on top of that. +const OPACITY_TOLERANCE = 0.004; +const SCALE_TOLERANCE = 0.001; + +const centerOf = async (locator: Locator): Promise => { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +}; + +interface Opened { + driver: TouchDriver; + trigger: Locator; + dialog: Locator; +} + +/** Opens the story with the alert closed. */ +async function openPage(page: Page): Promise { + await openStory(page, storybook(), 'core-alertdialog--delete'); + return { + driver: await touchDriver(page), + trigger: page.getByRole('button', {name: 'Delete item'}), + dialog: page.getByRole('alertdialog', {name: 'Delete item?'}), + }; +} + +/** + * Opens the alert and closes it 100 ms into its presentation, inside the + * page on its own clock (no round trip between the presentation's start and + * the close). Returns the presentation's and the fade's timing, the + * presented value at the close, and the alert's opacity at every frame until + * the dialog has closed. + */ +async function closeMidPresentation(page: Page) { + const {trigger} = await openPage(page); + // Opened and closed inside the page, on the page's own clock: no + // round trip between the presentation's start and the close. + await trigger.evaluate(async opener => { + (opener as HTMLElement).click(); + }); + return page.evaluate(async () => { + const element = document.querySelector('dialog') as HTMLElement; + const opacityAnimations = () => + element.getAnimations().filter(animation => { + const effect = animation.effect as KeyframeEffect; + return ( + effect.target === element && + effect.pseudoElement == null && + effect.getKeyframes().some(keyframe => 'opacity' in keyframe) + ); + }); + // The presentation, once the browser has started it. + const present = await new Promise((resolve, reject) => { + const giveUp = performance.now() + 2000; + const look = () => { + const found = opacityAnimations().find( + animation => animation.startTime != null, + ); + if (found != null) { + resolve(found); + } else if (performance.now() > giveUp) { + reject(new Error('the alert did not present')); + } else { + requestAnimationFrame(look); + } + }; + look(); + }); + const presentEffect = present.effect as KeyframeEffect; + const keyframes = presentEffect.getKeyframes().map(keyframe => ({ + offset: keyframe.computedOffset, + value: Number(keyframe.opacity), + })); + const span = Number(presentEffect.getTiming().duration); + const presentStart = Number(present.startTime); + // The presentation's value at a time, as the browser computes it from + // its keyframes (linear between them). + const presentedAt = (time: number) => { + const progress = Math.min(1, Math.max(0, (time - presentStart) / span)); + for (let i = 1; i < keyframes.length; i += 1) { + const a = keyframes[i - 1]; + const b = keyframes[i]; + if (progress <= b.offset) { + return b.offset === a.offset + ? b.value + : a.value + + ((b.value - a.value) * (progress - a.offset)) / + (b.offset - a.offset); + } + } + return keyframes[keyframes.length - 1].value; + }; + // Mid-presentation: 100 ms after it started. + await new Promise(resolve => + setTimeout(resolve, Math.max(0, presentStart + 100 - performance.now())), + ); + const closedAt = performance.now(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + // React commits the close in a microtask queued during the dispatch, + // and the leave runs in that commit. + await Promise.resolve(); + const leftBy = performance.now(); + const fade = opacityAnimations().find(animation => animation !== present); + if (fade == null) { + throw new Error('no fade after the close'); + } + // A fade that interrupts motion starts at the call (the animator + // continues it from the frame on screen): its start is the close's own + // instant, and its first keyframe the value it springs from. Read now, + // before it can retire. + const fadeStart = Number(fade.startTime); + const from = Number( + (fade.effect as KeyframeEffect).getKeyframes()[0].opacity, + ); + // Every frame until the dialog has closed, however far apart the + // browser puts them. + const frames: number[] = []; + let openFrames = 0; + await new Promise(resolve => { + const tick = () => { + frames.push(Number(getComputedStyle(element).opacity)); + const isOpen = element.matches('[open]'); + openFrames += isOpen ? 1 : 0; + if (isOpen && frames.length < 90) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + return { + presentStart, + closedAt, + leftBy, + fadeStart, + from, + presentedAtClose: presentedAt(fadeStart), + frames, + openFrames, + }; + }); +} + +interface AnimateCall { + pseudoElement: string | null; + /** The properties its keyframes animate. */ + properties: string[]; + /** When every property has reached its end, ms after the start. */ + settleMs: number; +} + +/** + * Records every Web Animations call on the dialog and its `::backdrop`: what + * the browser was asked to run, however short it is. + */ +async function recordAnimateCalls(page: Page): Promise { + await page.addInitScript(() => { + const calls: unknown[] = []; + (window as unknown as {__animateCalls: unknown[]}).__animateCalls = calls; + const original = Element.prototype.animate; + Element.prototype.animate = function ( + this: Element, + keyframes: Keyframe[] | PropertyIndexedKeyframes | null, + options?: number | KeyframeAnimationOptions, + ) { + const animation = original.call(this, keyframes, options); + if (this.tagName === 'DIALOG') { + const effect = animation.effect as KeyframeEffect; + const frames = effect.getKeyframes(); + const properties = [ + ...new Set( + frames.flatMap(frame => + Object.keys(frame).filter( + key => + !['offset', 'computedOffset', 'easing', 'composite'].includes( + key, + ), + ), + ), + ), + ].sort(); + const duration = Number(effect.getTiming().duration); + let settleMs = 0; + for (const property of properties) { + const end = frames[frames.length - 1][property]; + let reached = frames.length - 1; + while (reached > 0 && frames[reached - 1][property] === end) { + reached -= 1; + } + settleMs = Math.max( + settleMs, + frames[reached].computedOffset * duration, + ); + } + calls.push({ + // What the call asked for: an engine may report the target's + // pseudo-element differently (Linux WebKit answers ''). + pseudoElement: + typeof options === 'object' + ? (options.pseudoElement ?? null) + : null, + properties, + settleMs, + }); + } + return animation; + }; + }); +} + +async function animateCalls(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__animateCalls: AnimateCall[]}).__animateCalls, + ); +} + +interface AlertSample { + t: number; + scale: number; + opacity: number; + dim: number; +} + +/** + * Pauses the open dialog's animations (its own and its ::backdrop's, all on + * one start time) and reads the presented scale, opacity and dimming opacity + * at each time from their start. + */ +/** + * Pauses every animation started on a dialog the moment it is created + * (`sampleAlert` seeks them, and releaseAnimations plays them). + */ +async function holdDialogAnimationsAtCreation(page: Page): Promise { + await page.evaluate(() => { + const animate = Element.prototype.animate; + Element.prototype.animate = function hold( + this: Element, + ...args: Parameters + ) { + const animation = animate.apply(this, args); + if (this instanceof HTMLDialogElement) { + animation.pause(); + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? + []; + (window as unknown as {__harnessHeld: Animation[]}).__harnessHeld = [ + ...held, + animation, + ]; + } + return animation; + }; + }); +} + +async function sampleAlert( + page: Page, + times: ReadonlyArray, +): Promise<{held: number; samples: AlertSample[]}> { + return page.evaluate(ts => { + const dialog = document.querySelector('dialog[open]'); + if (dialog == null) { + throw new Error('no open dialog'); + } + // The transition in flight: the dialog's animations that started last + // (an earlier transition's curves may still hold their end state). + const onDialog = document + .getAnimations() + .filter(a => (a.effect as KeyframeEffect | null)?.target === dialog); + const latest = Math.max(...onDialog.map(a => Number(a.startTime))); + const held = onDialog.filter(a => Number(a.startTime) === latest); + for (const animation of held) { + animation.pause(); + } + // releaseAnimations resumes them. + (window as unknown as {__harnessHeld: Animation[]}).__harnessHeld = held; + const scaleOf = (value: string) => + value === 'none' ? 1 : Number(value.split(' ')[0]); + const samples = ts.map(t => { + for (const animation of held) { + animation.currentTime = t; + } + const own = getComputedStyle(dialog); + return { + t, + scale: scaleOf(own.scale), + opacity: Number(own.opacity), + dim: Number(getComputedStyle(dialog, '::backdrop').opacity), + }; + }); + return {held: held.length, samples}; + }, times); +} + +const channel = (samples: AlertSample[], key: 'scale' | 'opacity' | 'dim') => + samples.map(s => ({t: s.t, value: s[key]})); + +/** + * Frames the screencast delivered inside a window, judged at its own cadence, + * as the harness's busy-window verdict judges them: the median interval + * between the frames around the window (a loaded machine's screencast can + * deliver one frame in several), the frames that cadence makes room for, and + * the longest gap in those intervals. A main-thread animation delivers no + * frame for the whole block. + */ +function cadence( + frameTimes: ReadonlyArray, + window: {from: number; to: number}, +): { + frames: number; + expected: number; + cadenceMs: number; + longestGapMs: number; + longestGapFrames: number; +} { + const inside = frameTimes.filter(t => t >= window.from && t <= window.to); + const edges = [window.from, ...inside, window.to]; + let longestGapMs = 0; + for (let i = 1; i < edges.length; i += 1) { + longestGapMs = Math.max(longestGapMs, edges[i] - edges[i - 1]); + } + const around = frameTimes.filter( + t => t >= window.from - 250 && t <= window.to + 250, + ); + const intervals = around + .slice(1) + .map((t, i) => t - around[i]) + .sort((a, b) => a - b); + const cadenceMs = + intervals.length >= 4 + ? intervals[Math.floor(intervals.length / 2)] + : 1000 / 60; + return { + frames: inside.length, + expected: Math.floor((window.to - window.from) / cadenceMs), + cadenceMs, + longestGapMs, + longestGapFrames: Math.round(longestGapMs / cadenceMs), + }; +} + +test.describe('under a finger', () => { + test('presents from scale 1.2 and opacity 0 with the dimming, on the alert spring', async ({ + page, + }, testInfo) => { + const {driver, trigger, dialog} = await openPage(page); + // The dialog's animations are held as they are created, so the curves + // are sampled however late the page answers: a presentation that ran + // through before a poll after the tap first read the page (a fresh + // WebKit is that slow) left nothing to sample. + await holdDialogAnimationsAtCreation(page); + await driver.tap(await centerOf(trigger)); + await expect + .poll(async () => + page.evaluate( + () => + document + .getAnimations() + .filter( + a => + (a.effect as KeyframeEffect | null)?.target instanceof + HTMLDialogElement, + ).length, + ), + ) + .toBe(3); + const {held, samples} = await sampleAlert(page, timeline(PRESENT_MS)); + // The alert's scale and opacity, and the dimming's opacity. + expect(held).toBe(3); + const scale = compareCurve( + channel(samples, 'scale'), + t => scaleFrom + (1 - scaleFrom) * present(t), + SCALE_TOLERANCE, + ); + const opacity = compareCurve( + channel(samples, 'opacity'), + present, + OPACITY_TOLERANCE, + ); + const dim = compareCurve( + channel(samples, 'dim'), + present, + OPACITY_TOLERANCE, + ); + await releaseAnimations(page); + await writeEvidence( + 'curves/alertdialog-present', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {spring: springs.alertPresent, scaleFrom, scale, opacity, dim}, + null, + 1, + ), + }, + testInfo, + ); + expect(scale.maxError, JSON.stringify(scale.worst)).toBeLessThanOrEqual( + SCALE_TOLERANCE, + ); + expect(opacity.maxError, JSON.stringify(opacity.worst)).toBeLessThanOrEqual( + OPACITY_TOLERANCE, + ); + expect(dim.maxError, JSON.stringify(dim.worst)).toBeLessThanOrEqual( + OPACITY_TOLERANCE, + ); + // Nothing moves from the opener: the alert stays centred. + await waitForRest(dialog); + const box = await dialog.boundingBox(); + const viewport = page.viewportSize(); + expect(box).not.toBeNull(); + expect(viewport).not.toBeNull(); + if (box != null && viewport != null) { + expect(box.x + box.width / 2).toBeCloseTo(viewport.width / 2, 0); + } + }); + + test('dismisses with a fade alone on the alert spring, closes at its end and returns focus', async ({ + page, + }, testInfo) => { + const {driver, trigger, dialog} = await openPage(page); + await driver.tap(await centerOf(trigger)); + await waitForRest(dialog); + await installTimerGate(page); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + await waitForAnimation(dialog); + // The exit's backstop timer must not close the dialog while it is held. + await freezeTimers(page); + const {held, samples} = await sampleAlert(page, timeline(DISMISS_MS)); + // The alert's opacity and the dimming's: nothing scales. + expect(held).toBe(2); + const scale = compareCurve(channel(samples, 'scale'), () => 1, 0); + const opacity = compareCurve( + channel(samples, 'opacity'), + t => 1 - dismiss(t), + OPACITY_TOLERANCE, + ); + const dim = compareCurve( + channel(samples, 'dim'), + t => 1 - dismiss(t), + OPACITY_TOLERANCE, + ); + // Still presented while it fades: the caller's state is already closed. + await expect(dialog).toBeAttached(); + expect(await dialog.evaluate(el => (el as HTMLDialogElement).open)).toBe( + true, + ); + await releaseAnimations(page); + await thawTimers(page); + await writeEvidence( + 'curves/alertdialog-dismiss', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {spring: springs.alertDismiss, scale, opacity, dim}, + null, + 1, + ), + }, + testInfo, + ); + expect(scale.maxError, JSON.stringify(scale.worst)).toBe(0); + expect(opacity.maxError, JSON.stringify(opacity.worst)).toBeLessThanOrEqual( + OPACITY_TOLERANCE, + ); + expect(dim.maxError, JSON.stringify(dim.worst)).toBeLessThanOrEqual( + OPACITY_TOLERANCE, + ); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + }); + + test("starts inside the viewport: UIKit's 320 pt alert, never scaled past the screen", async ({ + page, + }) => { + const {driver, trigger, dialog} = await openPage(page); + await driver.tap(await centerOf(trigger)); + await waitForAnimation(dialog); + // Paused at the presentation's first frame: scale 1.2. + await sampleAlert(page, [0]); + const box = await dialog.evaluate(el => { + const rect = el.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + width: (el as HTMLElement).offsetWidth, + viewport: document.documentElement.clientWidth, + }; + }); + await releaseAnimations(page); + expect(box.width).toBe(alertController.width); + expect(box.left).toBeGreaterThanOrEqual(0); + expect(box.right).toBeLessThanOrEqual(box.viewport); + }); + + test('closed right after opening, it fades from where it is: from the presented value at the close, with its velocity, and never above that fade', async ({ + page, + }) => { + // A take in which the renderer painted no frame while the alert faded + // measured nothing: the WebKit container can stall a second under load + // (its first frame came 1.3 s after the close, with the dialog already + // closed). Such a take is taken again. + let result = await closeMidPresentation(page); + for (let take = 1; take < 3 && result.openFrames < 2; take += 1) { + result = await closeMidPresentation(page); + } + const detail = JSON.stringify(result); + // Closed mid-presentation. + expect(result.presentedAtClose, detail).toBeGreaterThan(0.2); + expect(result.presentedAtClose, detail).toBeLessThan(0.99); + // The fade starts in the close's own commit, not at an earlier time. + expect(result.fadeStart, detail).toBeGreaterThanOrEqual(result.closedAt); + expect(result.fadeStart, detail).toBeLessThanOrEqual(result.leftBy); + // It springs from the presented value at that instant... + expect( + Math.abs(result.from - result.presentedAtClose), + detail, + ).toBeLessThanOrEqual(OPACITY_TOLERANCE); + // ...with the presentation's velocity there: the fade rises to its own + // peak, then falls. No frame goes above that peak (the presented alert + // would), and none rises again after it. + const velocity = solveSpring(toSpring(springs.alertPresent), { + from: 0, + to: 1, + }).velocity((result.fadeStart - result.presentStart) / 1000); + const fade = solveSpring(toSpring(springs.alertDismiss), { + from: result.from, + to: 0, + velocity, + }); + let fadePeak = result.from; + for (let t = 0; t <= 0.1; t += 0.0001) { + fadePeak = Math.max(fadePeak, fade.value(t)); + } + expect(result.openFrames, detail).toBeGreaterThanOrEqual(2); + const peak = Math.max(...result.frames); + const peakAt = result.frames.indexOf(peak); + expect(peak, detail).toBeLessThanOrEqual(fadePeak + OPACITY_TOLERANCE); + // Before its peak the fade has only risen from where it started: no frame + // there shows less (the fade's end, say, painted early). + for (const value of result.frames.slice(0, peakAt)) { + expect(value, detail).toBeGreaterThanOrEqual( + result.from - OPACITY_TOLERANCE, + ); + } + // After it, the fade only falls. The engine's own rounding of computed + // values (WebKit's animation clock can put two frames a fraction of a + // millisecond apart, out of order) is the measurement's precision, as for + // every curve here. + let previous = peak; + for (const value of result.frames.slice(peakAt)) { + expect(value, detail).toBeLessThanOrEqual(previous + OPACITY_TOLERANCE); + previous = value; + } + }); + + test('a tap while the alert presents reaches nothing', async ({page}) => { + const {driver, trigger, dialog} = await openPage(page); + const triggerPoint = await centerOf(trigger); + // On the window, ahead of the hold's own listeners there (the hold + // swallows a held touch at the window, so the document never hears it). + await page.evaluate(() => { + const log = {start: Number.NaN, touches: [] as number[]}; + (window as unknown as {__presentLog: typeof log}).__presentLog = log; + window.addEventListener( + 'touchstart', + event => log.touches.push(event.timeStamp), + true, + ); + }); + await driver.tap(triggerPoint); + await waitForAnimation(dialog); + const cancelPoint = await centerOf( + dialog.getByRole('button', {name: 'Cancel'}), + ); + await page.evaluate(() => { + const dialogElement = document.querySelector('dialog[open]'); + const start = document + .getAnimations() + .find( + a => (a.effect as KeyframeEffect | null)?.target === dialogElement, + )?.startTime; + const log = (window as unknown as {__presentLog: {start: number; touches: number[]}}).__presentLog; + log.start = Number(start); + log.touches.length = 0; + }); + await driver.tap(cancelPoint); + const log = await page.evaluate( + () => + ( + window as unknown as { + __presentLog: {start: number; touches: number[]}; + } + ).__presentLog, + ); + // The tap must have landed inside the presentation for this to prove + // anything. + expect(log.touches[0] - log.start).toBeLessThan( + alertController.present.ignoresTouchesForMs, + ); + await waitForRest(dialog); + await page.waitForTimeout(100); + await expect(dialog).toBeVisible(); + }); + + test('a key still reaches the alert while it presents: Escape closes it', async ({ + page, + }) => { + // Keys and assistive technology stay live while it presents (the hold + // covers touches, presses and clicks alone). The key must land inside + // the presentation, under its hold, for this to prove anything: a busy + // page can push it past, and then it is taken again. + let heldAtKey = false; + for (let attempt = 0; attempt < 3 && !heldAtKey; attempt += 1) { + const {driver, trigger, dialog} = await openPage(page); + await page.evaluate(() => { + const log = {heldAtKey: [] as boolean[]}; + (window as unknown as {__keyLog: typeof log}).__keyLog = log; + document.addEventListener( + 'keydown', + () => + log.heldAtKey.push( + document.querySelector('dialog > [popover]') != null, + ), + true, + ); + }); + await driver.tap(await centerOf(trigger)); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + heldAtKey = await page.evaluate( + () => + (window as unknown as {__keyLog: {heldAtKey: boolean[]}}).__keyLog + .heldAtKey[0] === true, + ); + if (heldAtKey) { + await expect(dialog).toBeHidden(); + } + } + expect(heldAtKey).toBe(true); + }); + + // spec:AST-094 FR35 (DEC-16): a drag that lands during the entrance + // reaches nothing and scrolls nothing; an assistive-technology activation + // acts; a pinch still zooms the page. Each must land inside the + // presentation's hold to prove anything (the hold's shield is in the alert + // when it lands): a busy page can push it past, so each retries. + test('a drag that lands while the alert presents reaches nothing and scrolls nothing (spec:AST-094 FR35)', async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + 'needs native scrolling from injected touches (Mobile Safari on the simulator)', + ); + let heldAtDrag = false; + for (let attempt = 0; attempt < 3 && !heldAtDrag; attempt += 1) { + const {driver, trigger, dialog} = await openPage(page); + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + await page.evaluate(() => { + // A page that scrolls under the alert. + const spacer = document.createElement('div'); + spacer.style.height = '300vh'; + document.body.append(spacer); + const log = {heldAtDrag: [] as boolean[], clicks: [] as string[]}; + (window as unknown as {__dragLog: typeof log}).__dragLog = log; + window.addEventListener( + 'touchstart', + () => + log.heldAtDrag.push( + document.querySelector('dialog > [popover]') != null, + ), + true, + ); + document.addEventListener( + 'click', + event => { + const button = (event.target as Element).closest('dialog button'); + if (button != null) { + log.clicks.push(button.textContent ?? ''); + } + }, + true, + ); + }); + await driver.tap(await centerOf(trigger)); + await waitForAnimation(dialog); + const x = viewport.width / 2; + await driver.play( + drag( + {x, y: viewport.height - 40}, + {x, y: viewport.height - 340}, + {durationMs: 150, restMs: 50}, + ), + ); + const log = await page.evaluate( + () => + ( + window as unknown as { + __dragLog: {heldAtDrag: boolean[]; clicks: string[]}; + } + ).__dragLog, + ); + heldAtDrag = log.heldAtDrag.at(-1) === true; + if (heldAtDrag) { + await waitForRest(dialog); + await expect(dialog).toBeVisible(); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + expect(log.clicks).toEqual([]); + } + } + expect(heldAtDrag).toBe(true); + }); + + test('an assistive-technology activation reaches its action while the alert presents (spec:AST-094 FR35)', async ({ + page, + }) => { + let heldAtActivation = false; + for (let attempt = 0; attempt < 3 && !heldAtActivation; attempt += 1) { + const {driver, trigger, dialog} = await openPage(page); + await driver.tap(await centerOf(trigger)); + await waitForAnimation(dialog); + // An activation with no pointer, as assistive technology sends it: + // the button's own click, with no click count. + heldAtActivation = await page.evaluate(() => { + const held = document.querySelector('dialog > [popover]') != null; + const cancel = [...document.querySelectorAll('dialog button')].find( + button => button.textContent === 'Cancel', + ) as HTMLButtonElement | undefined; + cancel?.click(); + return held; + }); + if (heldAtActivation) { + await expect(dialog).toBeHidden(); + } + } + expect(heldAtActivation).toBe(true); + }); + + test('a pinch that lands while the alert presents still zooms the page (spec:AST-094 FR35, FR10)', async ({ + page, + browserName, + }) => { + test.skip( + browserName === 'webkit', + 'needs pinch-zoom from injected touches (Mobile Safari on the simulator)', + ); + let heldAtPinch = false; + for (let attempt = 0; attempt < 3 && !heldAtPinch; attempt += 1) { + const {driver, trigger, dialog} = await openPage(page); + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + await page.evaluate(() => { + const log = {heldAtPinch: [] as boolean[]}; + (window as unknown as {__pinchLog: typeof log}).__pinchLog = log; + window.addEventListener( + 'touchstart', + () => + log.heldAtPinch.push( + document.querySelector('dialog > [popover]') != null, + ), + true, + ); + }); + await driver.tap(await centerOf(trigger)); + await waitForAnimation(dialog); + await driver.play( + pinch({ + center: {x: viewport.width / 2, y: viewport.height / 2}, + fromDistance: 60, + toDistance: 220, + durationMs: 200, + }), + ); + // The pinch's first finger (the trigger's tap came before it). + heldAtPinch = await page.evaluate( + () => + (window as unknown as {__pinchLog: {heldAtPinch: boolean[]}}) + .__pinchLog.heldAtPinch[1] === true, + ); + if (heldAtPinch) { + await expect + .poll(async () => + page.evaluate(() => window.visualViewport?.scale ?? 1), + ) + .toBeGreaterThan(1.2); + await expect(dialog).toBeVisible(); + } + } + expect(heldAtPinch).toBe(true); + }); + + test('a tap or a key while the alert leaves reaches nothing, on the alert or under it', async ({ + page, + }) => { + const {driver, trigger, dialog} = await openPage(page); + const triggerPoint = await centerOf(trigger); + await driver.tap(triggerPoint); + await waitForRest(dialog); + const del = dialog.getByRole('button', {name: 'Delete'}); + const deletePoint = await centerOf(del); + await page.evaluate( + ([onDelete, onPage]) => { + const log = { + clicks: [] as string[], + heldHits: [] as boolean[], + keys: [] as {open: boolean; focusInAlert: boolean}[], + }; + (window as unknown as {__leave: typeof log}).__leave = log; + // A key while it leaves: whether the alert was still up, and whether + // focus was still in it (an action that turns inert while focused + // would still take Enter). + document.addEventListener( + 'keydown', + () => { + log.keys.push({ + open: document.querySelector('dialog[open]') != null, + focusInAlert: document.activeElement?.closest('dialog') != null, + }); + }, + true, + ); + // While it leaves, what a touch would hit, on the alert and on the + // page under it: the hold's shield, inside the alert, not the inert + // content and not whatever lies beneath it. + const hits = () => { + for (const point of [onDelete, onPage]) { + const hit = document.elementFromPoint(point.x, point.y); + log.heldHits.push( + hit?.matches('dialog > [popover]') === true && + hit.parentElement?.matches('dialog[open]') === true, + ); + } + }; + document.addEventListener( + 'click', + event => { + const button = (event.target as Element).closest('button'); + if (button != null) { + log.clicks.push(button.textContent ?? ''); + if (button.textContent === 'Cancel') { + requestAnimationFrame(() => requestAnimationFrame(hits)); + } + } + }, + true, + ); + }, + [deletePoint, triggerPoint], + ); + // Focus is in the alert when it starts to leave (its initial focus). + expect( + await page.evaluate( + () => document.activeElement?.closest('dialog') != null, + ), + ).toBe(true); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + // A second tap on Delete, and Enter, while the alert fades out. + await driver.tap(deletePoint); + await page.keyboard.press('Enter'); + await expect(dialog).toBeHidden(); + const log = await page.evaluate( + () => + ( + window as unknown as { + __leave: { + clicks: string[]; + heldHits: boolean[]; + keys: {open: boolean; focusInAlert: boolean}[]; + }; + } + ).__leave, + ); + expect(log.clicks).toEqual(['Cancel']); + expect(log.heldHits).toEqual([true, true]); + // The key landed while it was leaving, with focus already out of it. + expect(log.keys).toEqual([{open: true, focusInAlert: false}]); + }); + + test('present and dismiss keep presenting frames while the main thread is blocked', async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'screencast frame evidence is Chromium-only in Playwright (WebKit: the iOS simulator rig)', + ); + const {driver, trigger, dialog} = await openPage(page); + // This rig composites in software (no GPU), where Dialog's backdrop blur + // alone takes most of every frame: Chromium's DrawFrame costs 9.0 ms with + // it and 1.3 ms without, on an idle core. On a busy runner that drops + // frames inside the block and around it alike (3 CPU hogs on the test's 4 + // cores: 18 of 20 runs at about 30 fps with the blur, 0 of 20 without), + // while what this proves, which thread runs the motion, does not depend + // on it. The blur stays in every other proof and in the storyboard. + await page.addStyleTag({ + content: 'dialog::backdrop { backdrop-filter: none !important; }', + }); + const proofs: Record> = {}; + for (const phase of ['present', 'dismiss'] as const) { + const cast = await startScreencast(page, {format: 'jpeg', quality: 40}); + await driver.tap( + await centerOf( + phase === 'present' + ? trigger + : dialog.getByRole('button', {name: 'Cancel'}), + ), + ); + const [running] = await waitForAnimation(dialog, {minProgressMs: 16}); + const block = await blockMainThread(page, 250); + const frames = await cast.stop(); + // The curve's own end: the animation keeps holding its end state after. + const curveEnd = + running.from + (phase === 'present' ? PRESENT_MS : DISMISS_MS); + proofs[phase] = cadence( + frames.map(f => f.t), + {from: block.from + 20, to: Math.min(block.to, curveEnd) - 20}, + ); + if (phase === 'present') { + expect( + await dialog.evaluate( + el => getComputedStyle(el, '::backdrop').backdropFilter, + ), + ).toBe('none'); + await waitForRest(dialog); + await page.waitForTimeout(100); + } else { + await expect(dialog).toBeHidden(); + } + } + await writeEvidence( + 'busy-main-thread/alertdialog', + {[`${testInfo.project.name}.json`]: JSON.stringify(proofs, null, 1)}, + testInfo, + ); + // Judged at the screencast's own cadence (the harness's verdict, from + // #8045): something to judge, nearly every frame that cadence makes room + // for, and no gap over three of its intervals. No bound is absolute. + for (const proof of Object.values(proofs)) { + expect(proof.expected, JSON.stringify(proofs)).toBeGreaterThanOrEqual(4); + expect(proof.frames, JSON.stringify(proofs)).toBeGreaterThanOrEqual( + proof.expected - 2, + ); + expect( + proof.longestGapFrames, + JSON.stringify(proofs), + ).toBeLessThanOrEqual(3); + } + }); + + test('present and dismiss do no Layout or Paint per frame', async ({ + page, + browserName, + }) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {trigger, dialog} = await openPage(page); + const windows: Record = {}; + // Opened by a script click and closed with Escape: a tapped button's own + // press highlight fades with a paint per frame, which is not the alert's. + const trace = await traceRendering(page, async () => { + await trigger.evaluate(el => (el as HTMLElement).click()); + windows.present = (await waitForAnimation(dialog))[0]; + await waitForRest(dialog); + await page.waitForTimeout(100); + await page.keyboard.press('Escape'); + windows.dismiss = (await waitForAnimation(dialog))[0]; + await expect(dialog).toBeHidden(); + }); + const counts = Object.fromEntries( + Object.entries(windows).map(([phase, window]) => [ + phase, + // The steady part of the curve (the animation holds its end state + // after it): the first and last frames may commit. + countBetween( + trace, + window.from + 50, + window.from + (phase === 'present' ? PRESENT_MS : DISMISS_MS) - 50, + ), + ]), + ); + for (const during of Object.values(counts)) { + expect(during.Layout ?? 0, JSON.stringify(counts)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(counts)).toBe(0); + } + }); + + test('under reduced motion it cross-fades in and out, opacity only, settled within 200 ms, with no scale; the dimming too (design:reduced-motion DR1, DR2)', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await recordAnimateCalls(page); + const {driver, trigger, dialog} = await openPage(page); + await driver.tap(await centerOf(trigger)); + await expect(dialog).toBeVisible(); + await waitForRest(dialog); + const presented = await animateCalls(page); + // A cross-fade of the alert and of its dimming, not a change with none. + expect(new Set(presented.map(call => call.pseudoElement))).toEqual( + new Set([null, '::backdrop']), + ); + for (const call of presented) { + expect(call.properties, JSON.stringify(presented)).toEqual(['opacity']); + expect(call.settleMs, JSON.stringify(presented)).toBeLessThanOrEqual(200); + } + // At its own size: no scale, or the identity the animator writes in + // place. + expect(['none', '1', '1 1']).toContain( + await dialog.evaluate(el => getComputedStyle(el).scale), + ); + await page.evaluate(() => { + (window as unknown as {__animateCalls: unknown[]}).__animateCalls.length = + 0; + }); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + await expect(dialog).toBeHidden(); + const left = await animateCalls(page); + expect(new Set(left.map(call => call.pseudoElement))).toEqual( + new Set([null, '::backdrop']), + ); + for (const call of left) { + expect(call.properties, JSON.stringify(left)).toEqual(['opacity']); + expect(call.settleMs, JSON.stringify(left)).toBeLessThanOrEqual(200); + } + }); + + test( + 'storyboard: present and dismiss', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, trigger, dialog} = await openPage(page); + await installTimerGate(page); + const measure = async () => + page.evaluate(() => { + const element = document.querySelector('dialog[open]'); + if (element == null) { + return {scale: null, opacity: null, dim: null}; + } + const own = getComputedStyle(element); + return { + scale: own.scale, + opacity: Number(own.opacity), + dim: Number(getComputedStyle(element, '::backdrop').opacity), + }; + }); + await driver.tap(await centerOf(trigger)); + await waitForAnimation(dialog); + await freezeTimers(page); + const {storyboard: presentBoard} = await animationStoryboard(page, { + name: 'alertdialog-present', + scope: dialog, + times: timeline(PRESENT_MS, 1000 / 30), + measure, + columns: 7, + cellScale: 0.35, + }); + await presentBoard.write(testInfo, {spring: springs.alertPresent}); + await releaseAnimations(page); + await thawTimers(page); + await waitForRest(dialog); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + await waitForAnimation(dialog); + await freezeTimers(page); + const {storyboard: dismissBoard} = await animationStoryboard(page, { + name: 'alertdialog-dismiss', + scope: dialog, + times: timeline(DISMISS_MS, 1000 / 30), + measure, + columns: 7, + cellScale: 0.35, + }); + await dismissBoard.write(testInfo, {spring: springs.alertDismiss}); + await releaseAnimations(page); + await thawTimers(page); + await expect(dialog).toBeHidden(); + const first = presentBoard.frames[0].data; + const last = dismissBoard.frames.at(-1)?.data; + expect(first).toMatchObject({opacity: 0, dim: 0}); + expect(Number(String(first.scale).split(' ')[0])).toBeCloseTo( + scaleFrom, + 3, + ); + expect(Number(last?.opacity)).toBeLessThan(0.01); + }, + ); +}); + +/** + * Whether the dialog is still open on the next frame. Takes the element + * before the close: a closed dialog leaves the accessibility tree. + */ +const openOnNextFrame = async (element: ElementHandle | null) => + element?.evaluate( + async el => + new Promise(resolve => + requestAnimationFrame(() => resolve((el as HTMLDialogElement).open)), + ), + ); + +test.describe("under a finger in Chrome's engine", () => { + test.beforeEach(({browserName}) => { + test.skip(browserName !== 'chromium', "Chrome's engine"); + }); + + test("a finger gets the same presentation: UIKit's alert motion, not the theme's entrance", async ({ + page, + }) => { + await recordAnimateCalls(page); + const {driver, trigger, dialog} = await openPage(page); + expect( + await page.evaluate(() => ({ + coarse: matchMedia('(pointer: coarse)').matches, + ios: CSS.supports('(-webkit-touch-callout: none)'), + })), + ).toEqual({coarse: true, ios: false}); + await driver.tap(await centerOf(trigger)); + await expect(dialog).toBeVisible(); + await waitForRest(dialog); + // The animator's scale and fade on the alert, and the dimming's fade. + const calls = await animateCalls(page); + expect( + calls.some( + call => call.pseudoElement == null && call.properties.includes('scale'), + ), + JSON.stringify(calls), + ).toBe(true); + expect( + calls.some(call => call.pseudoElement === '::backdrop'), + JSON.stringify(calls), + ).toBe(true); + expect( + await dialog.evaluate(el => getComputedStyle(el).animationName), + ).toBe('none'); + }); +}); + +test.describe('with a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('keeps the theme entrance and closes at once', async ({page}) => { + const {trigger, dialog} = await openPage(page); + // A fine pointer in this context, before anything is checked. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(false); + // Recorded as it starts: a short entrance can end before a poll after the + // click first reads the page (a busy WebKit takes longer than it runs). + await page.evaluate(() => { + const record = window as unknown as {__entrance: number}; + record.__entrance = 0; + document.addEventListener( + 'animationstart', + event => { + const {target, pseudoElement} = event; + if ( + target instanceof Element && + target.tagName === 'DIALOG' && + !pseudoElement + ) { + record.__entrance += 1; + } + }, + {capture: true}, + ); + }); + await trigger.click(); + await waitForRest(dialog); + // The theme's CSS entrance ran on the alert (an animationstart is a CSS + // animation's). + expect( + await page.evaluate( + () => (window as unknown as {__entrance: number}).__entrance, + ), + ).toBeGreaterThan(0); + // A closed dialog leaves the accessibility tree: hold on to the element. + const element = await dialog.elementHandle(); + await dialog.getByRole('button', {name: 'Cancel'}).click(); + // No exit: the dialog is closed by the next frame. + expect(await openOnNextFrame(element)).toBe(false); + }); +}); diff --git a/packages/core/src/AlertDialog/useAlertMotion.test.tsx b/packages/core/src/AlertDialog/useAlertMotion.test.tsx new file mode 100644 index 0000000000000..241937f58ced2 --- /dev/null +++ b/packages/core/src/AlertDialog/useAlertMotion.test.tsx @@ -0,0 +1,685 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file useAlertMotion.test.tsx + * @input A component using useAlertMotion, with the motion animator replaced + * by a recorder whose animations start and end when the test says so + * @output The alert motion contract: an opening sets the alert to scale 1.2 + * and opacity 0 with the dimming at 0 and presents both from rest on + * ALERT_PRESENT_SPRING with no start time (the browser's commit); closed + * right after, it leaves from where it is; a closing keeps it presented + * while both fade out on ALERT_DISMISS_SPRING, then hides it (or after a + * backstop if the end is never reported); reopening mid-exit fades back + * without hiding; touches are refused during both transitions, the + * presentation's window counted from its reported start; under Reduce + * Motion the presentation cross-fades on its spring; a mouse taking over + * gets the dialog back at rest; disabled, it does nothing and follows the + * caller's open state + * @position jsdom tests of packages/core/src/AlertDialog/useAlertMotion.ts + */ + +import {act, render} from '@testing-library/react'; +import {StrictMode} from 'react'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + settlingDuration, + toSpring, + type SpringDescription, +} from '../motion/spring'; +import {motionRoleOf} from '../motion/roles'; +import {alertController} from '../motion/uikit/alert'; +import { + ALERT_DISMISS_SPRING, + ALERT_PRESENT_SPRING, +} from '../motion/uikit/springs'; +import type * as SpringsModule from '../motion/uikit/springs'; +import {isInHeldLayer} from '../Layer/inputHold'; +import {useAlertMotion, type AlertMotion} from './useAlertMotion'; + +type RecordedCall = + | {kind: 'attach'; target: unknown} + | {kind: 'set'; values: Record} + | { + kind: 'animate'; + values: Record; + spring: unknown; + velocity?: Record; + options: {at?: number; touchRelease?: boolean}; + }; + +interface Recorded { + readonly options: {pseudoElement?: string}; + readonly calls: RecordedCall[]; + /** What `get` and `getVelocity` answer: the values on screen. */ + presented: Record; + velocities: Record; + /** Ends each `animate` call's animation, in call order. */ + readonly endings: ((isDone: boolean) => void)[]; + /** Reports each `animate` call's start (when the browser started it). */ + readonly starts: ((time: number) => void)[]; +} + +const {recorded, reducedMotion, alertSprings} = vi.hoisted(() => ({ + recorded: [] as Recorded[], + reducedMotion: {matches: false}, + // The alert's springs as the hook reads them: the table's own, unless a + // test changes one. + alertSprings: { + present: null as SpringDescription | null, + dismiss: null as SpringDescription | null, + }, +})); + +vi.mock('../motion/uikit/springs', async importOriginal => { + const actual = await importOriginal(); + return Object.defineProperties( + {...actual}, + { + ALERT_PRESENT_SPRING: { + get: () => alertSprings.present ?? actual.ALERT_PRESENT_SPRING, + enumerable: true, + }, + ALERT_DISMISS_SPRING: { + get: () => alertSprings.dismiss ?? actual.ALERT_DISMISS_SPRING, + enumerable: true, + }, + }, + ); +}); + +vi.mock('../motion/animator', () => ({ + createAnimator: ( + _element: unknown, + options: {pseudoElement?: string} = {}, + ) => { + const record: Recorded = { + options, + calls: [], + endings: [], + starts: [], + presented: {}, + velocities: {}, + }; + recorded.push(record); + let element: unknown = null; + return { + // What Reduced Motion makes of a call here: the animator's own answer. + reducedMotion: () => (reducedMotion.matches ? 'crossfade' : 'off'), + get element() { + return element; + }, + attach: (target: unknown) => { + element = target; + record.calls.push({kind: 'attach', target}); + }, + set: (values: Record) => + record.calls.push({kind: 'set', values}), + animate: ( + values: Record, + transition: {spring: unknown; velocity?: Record}, + animateOptions: {at?: number; touchRelease?: boolean} = {}, + ) => { + record.calls.push({ + kind: 'animate', + values, + spring: transition.spring, + ...(transition.velocity == null + ? {} + : {velocity: transition.velocity}), + options: animateOptions, + }); + let end: (isDone: boolean) => void = () => {}; + const finished = new Promise(resolve => { + end = resolve; + }); + record.endings.push(end); + let start: (time: number) => void = () => {}; + const started = new Promise(resolve => { + start = resolve; + }); + record.starts.push(start); + return { + properties: Object.keys(values), + finished, + started, + value: () => 0, + velocity: () => 0, + retarget: () => { + throw new Error('not used'); + }, + stop: () => {}, + }; + }, + get: (property: string) => record.presented[property] ?? 0, + getVelocity: (property: string) => record.velocities[property] ?? 0, + play: () => { + throw new Error('not used'); + }, + decay: () => { + throw new Error('not used'); + }, + stop: () => {}, + dispose: () => {}, + }; + }, +})); + +let latest: AlertMotion | null = null; + +function Alert({isOpen, isEnabled}: {isOpen: boolean; isEnabled: boolean}) { + const motion = useAlertMotion({isOpen, isEnabled}); + latest = motion; + return ( +
+ +
+ ); +} + +const motion = (): AlertMotion => { + if (latest == null) { + throw new Error('not rendered'); + } + return latest; +}; + +/** Whether the input hold keeps touches from the alert (Layer/inputHold). */ +const isHeld = (): boolean => + isInHeldLayer(document.querySelector('[data-testid="alert"]')); + +/** The two animators the hook made: the alert and its dimming. */ +function animators(): {panel: Recorded; backdrop: Recorded} { + const panel = recorded.find(r => r.options.pseudoElement == null); + const backdrop = recorded.find(r => r.options.pseudoElement === '::backdrop'); + if (panel == null || backdrop == null) { + throw new Error('animators not created'); + } + return {panel, backdrop}; +} + +const moves = (record: Recorded) => + record.calls.filter(call => call.kind !== 'attach'); + +/** Slower springs than the alert's, both critically damped. */ +const SLOW_PRESENT = {mass: 1, stiffness: 240, damping: 31} as const; +const SLOW_DISMISS = {mass: 1, stiffness: 150, damping: 24.5} as const; + +/** A spring's length in whole ms: its settling duration (0.1 % from rest). */ +const lengthMs = (spring: SpringDescription) => + Math.round(settlingDuration(toSpring(spring)) * 1000); + +let now = 1000; +beforeEach(() => { + recorded.length = 0; + reducedMotion.matches = false; + alertSprings.present = null; + alertSprings.dismiss = null; + latest = null; + now = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => now); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('useAlertMotion', () => { + it('presents from scale 1.2 and opacity 0 with the dimming, from rest, on one spring and one start', () => { + render(); + const {panel, backdrop} = animators(); + // Set to its starting values and started in the same task: no start + // time, so the browser starts both at the commit that first shows the + // dialog, on one clock. No reduced-motion curve of its own: under reduced + // motion the animator's policy applies (design:reduced-motion DR4). + expect(moves(panel)).toEqual([ + { + kind: 'set', + values: {opacity: 0, scale: alertController.present.scaleFrom}, + }, + { + kind: 'animate', + values: {opacity: 1, scale: 1}, + spring: ALERT_PRESENT_SPRING, + options: {}, + }, + ]); + expect(moves(backdrop)).toEqual([ + {kind: 'set', values: {opacity: 0}}, + { + kind: 'animate', + values: {opacity: 1}, + spring: ALERT_PRESENT_SPRING, + options: {}, + }, + ]); + expect(motion().isPresented).toBe(true); + expect(motion().isDriving).toBe(true); + }); + + it("starts no wider than the viewport: an alert wider than UIKit's scales from less than 1.2", () => { + const offsetWidth = vi + .spyOn(HTMLElement.prototype, 'offsetWidth', 'get') + .mockReturnValue(370); + const clientWidth = vi + .spyOn(document.documentElement, 'clientWidth', 'get') + .mockReturnValue(402); + render(); + const {panel} = animators(); + expect(moves(panel)[0]).toEqual({ + kind: 'set', + values: {opacity: 0, scale: 402 / 370}, + }); + offsetWidth.mockRestore(); + clientWidth.mockRestore(); + }); + + it("starts at UIKit's 1.2 when that fits the viewport (its 320 pt alert on a 402 pt phone)", () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(320); + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue( + 402, + ); + render(); + const {panel} = animators(); + expect(moves(panel)[0]).toEqual({ + kind: 'set', + values: {opacity: 0, scale: alertController.present.scaleFrom}, + }); + }); + + it('leaves from where it is when closed right after opening: the fade springs from the presented values with the velocity on screen', async () => { + const view = render(); + const {panel, backdrop} = animators(); + // Mid-presentation: what the screen shows, and how fast it moves. + panel.presented = {opacity: 0.83, scale: 1.03}; + panel.velocities = {opacity: 2.1}; + backdrop.presented = {opacity: 0.83}; + backdrop.velocities = {opacity: 2.1}; + view.rerender(); + // From the presented values (the animator's), with their velocity; no + // start time from AlertDialog (the animator continues motion from its + // call). + expect(moves(panel).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 0}, + spring: ALERT_DISMISS_SPRING, + velocity: {opacity: 2.1}, + options: {}, + }); + expect(moves(backdrop).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 0}, + spring: ALERT_DISMISS_SPRING, + velocity: {opacity: 2.1}, + options: {}, + }); + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(motion().isPresented).toBe(false); + }); + + it('attaches both animators to the element only while it drives', () => { + const view = render(); + const {panel, backdrop} = animators(); + expect(panel.calls.at(-1)).toEqual({kind: 'attach', target: null}); + expect(backdrop.calls.at(-1)).toEqual({kind: 'attach', target: null}); + view.rerender(); + const element = view.getByTestId('alert'); + expect(panel.calls.at(-1)).toEqual({kind: 'attach', target: element}); + expect(backdrop.calls.at(-1)).toEqual({kind: 'attach', target: element}); + }); + + it('gives the dialog back at rest when a mouse takes over', () => { + const view = render(); + const {panel, backdrop} = animators(); + view.rerender(); + expect(moves(panel).at(-1)).toEqual({ + kind: 'set', + values: {opacity: 1, scale: 1}, + }); + expect(moves(backdrop).at(-1)).toEqual({ + kind: 'set', + values: {opacity: 1}, + }); + expect(panel.calls.at(-1)).toEqual({kind: 'attach', target: null}); + expect(backdrop.calls.at(-1)).toEqual({kind: 'attach', target: null}); + }); + + it('plays its springs through the internal motion roles springs.alertPresent and springs.alertDismiss (AlertDialog FR6, AST-094 FR4)', () => { + const view = render(); + const {panel} = animators(); + const shown = moves(panel).at(-1); + expect(shown?.kind === 'animate' && motionRoleOf(shown.spring)?.id).toBe( + 'springs.alertPresent', + ); + now = 2000; + view.rerender(); + const left = moves(panel).at(-1); + expect(left?.kind === 'animate' && motionRoleOf(left.spring)?.id).toBe( + 'springs.alertDismiss', + ); + }); + + it('stays presented while it fades out on alertDismiss, then hides', async () => { + const view = render(); + const {panel, backdrop} = animators(); + now = 2000; + view.rerender(); + expect(motion().isPresented).toBe(true); + expect(moves(panel).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 0}, + spring: ALERT_DISMISS_SPRING, + velocity: {opacity: 0}, + options: {}, + }); + expect(moves(backdrop).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 0}, + spring: ALERT_DISMISS_SPRING, + velocity: {opacity: 0}, + options: {}, + }); + // Only the alert's fade decides when it is hidden. + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(motion().isPresented).toBe(false); + }); + + it('hides once the exit has had its time when its end is never reported', () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const view = render(); + view.rerender(); + act(() => { + vi.advanceTimersByTime(alertController.dismiss.durationMs); + }); + expect(motion().isPresented).toBe(true); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(motion().isPresented).toBe(false); + }); + + it('fades back without hiding when reopened mid-exit', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const view = render(); + const {panel, backdrop} = animators(); + view.rerender(); + const exitEnding = panel.endings.at(-1); + // Mid-exit: fading out at 0.4, and falling. + panel.presented = {opacity: 0.4, scale: 1}; + panel.velocities = {opacity: -3}; + backdrop.presented = {opacity: 0.4}; + backdrop.velocities = {opacity: -3}; + now = 1200; + view.rerender(); + expect(motion().isPresented).toBe(true); + expect(moves(panel).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 1}, + spring: ALERT_PRESENT_SPRING, + velocity: {opacity: -3}, + options: {}, + }); + expect(moves(backdrop).at(-1)).toEqual({ + kind: 'animate', + values: {opacity: 1}, + spring: ALERT_PRESENT_SPRING, + velocity: {opacity: -3}, + options: {}, + }); + // Touches come back a presentation's time after the fade back starts. + expect(isHeld()).toBe(true); + await act(async () => { + panel.starts.at(-1)?.(1216); + }); + await act(async () => { + vi.advanceTimersByTime( + 1216 + alertController.present.ignoresTouchesForMs - now, + ); + }); + expect(isHeld()).toBe(false); + // The abandoned exit ending late does not hide the reopened alert. + await act(async () => { + exitEnding?.(true); + }); + expect(motion().isPresented).toBe(true); + }); + + it('gives focus back to the action the dismissal took it from when reopened mid-exit', () => { + const view = render(); + const action = document.querySelector( + '[data-testid="alert"] > button', + ); + act(() => action?.focus()); + expect(document.activeElement).toBe(action); + // Leaving: the dismissal's hold makes the content inert and blurs it. + view.rerender(); + expect(document.activeElement).toBe(document.body); + // Reopened mid-exit: live again, and focused where it was. + view.rerender(); + expect(action?.hasAttribute('inert')).toBe(false); + expect(document.activeElement).toBe(action); + }); + + it('holds only pointers while it presents: its content stays live for keys and assistive technology', () => { + render(); + const action = document.querySelector( + '[data-testid="alert"] > button', + ); + // Presenting and held: touches are refused under the shield... + expect(isHeld()).toBe(true); + expect(document.querySelector('[popover="manual"]')).not.toBeNull(); + // ...but nothing in the alert is inert or hidden from the accessibility + // tree, and it takes focus (a key reaches it). + expect(document.querySelector('[data-testid="alert"] [inert]')).toBeNull(); + // (The shield itself is hidden from it; the content is not.) + expect( + document.querySelector( + '[data-testid="alert"] [aria-hidden="true"]:not([popover])', + ), + ).toBeNull(); + expect( + document.querySelector('[data-testid="alert"]')?.hasAttribute('inert'), + ).toBe(false); + act(() => action?.focus()); + expect(document.activeElement).toBe(action); + }); + + it('keeps its input hold when mounted open under StrictMode, whose effect replay runs every cleanup once', () => { + render( + + + , + ); + // Presenting: held, with a shield over the viewport. + expect(isHeld()).toBe(true); + expect(document.querySelector('[popover="manual"]')).not.toBeNull(); + }); + + it('holds input until the presentation completes, counted from its actual start, and while it leaves', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const view = render(); + const {panel} = animators(); + const shield = () => document.querySelector('[popover="manual"]'); + // The browser has not reported its start yet (a busy main thread can + // push it later): held, however long that takes, with a shield over + // the viewport (a presentation's scope). + now = 6300; + expect(isHeld()).toBe(true); + expect(shield()).not.toBeNull(); + // Reported late: the window still counts from the start it reports. + await act(async () => { + panel.starts.at(-1)?.(6100); + }); + await act(async () => { + vi.advanceTimersByTime( + 6100 + alertController.present.ignoresTouchesForMs - 6300 - 1, + ); + }); + expect(isHeld()).toBe(true); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(isHeld()).toBe(false); + expect(shield()).toBeNull(); + // Leaving: a dismissal's scope, the shield again and the alert's + // content inert. + view.rerender(); + expect(isHeld()).toBe(true); + expect(shield()).not.toBeNull(); + expect( + document.querySelector('[data-testid="alert"] > button[inert]'), + ).not.toBeNull(); + }); + + it("under reduced motion refuses touches until the animator's cross-fade ends, not a spring's time after it starts", async () => { + reducedMotion.matches = true; + render(); + const {panel} = animators(); + expect(moves(panel).at(-1)).toMatchObject({kind: 'animate', options: {}}); + await act(async () => { + panel.starts.at(-1)?.(1000); + }); + // Past the spring's window, the cross-fade still running: refused. + now = 1000 + alertController.present.ignoresTouchesForMs + 50; + expect(isHeld()).toBe(true); + // The cross-fade ends (its duration is the animator's): accepted. + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(isHeld()).toBe(false); + }); + + it('under reduced motion, reopened mid-exit, takes touches once the cross-fade back ends', async () => { + const view = render(); + const {panel} = animators(); + view.rerender(); + reducedMotion.matches = true; + view.rerender(); + expect(isHeld()).toBe(true); + await act(async () => { + panel.starts.at(-1)?.(1000); + }); + now = 1000 + alertController.present.ignoresTouchesForMs + 50; + expect(isHeld()).toBe(true); + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(isHeld()).toBe(false); + }); + + it("derives the touch window and the exit backstop from the springs' lengths: slower springs move both", async () => { + alertSprings.present = SLOW_PRESENT; + alertSprings.dismiss = SLOW_DISMISS; + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const view = render(); + const {panel} = animators(); + // The animator plays the spring the window is timed from. + expect(panel.calls.find(call => call.kind === 'animate')).toMatchObject({ + spring: SLOW_PRESENT, + }); + now = 2000; + await act(async () => { + panel.starts.at(-1)?.(2000); + }); + // UIKit's frame or two after the spring stays: 435 of 404 ms. + const lag = + alertController.present.ignoresTouchesForMs - + alertController.present.durationMs; + const present = lengthMs(SLOW_PRESENT); + expect(present).toBeGreaterThan(alertController.present.durationMs + 100); + await act(async () => { + vi.advanceTimersByTime(present + lag - 1); + }); + expect(isHeld()).toBe(true); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(isHeld()).toBe(false); + view.rerender(); + act(() => { + vi.advanceTimersByTime(lengthMs(SLOW_DISMISS) + 99); + }); + expect(motion().isPresented).toBe(true); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(motion().isPresented).toBe(false); + }); + + it('takes no theme variable: `--motion-springs-alert-*` on its theme changes neither the spring it plays nor the touch window', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + render( +
+ +
, + ); + const {panel} = animators(); + expect(panel.calls.find(call => call.kind === 'animate')).toMatchObject({ + spring: ALERT_PRESENT_SPRING, + }); + now = 2000; + await act(async () => { + panel.starts.at(-1)?.(2000); + }); + await act(async () => { + vi.advanceTimersByTime(alertController.present.ignoresTouchesForMs - 1); + }); + expect(isHeld()).toBe(true); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(isHeld()).toBe(false); + }); + + it("measures UIKit's durations as its springs' lengths, so the alert keeps UIKit's 435 ms window", () => { + for (const [spring, measured] of [ + [ALERT_PRESENT_SPRING, alertController.present.durationMs], + [ALERT_DISMISS_SPRING, alertController.dismiss.durationMs], + ] as const) { + expect(Math.round(settlingDuration(toSpring(spring)) * 1000)).toBe( + measured, + ); + } + }); + + it('keeps refusing touches when the presentation reports its start after the alert began to leave', async () => { + const view = render(); + const {panel} = animators(); + const presentStart = panel.starts.at(-1); + view.rerender(); + await act(async () => { + presentStart?.(1016); + }); + now = 5000; + expect(isHeld()).toBe(true); + }); + + it('does nothing when disabled: no animation, and it closes with the caller', () => { + const view = render(); + view.rerender(); + const {panel, backdrop} = animators(); + expect(moves(panel)).toEqual([]); + expect(moves(backdrop)).toEqual([]); + expect(motion().isPresented).toBe(false); + expect(motion().isDriving).toBe(false); + expect(isHeld()).toBe(false); + }); + + it('leaves no timer behind when unmounted mid-exit', () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}); + const view = render(); + view.rerender(); + expect(vi.getTimerCount()).toBe(1); + view.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/core/src/AlertDialog/useAlertMotion.ts b/packages/core/src/AlertDialog/useAlertMotion.ts new file mode 100644 index 0000000000000..8fa2ad023c456 --- /dev/null +++ b/packages/core/src/AlertDialog/useAlertMotion.ts @@ -0,0 +1,416 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useAlertMotion.ts + * @input Whether the alert is open, whether it runs UIKit's motion, the + * motion animator, the alert entries of the UIKit constants table, and the + * layer runtime's input hold (Layer/inputHold) + * @output Exports useAlertMotion and AlertMotion: when the Dialog is + * presented (it stays open while the alert leaves), and the callback ref + * that puts the animators and the input hold on the dialog; whether a touch + * may act is the hold's (isInHeldLayer), which the gesture host reads + * @position Internal hook of AlertDialog; the touch presentation's motion + * + * UIKit's alert motion (measured on iOS 26.2; numbers in + * motion/uikit/alert.ts, springs in the `springs.alertPresent` and + * `springs.alertDismiss` roles): + * + * - presenting, the alert scales from 1.2 to 1 and fades in, and the dimming + * fades in, all on one critically damped spring (0.404 s); nothing moves + * from the opener; + * - dismissing, the alert and the dimming only fade out, on the same spring, + * from where they are (closed mid-presentation, or reopened mid-exit, the + * fade springs from the presented opacity with its velocity); + * - input is held during either transition (Layer/inputHold): while + * it presents, a touch, a pointer press or a click anywhere reaches nothing + * (keys and assistive technology stay live, and nothing is inert) until + * the presentation completes (UIKit's touch window, counted from when the + * browser started it, `handle.started`); while it leaves, its content is + * inert under the same shield until it is hidden; + * - under reduced motion the animator's own policy applies + * (docs/design/reduced-motion.md): the alert and the dimming only + * cross-fade, and touches come back when that cross-fade ends. The alert + * sets no curve of its own there (DR4). + * + * Both run on the compositor through the motion animator: the alert is the + * dialog element itself (`scale`, `opacity`), the dimming is its `::backdrop` + * (`opacity`), one start time for both: the browser's, at the commit that + * first shows the dialog. The dialog stays open while it + * leaves, so focus returns to the opener, the page scroll unlocks and the + * close event fires when it is hidden; Escape keeps reaching it until then. + * Reopening it mid-exit fades it back from where it is; unmounting removes it + * at once; if the exit never reports its end (animations disabled) the + * dialog closes anyway once its duration and a frame budget have passed. The + * touch window and that backstop derive from the lengths of the springs the + * animator plays, so they follow the springs. The springs are the internal + * motion roles `springs.alertPresent` and `springs.alertDismiss` + * (`component:AlertDialog` FR6, `spec:AST-094` FR4), which no per-role theme + * variable changes (`spec:AST-094` DEC-15); the timings are measured values, + * not roles. + * + * When `isEnabled` is false (a mouse, the inline preview) nothing here + * acts: the Dialog opens and closes with the theme's motion. + * + * SYNC: When modified, update useAlertMotion.test.tsx and + * __tests__/AlertDialog.motion.interaction.browser.spec.ts + */ + +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; +import { + createAnimator, + type Animator, + type MotionHandle, +} from '../motion/animator'; +import { + settlingDuration, + toSpring, + type SpringDescription, +} from '../motion/spring'; +import {holdInput} from '../Layer/inputHold'; +import {alertController} from '../motion/uikit/alert'; +import { + ALERT_DISMISS_SPRING, + ALERT_PRESENT_SPRING, + registerAlertSprings, +} from '../motion/uikit/springs'; + +/** + * The alert's springs, played through their internal motion roles + * `springs.alertPresent` and `springs.alertDismiss` (`component:AlertDialog` + * FR6, `spec:AST-094` FR4). Registering only those two rows keeps every other + * spring out of AlertDialog's bundle; it is idempotent and happens only when + * the touch motion first runs. + */ +function alertSprings(): { + present: SpringDescription; + dismiss: SpringDescription; +} { + registerAlertSprings(); + return {present: ALERT_PRESENT_SPRING, dismiss: ALERT_DISMISS_SPRING}; +} + +/** A dialog that misses its exit's end still closes this long after it. */ +const EXIT_BACKSTOP_MS = 100; + +/** + * How long a spring the alert plays runs, in whole ms: its settling duration + * (0.1 % from rest), the length UIKit gives the Core Animation spring. + */ +function springLengthMs(spring: SpringDescription): number { + return Math.round(settlingDuration(toSpring(spring)) * 1000); +} + +/** + * How long touches stay out once the presentation has started: the length + * of the presentation spring the animator plays, plus the frame or two UIKit + * takes to complete it (435 of 404 ms, measured). + */ +/** + * UIKit's 1.2 start, never wider than the viewport: an alert wider than + * UIKit's 320 pt would otherwise start past the screen's edges. One layout + * read, as the alert opens. + */ +function presentScaleFrom(element: HTMLElement | null): number { + const {scaleFrom} = alertController.present; + const width = element?.offsetWidth ?? 0; + const viewport = element?.ownerDocument.documentElement.clientWidth ?? 0; + return width > 0 && viewport > 0 + ? Math.max(1, Math.min(scaleFrom, viewport / width)) + : scaleFrom; +} + +/** + * Fades the alert and its dimming to `opacity` from where they are: the + * animator springs from the presented values, with the velocity on screen. A + * fade that interrupts motion continues from the frame on screen (the animator + * starts it at the call); one from rest starts at the commit that shows it, as + * the presentation does. The scale, if still settling, keeps its own curve. + */ +function fadeFromHere( + panel: Animator, + backdrop: Animator, + opacity: number, + spring: SpringDescription, +): MotionHandle { + backdrop.animate( + {opacity}, + {spring, velocity: {opacity: backdrop.getVelocity('opacity')}}, + ); + return panel.animate( + {opacity}, + {spring, velocity: {opacity: panel.getVelocity('opacity')}}, + ); +} + +function touchWindowMs(): number { + const {present} = alertController; + // UIKit's frame or two after the spring, from the measured pair. + const lag = present.ignoresTouchesForMs - present.durationMs; + return springLengthMs(alertSprings().present) + lag; +} + +type Status = 'hidden' | 'open' | 'leaving'; + +export interface AlertMotionOptions { + /** The caller's open state. */ + isOpen: boolean; + /** Run UIKit's alert motion (under a finger: a coarse primary pointer). */ + isEnabled: boolean; +} + +export interface AlertMotion { + /** Whether the Dialog is open: true while the alert is shown or leaving. */ + readonly isPresented: boolean; + /** True while the motion drives the dialog (its theme entrance is off). */ + readonly isDriving: boolean; + /** Callback ref for the dialog element. */ + readonly ref: (element: HTMLElement | null) => void; +} + +export function useAlertMotion({ + isOpen, + isEnabled, +}: AlertMotionOptions): AlertMotion { + // The open state the Dialog sees: the alert stays presented while it + // leaves. Adjusted while rendering, so the Dialog never commits a close + // the exit has not finished. + const [isLeaving, setIsLeaving] = useState(false); + const [lastIsOpen, setLastIsOpen] = useState(isOpen); + if (lastIsOpen !== isOpen) { + setLastIsOpen(isOpen); + setIsLeaving(!isOpen && isEnabled); + } + const isPresented = isOpen || isLeaving; + + const [animators] = useState(() => ({ + panel: createAnimator(null), + backdrop: createAnimator(null, {pseudoElement: '::backdrop'}), + })); + const isEnabledRef = useRef(isEnabled); + isEnabledRef.current = isEnabled; + + // The animators hold the dialog only while the motion drives it, so a mouse + // presentation keeps its own styles untouched. The callback is stable: a + // caller's changing ref must not re-attach them on every render. + const elementRef = useRef(null); + const ref = useCallback( + (node: HTMLElement | null) => { + elementRef.current = node; + const target = isEnabledRef.current ? node : null; + animators.panel.attach(target); + animators.backdrop.attach(target); + }, + [animators], + ); + useIsomorphicLayoutEffect(() => { + const {panel, backdrop} = animators; + if (isEnabled) { + panel.attach(elementRef.current); + backdrop.attach(elementRef.current); + return; + } + // A finger gave way to a mouse: the theme's styles show again. + if (panel.element != null) { + panel.set({opacity: 1, scale: 1}); + backdrop.set({opacity: 1}); + } + panel.attach(null); + backdrop.attach(null); + }, [animators, isEnabled]); + + // Starts hidden even when mounted open, so a first render that is already + // open presents like any other opening. + const statusRef = useRef('hidden'); + // The exit in flight: only its own end may hide the dialog. + const exitRef = useRef(null); + // The input hold in place (Layer/inputHold): a touch during + // either transition reaches nothing. + const releaseHoldRef = useRef<(() => void) | null>(null); + // The action a dismissal's hold blurred (its content turns inert), given + // focus back if the alert reopens mid-exit. + const blurredByDismissalRef = useRef(null); + // Unmounting lets the alert go at once, hold included. The alert counts as + // hidden again, so an effect replay (StrictMode runs every cleanup once, + // then every effect) presents it again and places its hold + // (architecture:react-component-runtime INV3). + useEffect( + () => () => { + releaseHoldRef.current?.(); + releaseHoldRef.current = null; + statusRef.current = 'hidden'; + exitRef.current = null; + blurredByDismissalRef.current = null; + }, + [], + ); + + useEffect(() => { + const was = statusRef.current; + const now: Status = !isPresented ? 'hidden' : isOpen ? 'open' : 'leaving'; + statusRef.current = now; + if (!isEnabled || was === now) { + return; + } + const {panel, backdrop} = animators; + // One hold at a time on the alert: a new transition replaces the last. + const hold = ( + until: PromiseLike, + scope: 'viewport' | 'layer', + cancel?: () => void, + ) => { + releaseHoldRef.current?.(); + const element = elementRef.current; + const release = + element == null ? null : holdInput(element, {until, scope}); + releaseHoldRef.current = () => { + cancel?.(); + release?.(); + }; + }; + // A presentation holds every touch until it completes: UIKit's measured + // touch window after the browser actually started it + // (`alertController.present.ignoresTouchesForMs`: UIKit lab, iOS 26.2, + // taps at 400 ms ignored and at 450 ms handled; UIKit completes a frame + // or two after the 404 ms spring, so the hold outlasts the animation's + // `finished`), or, under reduced motion, the animator's cross-fade's end + // (its duration is the animator's). + const holdWhilePresenting = (handle: MotionHandle) => { + if (panel.reducedMotion() !== 'off') { + hold(handle.finished, 'viewport'); + return; + } + const window = windowAfterStart(handle.started, touchWindowMs); + hold(window.end, 'viewport', window.cancel); + }; + if (now === 'open' && was === 'hidden') { + // The Dialog opened its modal in this same flush (its effect runs + // first), and nothing has painted it yet. The alert starts at its + // starting values and moves from rest with no start time: the browser + // starts it at the commit that first shows it, as Core Animation starts + // an animation at its transaction's commit, and the animator promotes + // it in this task. Chromium runs it on the compositor from that first + // frame through a busy main thread (the busy-thread browser proof). In + // iOS Safari an animation created in the task that first displays a + // dialog can stay on the main thread (motion/animator.ts, known limits); + // that case is not yet proven here. + panel.set({opacity: 0, scale: presentScaleFrom(elementRef.current)}); + backdrop.set({opacity: 0}); + holdWhilePresenting(present(panel, backdrop)); + return; + } + if (now === 'open' && was === 'leaving') { + // Reopened mid-exit: fade back from where it is, with its velocity. + // Touches come back a presentation's time after that fade starts. + exitRef.current = null; + holdWhilePresenting( + fadeFromHere(panel, backdrop, 1, alertSprings().present), + ); + // The dismissal's hold is released, its content live again: the action + // it blurred gets focus back, unless focus has gone somewhere since. + const blurred = blurredByDismissalRef.current; + blurredByDismissalRef.current = null; + const doc = elementRef.current?.ownerDocument; + if ( + blurred?.isConnected === true && + doc != null && + (doc.activeElement == null || doc.activeElement === doc.body) + ) { + blurred.focus({preventScroll: true}); + } + return; + } + if (now === 'leaving') { + // A dismissal drops every input until it ends: the alert's content is + // inert (no click, key or assistive-technology activation reaches an + // action) and the hold's shield catches every touch. + const handle = fadeFromHere(panel, backdrop, 0, alertSprings().dismiss); + const element = elementRef.current; + const focused = element?.ownerDocument.activeElement; + blurredByDismissalRef.current = + focused instanceof HTMLElement && + focused !== element && + element?.contains(focused) === true + ? focused + : null; + hold(handle.finished, 'layer'); + exitRef.current = handle; + void handle.finished.then(isDone => { + if (isDone && exitRef.current === handle) { + exitRef.current = null; + setIsLeaving(false); + } + }); + return; + } + // Hidden: the exit ended and the Dialog closed its modal. + exitRef.current = null; + blurredByDismissalRef.current = null; + releaseHoldRef.current?.(); + releaseHoldRef.current = null; + }, [animators, isEnabled, isOpen, isPresented]); + + // An exit whose end is never reported (animations disabled, a detached + // element) still hides the dialog once its duration has passed. Unmounting + // mid-exit removes the dialog at once; React detaches the animators + // through the ref. + useEffect(() => { + if (!isLeaving) { + return; + } + const backstop = setTimeout( + () => { + exitRef.current = null; + setIsLeaving(false); + }, + springLengthMs(alertSprings().dismiss) + EXIT_BACKSTOP_MS, + ); + return () => clearTimeout(backstop); + }, [isLeaving]); + + return {isPresented, isDriving: isEnabled, ref}; +} + +/** + * Settles `windowMs()` after the time `started` reports (performance.now() + * time), counted when it reports; `cancel` stops the timer. + */ +function windowAfterStart( + started: Promise, + windowMs: () => number, +): {end: Promise; cancel: () => void} { + let timer: ReturnType | undefined; + let isCancelled = false; + const end = new Promise(resolve => { + void started.then(start => { + if (!isCancelled) { + timer = setTimeout( + resolve, + Math.max(0, start + windowMs() - performance.now()), + ); + } + }); + }); + return { + end, + cancel: () => { + isCancelled = true; + clearTimeout(timer); + }, + }; +} + +function present(panel: Animator, backdrop: Animator): MotionHandle { + // From rest and with no `at` (a frame's timestamp is stale after a busy + // main thread): the browser starts both at the frame that commits them, one + // start for the alert and its dimming, as Core Animation starts an + // animation at its transaction's commit. Under reduced motion the + // animator's own policy turns it into an opacity-only cross-fade + // (design:reduced-motion); the alert passes no curve of its own (DR4). + const spring = alertSprings().present; + const handle = panel.animate({opacity: 1, scale: 1}, {spring}); + backdrop.animate({opacity: 1}, {spring}); + return handle; +} diff --git a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx index 6892c80c9c731..015dbfc4bb66c 100644 --- a/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx +++ b/packages/core/src/Breadcrumbs/BreadcrumbItem.tsx @@ -51,7 +51,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {Icon} from '../Icon'; import {usePopover} from '../Popover/usePopover'; import {useListFocus} from '../hooks/useListFocus'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {pressOnItemsOnly} from '../hooks/menuPressOnItems'; import {menuTouchActionStyles} from '../DropdownMenu/menuTouchAction.stylex'; import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; @@ -559,7 +559,7 @@ function BreadcrumbMenuTrigger({ // A press that slides across the menu's items acts on the item it lifts on, // the highlight following the pointer, as DropdownMenu's does // (`family:action-groups` FR1-FR3, FR5); a mouse released outside closes it. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: closeMenu, diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 322a6c7c85059..aada01f5fa88e 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -70,7 +70,7 @@ import { import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useListFocus} from '../hooks/useListFocus'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {useLongPress} from '../hooks/useLongPress'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; @@ -720,7 +720,7 @@ export function ContextMenu({ // The press model: the row under a release acts, the highlight follows a // held pointer, a mouse released outside dismisses and a finger leaves the // menu open. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: closeMenu, diff --git a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs index 949d2c551a941..252b37b4bb5ee 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs +++ b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs @@ -201,8 +201,7 @@ export const docs = { name: 'presentation', type: "'popover' | 'bottom-sheet' | 'adaptive'", description: - "Presentation surface for data-driven items. 'popover' stays anchored, 'bottom-sheet' always renders the actions in a modal BottomSheet, and 'adaptive' uses a BottomSheet on compact coarse-pointer layouts while remaining anchored elsewhere. Compound children currently support popover only.", - default: "'popover'", + "Presentation surface for data-driven items. 'popover' stays anchored, 'bottom-sheet' renders the actions in a modal BottomSheet, and 'adaptive' uses a BottomSheet on compact coarse-pointer layouts while remaining anchored elsewhere. On a compact touch screen, either sheet of plain actions (no sub-menus, links, rich labels, descriptions, end content, header or footer) presents the way iOS 26 presents an action sheet instead: a centered alert-style card with Cancel last, which a tap outside dismisses. Compound children currently support popover only. Omitted, the menu is anchored, and under a finger, on every device, its trigger opens as the finger presses (inside a scroll view, after the 150 ms content-touch delay), and the opening finger picks after moving 10 px. Pass 'popover' to keep the pointer behavior on every device, a 500 ms hold included.", }, { name: 'isMenuOpen', @@ -277,7 +276,7 @@ export const docs = { usage: { anatomy, description: - 'A dropdown menu that displays a list of actionable items in a popup triggered by a button. Use to present action options as a next step in a process, or to offer contextual actions without cluttering the interface. Like the menus of macOS and iOS, the row under the pointer when it is released is the row that acts, the highlight follows a held mouse or finger, a mouse opens it on press and can drag straight into it, and a finger held on the trigger opens it with the finger down.', + 'A dropdown menu that displays a list of actionable items in a popup triggered by a button. Use to present action options as a next step in a process, or to offer contextual actions without cluttering the interface. Like the menus of macOS and iOS, the row under the pointer when it is released is the row that acts, the highlight follows a held mouse or finger, a mouse opens it on press and can drag straight into it, and a finger held on the trigger opens it with the finger down. Under a finger, on every device, it behaves like UIMenu on iOS: a finger opens the menu as it presses the trigger (inside a scroll view, once the 150 ms content-touch delay has passed with no scroll), the finger that opened it picks a row only once it has moved 10 px (so the row that appears under it is never picked by accident), the menu grows out of its trigger and collapses back into it on UIKit\u2019s springs, and a pan that starts outside it dismisses it as the page scrolls. On a phone, as on a larger screen, the popover menu opens beside its trigger. A sub-menu opens in place as UIMenu\u2019s does: its card grows down from its row and the box with it, the parent\u2019s rows stay behind at half opacity and 97% scale, and the sub-menu\u2019s header, the row\u2019s label with its chevron turned down, takes it back.', bestPractices: [ { guidance: true, diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index 0cb451da9659c..583c7baf72825 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -104,30 +104,30 @@ keeps its released default. ## Behavioral and layout contract -| ID | Candidate invariant | Basis | Draft review state | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| FR1 | Every current render contains a Trigger button. Pointer presentation renders a Pointer menu surface with pointer-owned rows and optional pointer headings, dividers, indicators, and nested flyouts. Touch presentation renders a Touch sheet frame containing a Touch menu surface, Touch heading, Touch action list, and Touch action rows. | Current source, docs, and tests | Verified current behavior; no new behavior decided | -| FR2 | The six current local targets are `dropdown-menu`, `dropdown-menu-item`, `dropdown-menu-radio`, `dropdown-menu-section-heading`, `dropdown-menu-divider`, and `dropdown-menu-indicator-icon`; every target remains on its current painted element. | Current source, docs, and tests | Verified current inventory; no target change | -| FR3 | Button owns the Trigger button, BottomSheet owns the Touch sheet frame, List owns the Touch action list and Touch action rows, Indicator owns checkbox chrome, and Icon owns ordinary rendered icons. | Current source and owner docs | Verified current delegation; no ownership change | -| FR4 | The same `dropdown-menu` target reaches the alternative Pointer menu surface and Touch menu surface. Pointer action rows retain `dropdown-menu-item`; touch action rows instead use List's `list-item` target. | Current source and tests | Verified modality split; no target change | -| FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. Under a finger the press, the menu's grow and collapse, and its dismissal follow `spec:AST-094` FR26–FR35 (pending implementation). Trigger opening and keyboard navigation are unchanged. | `module:DropdownMenu/useMenuPress`; `DropdownMenu.test.tsx` press model suite | Proposed; verified in jsdom and real Chromium | -| FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; proposed in this change; `DropdownMenu.test.tsx` closing suite | Proposed; verified in jsdom, pending owner review | -| FR7 | `menuMaxHeight` MUST replace the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | `component:DropdownMenu/DEC-3` | Proposed; verified in jsdom; owner to confirm DEC-3 | -| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with the module's settle rule on the opening release, and under a finger as `spec:AST-094` FR29 states; pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before (`module:DropdownMenu/useMenuPress` proposed FR8, FR9). | Proposed `module:DropdownMenu/useMenuPress/DEC-4`; `DropdownMenu.test.tsx` press model suite | Proposed; verified in jsdom, pending owner review | -| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. | -| FR9 | With `renderTrigger`, the rendered control MUST carry `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and MUST receive the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. `button` and `trigger` are mutually exclusive: both given, `trigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner to confirm DEC-2 | -| Row destination | `href` (+ `target`, `rel`) on an action item | The row's act is navigation: the row itself is the link (`family:navigation-destinations`). | Pointer action row and Touch action row, compound and data mode | none (an action row) | `component:DropdownMenu` | stable | Blocked schemes follow the family rule; a disabled row has none. | -| FR10 | An action row with `href` MUST render as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click MUST run `onClick`, then close the menu (`hasCloseOnSelect`), then let the browser navigate; a modified click or middle click MUST be left to the browser and MUST NOT run `onClick`; Enter and Space MUST activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`. | `component:DropdownMenu/DEC-2`; `family:navigation-destinations` | Proposed; verified in jsdom; owner to confirm DEC-1 | -| Sub-menu presentation | `presentation` on a sub-menu row: `flyout`, `drill-in`, `adaptive` | Whether a sub-menu opens beside its row or replaces the menu's rows with its own and a Back row, in the same box. `adaptive` drills in when a coarse pointer opened the menu. | Pointer presentation, compound and data mode | `adaptive` | `component:DropdownMenu` | stable | An unknown value reads as `adaptive`. | -| FR11 | When a compact touch display opened the pointer menu (`COMPACT_TOUCH_PRESENTATION_QUERY`, the same query the root presentation uses for its bottom sheet, sampled once as it opens) an `adaptive` sub-menu row MUST drill in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft MUST return to the row with focus on it; a pick inside closes the whole menu. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | `component:DropdownMenu/DEC-2`; `architecture:interaction-modality` | Proposed; verified in jsdom and Chromium (coarse pointer); owner to confirm DEC-5 | -| FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Proposed in this change; `DropdownMenu.test.tsx` keyboard suite, `useListFocus.test.tsx`, `useTypeahead.test.tsx` | Proposed; verified in jsdom, pending owner review | -| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Proposed in this change; `DropdownMenuSubMenu.test.tsx` safe-triangle suite, `useMenuHover.test.tsx` | Proposed; verified in jsdom, pending owner review | -| FR14 | A compound `DropdownMenuGroup` is one `role="group"` named by its heading through `aria-labelledby`; the heading carries `dropdown-menu-section-heading`, is plain text rather than a menu item, and is skipped by roving focus and typeahead. An untitled group is an unnamed `role="group"`. | DEC-1, docs, and tests | Proposed; awaiting owner approval | -| FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval | -| FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved | -| FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 | -| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review | -| FR21 | Under a finger, in the touch modes (`presentation` not pinned to `'popover'`), the pointer menu grows out of the Trigger button and collapses back into it (`spec:AST-094` FR31), and closes on a pan that starts outside it (FR32); a pinned `'popover'` keeps the layer's own entrance. A pan dismissal is the menu's own close: focus that was in the menu returns to the Trigger button, whose `aria-expanded` turns false. A mouse keeps the layer's own entrance and exit. | Owner decision, 2026-10-26; `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | Decided; pending implementation | +| ID | Candidate invariant | Basis | Draft review state | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | Every current render contains a Trigger button. Pointer presentation renders a Pointer menu surface with pointer-owned rows and optional pointer headings, dividers, indicators, and nested flyouts. Touch presentation renders a Touch sheet frame containing a Touch menu surface, Touch heading, Touch action list, and Touch action rows. | Current source, docs, and tests | Verified current behavior; no new behavior decided | +| FR2 | The six current local targets are `dropdown-menu`, `dropdown-menu-item`, `dropdown-menu-radio`, `dropdown-menu-section-heading`, `dropdown-menu-divider`, and `dropdown-menu-indicator-icon`; every target remains on its current painted element. | Current source, docs, and tests | Verified current inventory; no target change | +| FR3 | Button owns the Trigger button, BottomSheet owns the Touch sheet frame, List owns the Touch action list and Touch action rows, Indicator owns checkbox chrome, and Icon owns ordinary rendered icons. | Current source and owner docs | Verified current delegation; no ownership change | +| FR4 | The same `dropdown-menu` target reaches the alternative Pointer menu surface and Touch menu surface. Pointer action rows retain `dropdown-menu-item`; touch action rows instead use List's `list-item` target. | Current source and tests | Verified modality split; no target change | +| FR5 | Both menu surfaces carry `data-astryx-menu-press` and follow `module:DropdownMenu/useMenuPress` FR1–FR7: the row under the release acts, the highlight follows a held pointer, a mouse released outside closes and a finger leaves the menu open, the browser's stray click never acts, and the Pointer menu surface declares `touch-action` by overflow. Under a finger the press, the menu's grow and collapse, and its dismissal follow `spec:AST-094` FR26–FR32, FR34 and FR35. Trigger opening and keyboard navigation are unchanged. | `module:DropdownMenu/useMenuPress`; `DropdownMenu.test.tsx` press model suite | Proposed; FR26, FR27 and FR29–FR32 verified in jsdom and real Chromium; FR28's cancel clear, FR34 and FR35 pending implementation | +| FR6 | When the pointer menu closes, focus returns to the Trigger button with a visible ring only when the menu was driven by keyboard; a press outside that landed on a focusable control keeps focus there. | `architecture:interaction-modality` INV1; proposed in this change; `DropdownMenu.test.tsx` closing suite | Proposed; verified in jsdom, pending owner review | +| FR7 | `menuMaxHeight` MUST replace the 300px term of the menu's and its popover viewport's block-size cap while the viewport gutters still bound both; the touch sheet ignores it. | `component:DropdownMenu/DEC-3` | Proposed; verified in jsdom; owner to confirm DEC-3 | +| FR8 | The Trigger button opens the pointer menu on a mouse press-down, with the module's settle rule on the opening release, and under a finger as `spec:AST-094` FR29 states; pressing the trigger of an open menu closes it without reopening in the same gesture; a mouse click and the keyboard open as before (`module:DropdownMenu/useMenuPress` proposed FR8, FR9). | Proposed `module:DropdownMenu/useMenuPress/DEC-4`; `DropdownMenu.test.tsx` press model suite | Proposed; verified in jsdom, pending owner review | +| Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. | +| FR9 | With `renderTrigger`, the rendered control MUST carry `aria-haspopup`, `aria-expanded`, `aria-controls` and the `id` the menu's `aria-labelledby` points at, and MUST receive the same press-model, keyboard-open and toggle-click handlers as the Button path; the menu is named by that control. `button` and `trigger` are mutually exclusive: both given, `trigger` wins and a dev warning fires, in both presentations. A drilled-in Back row (FR11) returns to the menu by that same name: the caller's `aria-label` when given, else the control's accessible name, read as the menu opens. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner to confirm DEC-2 | +| Row destination | `href` (+ `target`, `rel`) on an action item | The row's act is navigation: the row itself is the link (`family:navigation-destinations`). | Pointer action row and Touch action row, compound and data mode | none (an action row) | `component:DropdownMenu` | stable | Blocked schemes follow the family rule; a disabled row has none. | +| FR10 | An action row with `href` MUST render as one anchor carrying `role="menuitem"` (the pointer menu) or the touch sheet's link row, routed through `LinkProvider`; a plain click MUST run `onClick`, then close the menu (`hasCloseOnSelect`), then let the browser navigate; a modified click or middle click MUST be left to the browser and MUST NOT run `onClick`; Enter and Space MUST activate through a synthesized click that carries the key's modifiers. A disabled link row has no `href`. | `component:DropdownMenu/DEC-2`; `family:navigation-destinations` | Proposed; verified in jsdom; owner to confirm DEC-1 | +| Sub-menu presentation | `presentation` on a sub-menu row: `flyout`, `drill-in`, `adaptive` | Whether a sub-menu opens beside its row or replaces the menu's rows with its own and a Back row, in the same box. `adaptive` drills in when a coarse pointer opened the menu. | Pointer presentation, compound and data mode | `adaptive` | `component:DropdownMenu` | stable | An unknown value reads as `adaptive`. | +| FR11 | When a compact touch display opened the pointer menu (`COMPACT_TOUCH_PRESENTATION_QUERY`, the same query the root presentation uses for its bottom sheet, sampled once as it opens) an `adaptive` sub-menu row MUST drill in: its rows and a leading Back row named "Back to " replace the menu's rows in the same Pointer menu surface, no flyout opens, the drilled list is a `menu` named by the row's label, and Back, Escape and ArrowLeft MUST return to the row with focus on it; a pick inside closes the whole menu. Roving focus and typeahead scope to the shown rows; sibling rows and dividers need no knowledge of it. `presentation="flyout"` keeps the flyout; `"drill-in"` drills in on any pointer. | `component:DropdownMenu/DEC-2`; `architecture:interaction-modality` | Proposed; verified in jsdom and Chromium (coarse pointer); owner to confirm DEC-5 | +| FR12 | In the pointer menu ArrowDown on the last enabled row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp move to the last and first fully visible enabled row and, pressed there again, one viewport further without wrapping; ArrowUp on the trigger opens with the last enabled row highlighted; the key that opened the menu and its auto-repeats do not activate; typeahead matches the row's label element alone and ignores Control/Command chords and input-method composition. | Proposed in this change; `DropdownMenu.test.tsx` keyboard suite, `useListFocus.test.tsx`, `useTypeahead.test.tsx` | Proposed; verified in jsdom, pending owner review | +| FR13 | On a mouse, a nested flyout stays open while the pointer is inside the triangle from where it left its row to the flyout's near edge — including while the pointer is paused there — and closes after the existing delay once the pointer has left both the row and that triangle. The row's click toggle and its guard window are unchanged. | Proposed in this change; `DropdownMenuSubMenu.test.tsx` safe-triangle suite, `useMenuHover.test.tsx` | Proposed; verified in jsdom, pending owner review | +| FR14 | A compound `DropdownMenuGroup` is one `role="group"` named by its heading through `aria-labelledby`; the heading carries `dropdown-menu-section-heading`, is plain text rather than a menu item, and is skipped by roving focus and typeahead. An untitled group is an unnamed `role="group"`. | DEC-1, docs, and tests | Proposed; awaiting owner approval | +| FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval | +| FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved | +| FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 | +| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review | +| FR21 | Under a finger, in the touch modes (`presentation` not pinned to `'popover'`), the pointer menu grows out of the Trigger button and collapses back into it (`spec:AST-094` FR31), and closes on a pan that starts outside it (FR32); a pinned `'popover'` keeps the layer's own entrance. A pan dismissal is the menu's own close: focus that was in the menu returns to the Trigger button, whose `aria-expanded` turns false. A mouse keeps the layer's own entrance and exit. | Owner decision, 2026-10-26; `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | Decided; pending implementation | ### Allowed variation diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index b154b7cfb6107..100fd6bced4df 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -35,6 +35,11 @@ import {__resetInteractionModalityForTest} from '../utils/interactionModality'; import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import {spacingVars} from '../theme/tokens.stylex'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +import {isInputShield} from '../Layer/inputHold'; +import {MENU_PRESS_LONG_PRESS_MS} from '../hooks/useMenuPress'; +import {MENU_PRESS_SETTLE_MS} from '../hooks/menuPressGesture'; +import {COMPACT_QUERY, TOUCH_QUERY} from '../utils/touchPresentation'; +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; // Mock showPopover and hidePopover methods since they're not implemented in jsdom beforeEach(() => { @@ -371,10 +376,16 @@ describe('DropdownMenu', () => { }); it('uses the BottomSheet for adaptive presentation on compact touch', async () => { + // A compact touch screen answers each of its queries: the pointer, the + // width, and the two together. vi.stubGlobal( 'matchMedia', vi.fn().mockImplementation((query: string) => ({ - matches: query === COMPACT_TOUCH_PRESENTATION_QUERY, + matches: [ + COMPACT_TOUCH_PRESENTATION_QUERY, + TOUCH_QUERY, + COMPACT_QUERY, + ].includes(query), media: query, onchange: null, addEventListener: vi.fn(), @@ -394,8 +405,16 @@ describe('DropdownMenu', () => { ); await user.click(screen.getByRole('button', {name: /Actions/})); - expect(screen.getByRole('dialog', {name: 'Actions'})).toBeInTheDocument(); - expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + expect( + await screen.findByRole('dialog', {name: 'Actions'}), + ).toBeInTheDocument(); + // No menu opens as a popover. A touch presentation holds pointer input + // while it enters behind a shield in the top layer (`spec:AST-094` FR35), + // which is not a menu. + const shown = vi + .mocked(HTMLElement.prototype.showPopover) + .mock.contexts.filter(element => !isInputShield(element as Node)); + expect(shown).toEqual([]); }); it('uses the shared menu BottomSheet frame', () => { @@ -2700,6 +2719,266 @@ describe('DropdownMenu data/compound parity', () => { }); }); +/** The primary pointer: a finger (coarse) or a mouse. */ +function stubPointer(coarse: boolean): void { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: coarse && query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +describe('DropdownMenu touch presentation (UIMenu under a finger)', () => { + const originalRect = HTMLElement.prototype.getBoundingClientRect; + beforeEach(() => { + // A finger: the primary pointer is coarse, whatever the engine (one touch + // model on every touch device). A case that wants a mouse stubs it again. + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + // jsdom runs no animations and has no layout. + HTMLElement.prototype.animate = vi.fn(function () { + return { + cancel: vi.fn(), + finished: new Promise(() => {}), + startTime: null, + } as unknown as Animation; + }); + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const box = this.hasAttribute('popover') + ? {left: 75, top: 160, width: 250, height: 146} + : {left: 140, top: 160, width: 120, height: 44}; + return { + ...box, + x: box.left, + y: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + toJSON: () => ({}), + }; + }; + }); + afterEach(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + HTMLElement.prototype.getBoundingClientRect = originalRect; + vi.unstubAllGlobals(); + }); + /** A mouse: the primary pointer is fine. */ + const asMouse = () => { + stubPointer(false); + // A mouse case is a fine pointer's: prove the stub reports one. + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(false); + }; + + function renderMenu() { + render( + + {}} /> + {}} /> + , + ); + return screen.getByRole('button', {name: /Actions/}); + } + const popoverOf = () => + document.querySelector('[popover]:has([role="menu"])')!; + + it('grows out of its trigger: UIKit\u2019s pivot, and no CSS entrance', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const popover = popoverOf(); + expect(popover.style.getPropertyValue('animation-name')).toBe('none'); + expect(popover.style.getPropertyValue('transform-origin')).not.toBe(''); + }); + + it('closes on a static copy of the menu, hidden from assistive technology', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + act(() => { + const event = new Event('beforetoggle'); + Object.defineProperty(event, 'newState', {value: 'closed'}); + popoverOf().dispatchEvent(event); + }); + const copy = document.querySelector('[data-astryx-menu-exit]'); + expect(copy).not.toBeNull(); + expect(copy).toHaveAttribute('aria-hidden', 'true'); + expect(copy!.querySelector('[id]')).toBeNull(); + }); + + it('a pan that starts outside dismisses it as the browser takes the touch; a tap outside is the browser\u2019s', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + const finger = {pointerType: 'touch', pointerId: 5}; + // A tap outside: no cancel, so nothing here (light dismiss acts at the lift). + fireEvent.pointerDown(document.body, finger); + fireEvent.pointerUp(document.body, finger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // A pan outside: the browser cancels the pointer as it starts scrolling. + // The close is the menu's own (as a pick's): focus that was in the menu + // goes back to the trigger, whose collapsed state is what a screen + // reader announces. + const edit = screen.getByRole('menuitem', {name: 'Edit', hidden: true}); + act(() => edit.focus()); + expect(edit).toHaveFocus(); + fireEvent.pointerDown(document.body, finger); + fireEvent.pointerCancel(document.body, finger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('holds its press-end listeners only while an outside press is live', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + try { + const ends = (spy: typeof add) => + spy.mock.calls.filter(([type]) => + ['pointerup', 'pointercancel'].includes(type), + ).length; + expect(ends(add)).toBe(0); + const finger = {pointerType: 'touch', pointerId: 11}; + fireEvent.pointerDown(document.body, finger); + expect(ends(add)).toBe(2); + fireEvent.pointerUp(document.body, finger); + expect(ends(remove)).toBe(2); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + } finally { + add.mockRestore(); + remove.mockRestore(); + } + }); + + it('a re-render between the outside press and the browser\u2019s cancel still dismisses it', async () => { + const user = userEvent.setup(); + const menu = (label: string) => ( + + {}} /> + {}} /> + + ); + const {rerender} = render(menu('Edit')); + const trigger = screen.getByRole('button', {name: /Actions/}); + await user.click(trigger); + const finger = {pointerType: 'touch', pointerId: 9}; + fireEvent.pointerDown(document.body, finger); + // Anything that re-renders the menu while the finger is down (a parent's + // state, a new label) must not forget the pointer that began outside. + rerender(menu('Rename')); + fireEvent.pointerCancel(document.body, finger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('a mouse keeps the layer\u2019s own entrance and close: no pivot and no exit copy (touch work costs nothing under a fine pointer)', async () => { + asMouse(); + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const popover = popoverOf(); + expect(popover.style.getPropertyValue('animation-name')).toBe(''); + expect(popover.style.getPropertyValue('transform-origin')).toBe(''); + act(() => { + const event = new Event('beforetoggle'); + Object.defineProperty(event, 'newState', {value: 'closed'}); + popoverOf().dispatchEvent(event); + }); + expect(document.querySelector('[data-astryx-menu-exit]')).toBeNull(); + }); + + it('an Android finger is the same finger: a pan that starts outside dismisses it', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const finger = {pointerType: 'touch', pointerId: 8}; + fireEvent.pointerDown(document.body, finger); + fireEvent.pointerCancel(document.body, finger); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + describe('on a phone (compact and touch): beside its trigger, as on a larger screen', () => { + beforeEach(() => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + // Coarse primary pointer, and the compact viewport. + matches: query === '(pointer: coarse)' || query === COMPACT_QUERY, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + }); + + it('opens at the layer\u2019s placement beside its trigger, not over it', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const {style} = popoverOf(); + // Not centered on the trigger or pinned to the viewport's sides. + expect(style.getPropertyValue('justify-self')).toBe(''); + expect(style.getPropertyValue('left')).toBe(''); + expect(style.getPropertyValue('right')).toBe(''); + expect(style.getPropertyValue('top')).toBe(''); + expect(style.getPropertyValue('bottom')).toBe(''); + }); + + it('keeps the caller\u2019s placement', async () => { + const user = userEvent.setup(); + render( + + {}} /> + {}} /> + , + ); + await user.click(screen.getByRole('button', {name: /Above/})); + const {style} = popoverOf(); + expect(style.getPropertyValue('justify-self')).toBe(''); + expect(style.getPropertyValue('top')).toBe(''); + }); + }); + + it('a pan that starts inside the menu, or a mouse, does not dismiss it', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const finger = {pointerType: 'touch', pointerId: 6}; + const row = screen.getByRole('menuitem', {name: 'Edit', hidden: true}); + fireEvent.pointerDown(row, finger); + fireEvent.pointerCancel(row, finger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + const mouse = {pointerType: 'mouse', pointerId: 7, button: 0}; + fireEvent.pointerDown(document.body, mouse); + fireEvent.pointerCancel(document.body, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); +}); + describe('DropdownMenu press model', () => { const mouse = {pointerType: 'mouse', pointerId: 1, button: 0}; const touch = {pointerType: 'touch', pointerId: 1}; @@ -2721,6 +3000,24 @@ describe('DropdownMenu press model', () => { const item = (name: string) => screen.getByRole('menuitem', {name, hidden: true}); + // A phone or tablet: the touch modes are DropdownMenu's default there. + function stubCoarsePointer() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === TOUCH_QUERY, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(true); + } + it('a finger that lands on one row and lifts on another acts on the second, once', async () => { const onPick = vi.fn(); const user = userEvent.setup(); @@ -2871,20 +3168,30 @@ describe('DropdownMenu press model', () => { expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); }); - it('a held touch on the trigger opens with the finger down and a slide picks', () => { + it('under a coarse pointer a finger opens the menu as it presses the trigger (no scroll view), and a slide picks', () => { + stubCoarsePointer(); vi.useFakeTimers(); try { const onPick = vi.fn(); const trigger = renderMenu(onPick); - fireEvent.pointerDown(trigger, touch); - expect(trigger).toHaveAttribute('aria-expanded', 'false'); - act(() => { - vi.advanceTimersByTime(500); - }); + // spec:AST-094 FR29: a trigger in no scroll view opens on the press. + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); expect(trigger).toHaveAttribute('aria-expanded', 'true'); - fireEvent.pointerMove(item('Delete'), touch); + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(item('Delete')).not.toHaveFocus(); + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); expect(item('Delete')).toHaveFocus(); - fireEvent.pointerUp(item('Delete'), touch); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); expect(onPick).toHaveBeenCalledWith('Delete'); // The click the browser aims at the trigger for this gesture is spent. fireEvent.click(trigger, {detail: 1}); @@ -2894,6 +3201,132 @@ describe('DropdownMenu press model', () => { } }); + it('under a coarse pointer the finger that opened the menu picks only once it travels the handoff (spec:AST-094 FR29)', () => { + stubCoarsePointer(); + vi.useFakeTimers(); + try { + const onPick = vi.fn(); + const trigger = renderMenu(onPick); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // A still lift picks nothing, however long the finger rested. + act(() => { + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + }); + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(item('Delete')).not.toHaveFocus(); + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); + expect(onPick).toHaveBeenCalledWith('Delete'); + } finally { + vi.useRealTimers(); + } + }); + + it('on a fine primary pointer too, the finger that opened the menu picks only once it travels the handoff, as on a phone (spec:AST-094 FR29, FR43)', () => { + vi.useFakeTimers(); + try { + const onPick = vi.fn(); + const trigger = renderMenu(onPick); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + }); + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(item('Delete')).not.toHaveFocus(); + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3 + UIKIT_GESTURES.menuPress.handoff.value, + }); + expect(onPick).toHaveBeenCalledWith('Delete'); + } finally { + vi.useRealTimers(); + } + }); + + it('presentation="popover" pins the settle rule under a finger: once the opening press has settled, a still lift picks the row under it', () => { + // spec:AST-094 FR25: a moved touch default comes back through + // `presentation`, never through a public threshold. + stubCoarsePointer(); + vi.useFakeTimers(); + try { + const onPick = vi.fn<(label: string) => void>(); + render( + + onPick('Edit')} /> + onPick('Delete')} /> + , + ); + const trigger = screen.getByRole('button', {name: /Pinned/}); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS + MENU_PRESS_SETTLE_MS); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 2}); + fireEvent.pointerUp(item('Delete'), {...touch, clientX: 0, clientY: 2}); + expect(onPick).toHaveBeenCalledWith('Delete'); + } finally { + vi.useRealTimers(); + } + }); + + it('presentation="popover" pins the released trigger under a finger: it opens at 500 ms, not on the press', () => { + // spec:AST-094 FR25: a moved touch default comes back through + // `presentation`, never through a public threshold. + stubCoarsePointer(); + vi.useFakeTimers(); + try { + const onPick = vi.fn<(label: string) => void>(); + render( + + onPick('Edit')} /> + onPick('Delete')} /> + , + ); + const trigger = screen.getByRole('button', {name: /Pinned/}); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + // Not on the press, as the touch modes would open it. + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS - 1); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(MENU_PRESS_LONG_PRESS_MS).toBe(500); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + } finally { + vi.useRealTimers(); + } + }); + it('a menu a held finger opens keeps its trigger as the invoker until the finger lifts', () => { // A release on the trigger is outside the `popover="auto"` menu, so the // browser's light dismiss would close it; holding the invoker through @@ -2925,6 +3358,175 @@ describe('DropdownMenu press model', () => { fireEvent.click(trigger, {detail: 1}); expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); + + // spec:AST-094 FR43 and module:DropdownMenu/useMenuPress FR9: a finger + // follows the menus' finger rules on every device, a touchscreen laptop + // (a fine primary pointer) as on a phone; finger or mouse is still read per + // gesture, and `presentation="popover"` keeps the released finger behavior. + describe('a finger follows the same rules on every device; each gesture is still a finger or a mouse', () => { + function stubFinePointer() { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(false); + } + const touchmoveIsPrevented = () => { + const event = new Event('touchmove', {bubbles: true, cancelable: true}); + document.body.dispatchEvent(event); + return event.defaultPrevented; + }; + + it('fine primary pointer: a finger opens the menu as it presses the trigger, as on a phone (spec:AST-094 FR29, FR43)', () => { + stubFinePointer(); + const trigger = renderMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer: a pen opens the menu as it presses the trigger, as a finger does (spec:AST-094 FR29, FR43; spec:AST-017 FR59)', () => { + stubFinePointer(); + const trigger = renderMenu(); + const pen = {pointerType: 'pen', pointerId: 7, clientX: 0, clientY: 0}; + fireEvent.pointerDown(trigger, pen); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, pen); + // The pen's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer, presentation="popover": a finger keeps the released behavior, its press opening nothing and its tap opening the menu through the click (spec:AST-094 FR25)', () => { + stubFinePointer(); + render( + + + + , + ); + const trigger = screen.getByRole('button', {name: /Pinned/}); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('fine primary pointer: the finger that opened the menu picks only once it travels the handoff, as on a phone (spec:AST-094 FR29, FR43)', () => { + stubFinePointer(); + const onPick = vi.fn<(label: string) => void>(); + const trigger = renderMenu(onPick); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // The finger's later touchmoves are the menu's, not a scroll. + expect(touchmoveIsPrevented()).toBe(true); + // A finger that barely moved lights nothing, however long it rests. + fireEvent.pointerMove(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(item('Delete')).not.toHaveFocus(); + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. + const handoff = 3 + UIKIT_GESTURES.menuPress.handoff.value; + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: handoff, + }); + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(item('Delete'), { + ...touch, + clientX: 0, + clientY: handoff, + }); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(touchmoveIsPrevented()).toBe(false); + }); + + it('fine primary pointer: a finger that lifts outside the menu leaves it open; a mouse released outside closes it', async () => { + stubFinePointer(); + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + fireEvent.pointerDown(item('Edit'), touch); + fireEvent.pointerUp(document.body, touch); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerDown(item('Edit'), mouse); + fireEvent.pointerUp(document.body, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + function renderCountingMenu() { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const onPick = vi.fn<(label: string) => void>(); + render( + + onPick('Edit')} /> + onPick('Delete')} /> + , + ); + return { + trigger: screen.getByRole('button', {name: /Actions/}), + onOpenChange, + onPick, + }; + } + + it('coarse primary pointer: a finger opens the menu once, as it presses, and it closes once at a pick (module AR5)', () => { + stubCoarsePointer(); + const {trigger, onOpenChange, onPick} = renderCountingMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(onOpenChange.mock.calls).toEqual([[true]]); + fireEvent.pointerDown(item('Delete'), touch); + fireEvent.pointerUp(item('Delete'), touch); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it('fine primary pointer: a finger opens the menu once, as it presses, and it closes once at a pick, as on a phone (module AR5, spec:AST-094 FR43)', () => { + stubFinePointer(); + const {trigger, onOpenChange, onPick} = renderCountingMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + // The tap's own click comes after the lift; it must not toggle. + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(onOpenChange.mock.calls).toEqual([[true]]); + fireEvent.pointerDown(item('Delete'), touch); + fireEvent.pointerUp(item('Delete'), touch); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it('coarse primary pointer: a mouse keeps the mouse path: it opens on its press, and a press in the menu released outside closes it', () => { + stubCoarsePointer(); + const trigger = renderMenu(); + fireEvent.pointerDown(trigger, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerUp(trigger, mouse); + fireEvent.click(trigger, {detail: 1}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + fireEvent.pointerDown(item('Edit'), mouse); + fireEvent.pointerUp(document.body, mouse); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + }); }); describe('DropdownMenu trigger handler composition', () => { diff --git a/packages/core/src/DropdownMenu/DropdownMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenu.tsx index 31367307bcb5d..d73a4c056f893 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -22,6 +22,14 @@ * A sub-menu drills in on a phone through the view stack useMenuDrillIn * keeps; the root shows the drilled view in place of its rows. * + * Under a finger (a coarse primary pointer, the shared touch resolver; one + * touch model on every touch device) the popover opens and closes the way + * UIMenu does: it grows out of its trigger on UIKit's open spring and + * collapses back into it on the close spring (menuSurfaceMotion.ts), and a + * pan that begins outside closes it. On every screen it opens beside its + * trigger at the layer's placement. Under a mouse it keeps the layer's own + * entrance and the theme's motion tokens (D-01). + * * Initial focus on open follows the input modality: a keyboard open * (Enter / Space / ArrowDown on the trigger) focuses the first enabled item * (APG menu-button); a pointer open focuses the menu container itself so no @@ -68,7 +76,7 @@ import { } from './DropdownMenuContext'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {useListFocus} from '../hooks/useListFocus'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useDropdownMenuPress} from '../hooks/useMenuPress'; import type {MenuPressPointerType} from '../hooks/menuPressGesture'; import {useTypeahead} from '../hooks/useTypeahead'; import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility'; @@ -81,10 +89,16 @@ import {useMenuDrillIn} from './useMenuDrillIn'; import {resolveMenuWidth} from './menuWidth'; import {menuTouchActionStyles} from './menuTouchAction.stylex'; import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; +import type {AdaptivePresentation} from '../hooks/useAdaptivePresentation'; +import {usePresentation} from '../utils/presentation'; +import {useTouchPresentation} from '../utils/touchPresentation'; import { - useAdaptivePresentation, - type AdaptivePresentation, -} from '../hooks/useAdaptivePresentation'; + MenuActionSheetCard, + createMenuDrillInMotion, + createMenuSurfaceMotion, + isActionSheetCardEligible, + type MenuSurfaceMotion, +} from './DropdownMenuTouch'; // Loaded on demand: only the bottom-sheet presentation renders the sheet. import { MenuBottomSheet, @@ -100,6 +114,7 @@ import { radiusVars, durationVars, easeVars, + shadowVars, } from '../theme/tokens.stylex'; import {isRenderable, mergeProps, rtlStyles} from '../utils'; import type {BaseProps} from '../BaseProps'; @@ -116,6 +131,15 @@ import {focusOutlineStyles} from '../utils/focusOutline.stylex'; const MENU_BLOCK_CAP = '300px'; const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSize})`; const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`; +// While the menu's box grows or shrinks around a sub-menu's card +// (menuDrillInMotion.ts sets the attribute to the edge that stays). +const BOX_MOTION = ':is([data-astryx-menu-box-motion])'; +const BOX_KEEPS_TOP = ':is([data-astryx-menu-box-motion="top"])'; +const BOX_KEEPS_BOTTOM = ':is([data-astryx-menu-box-motion="bottom"])'; +const BOX_CAP = 'var(--_menu-box-radius, 0px)'; +/** How far a moving part of the box may cast its shadow, px. */ +const BOX_SHADOW_REACH = '200px'; + const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`; // A sub-menu's drilled-in view with a header or footer renders in the surface @@ -178,6 +202,75 @@ const styles = stylex.create({ overflowX: 'hidden', overscrollBehavior: 'contain', }, + // A sub-menu's card shares one cell with the parent's rows it lies over + // (useMenuDrillIn), so the menu's box grows to hold it. + cardStack: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr)', + alignContent: 'start', + position: 'relative', + }, + // A card going back plays out over the rows, positioned in the menu. + cardLeaving: { + position: 'relative', + }, + // The menu's box while it grows or shrinks around a sub-menu's card + // (menuDrillInMotion.ts): its paint steps aside for its own pseudo-elements, + // so nothing is added to draw it. Its background stays only on the corner + // cap at the edge that stays; ::after is the middle, which scales, and + // ::before the cap at the moving edge, which travels; each casts the box's + // shadow on its outer sides only. + boxMotion: { + isolation: 'isolate', + backgroundColor: {default: null, [BOX_MOTION]: 'transparent'}, + boxShadow: {default: null, [BOX_MOTION]: 'none'}, + backgroundImage: { + default: null, + [BOX_KEEPS_TOP]: `linear-gradient(to bottom, ${colorVars['--color-background-popover']} ${BOX_CAP}, transparent ${BOX_CAP})`, + [BOX_KEEPS_BOTTOM]: `linear-gradient(to top, ${colorVars['--color-background-popover']} ${BOX_CAP}, transparent ${BOX_CAP})`, + }, + '::before': { + content: '""', + display: {default: 'none', [BOX_MOTION]: 'block'}, + position: 'absolute', + insetInline: 0, + height: BOX_CAP, + top: {default: null, [BOX_KEEPS_BOTTOM]: 0}, + bottom: {default: null, [BOX_KEEPS_TOP]: 0}, + backgroundColor: colorVars['--color-background-popover'], + boxShadow: shadowVars['--shadow-low'], + borderRadius: { + default: null, + [BOX_KEEPS_TOP]: `0 0 ${BOX_CAP} ${BOX_CAP}`, + [BOX_KEEPS_BOTTOM]: `${BOX_CAP} ${BOX_CAP} 0 0`, + }, + clipPath: { + default: null, + [BOX_KEEPS_TOP]: `inset(0 -${BOX_SHADOW_REACH} -${BOX_SHADOW_REACH})`, + [BOX_KEEPS_BOTTOM]: `inset(-${BOX_SHADOW_REACH} -${BOX_SHADOW_REACH} 0)`, + }, + zIndex: -1, + pointerEvents: 'none', + }, + '::after': { + content: '""', + display: {default: 'none', [BOX_MOTION]: 'block'}, + position: 'absolute', + insetInline: 0, + top: BOX_CAP, + bottom: BOX_CAP, + backgroundColor: colorVars['--color-background-popover'], + boxShadow: shadowVars['--shadow-low'], + clipPath: `inset(0 -${BOX_SHADOW_REACH})`, + transformOrigin: { + default: null, + [BOX_KEEPS_TOP]: '50% 0%', + [BOX_KEEPS_BOTTOM]: '50% 100%', + }, + zIndex: -1, + pointerEvents: 'none', + }, + }, // With a header or footer, the regions and the menu share one column under // the menu's block cap: the regions keep their height and the menu shrinks // and scrolls between them. @@ -452,7 +545,10 @@ interface DropdownMenuDataProps extends DropdownMenuBaseProps { * Surface used to present the actions. Pass a value selected by product * policy (for example, a compact-touch media query) when the presentation * should adapt. Bottom-sheet presentation is intended for short action sets. - * @default 'popover' + * Omitted, the menu is the anchored popover, and under a finger, on every + * device, its trigger opens as UIMenu's pull-down button does. `'popover'` + * pins the anchored popover with the pointer behavior on every device (a + * finger held for 500 ms opens it, as before). */ presentation?: DropdownMenuPresentation; items: DropdownMenuOption[]; @@ -460,6 +556,11 @@ interface DropdownMenuDataProps extends DropdownMenuBaseProps { } interface DropdownMenuCompoundProps extends DropdownMenuBaseProps { + /** + * `'popover'` pins the pointer behavior on every device (a finger held for + * 500 ms opens the menu, as before). Omitted, on every device, a finger's + * press opens the menu as UIMenu's pull-down button does. + */ presentation?: 'popover'; items?: undefined; children: ReactNode; @@ -654,6 +755,26 @@ function DropdownMenuBottomSheet({ const currentTitle = currentSubmenu?.label ?? button.label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : button.label; + // On a compact touch screen (a phone, on every touch device), an action + // sheet of plain actions presents the way iOS 26 presents one: an + // alert-style card (MenuActionSheetCard). Larger screens and a mouse keep + // the bottom sheet. Decided as the menu opens, so it never changes shape + // while open. + const {isTouch, isCompact} = useTouchPresentation(); + const isCompactTouch = isTouch && isCompact; + const [presentsAsCard, setPresentsAsCard] = useState(false); + const [cardDecidedFor, setCardDecidedFor] = useState(false); + if (cardDecidedFor !== isOpen) { + setCardDecidedFor(isOpen); + if (isOpen) { + setPresentsAsCard( + isCompactTouch && + isActionSheetCardEligible(items, { + hasRegions: isRenderable(header) || isRenderable(footer), + }), + ); + } + } const setOpen = useCallback( (nextIsOpen: boolean) => { @@ -833,59 +954,81 @@ function DropdownMenuBottomSheet({ /> )} - -
- {submenuPath.length > 0 ? ( -
-
- ) : ( - viewHeading - )} - {isRenderable(header) && ( -
{header}
- )} - setSubmenuPath(path => [...path, item])} - /> - {isRenderable(footer) && ( -
{footer}
- )} -
-
+ }} + /> + ) : ( + +
+ {submenuPath.length > 0 ? ( +
+
+ ) : ( + viewHeading + )} + {isRenderable(header) && ( +
{header}
+ )} + setSubmenuPath(path => [...path, item])} + /> + {isRenderable(footer) && ( +
{footer}
+ )} +
+
+ )} ); } @@ -902,6 +1045,8 @@ function DropdownMenuPopover({ placement = 'below', alignment = 'start', presentation: _presentation, + touchModes, + pressTouchModel = 'uikit', header, footer, className, @@ -909,7 +1054,18 @@ function DropdownMenuPopover({ xstyle, 'data-testid': testId, ...props -}: DropdownMenuProps) { +}: DropdownMenuProps & { + /** + * Whether the touch modes apply (the resolved presentation): the grow and + * collapse are a finger's on a touch device (`spec:AST-094` FR31). + */ + touchModes?: boolean; + /** + * How a finger's press runs, on every device (`spec:AST-094` FR29, FR43): + * UIMenu's, unless the caller pins `presentation="popover"`. + */ + pressTouchModel?: 'pointer' | 'uikit'; +}) { const t = useTranslator(); const button = buttonFromProps ?? {label: t(DEFAULT_BUTTON_I18N_KEY)}; @@ -963,6 +1119,55 @@ function DropdownMenuPopover({ // above it ends the gesture in flight through this ref. const cancelMenuPressRef = useRef<() => void>(() => {}); + // The touch presentation's open and close (menuSurfaceMotion.ts), created + // for the popover element the first time the menu opens, with the + // `beforetoggle` listener that plays the close. + const surfaceMotionRef = useRef<{ + element: HTMLElement; + motion: MenuSurfaceMotion; + detach: () => void; + } | null>(null); + // Whether the open in progress is under a finger: it then runs UIMenu's + // motion and the pan-outside dismissal. + const isTouchPresentationRef = useRef(false); + const isUikitMotionRef = useRef(false); + const getSurfaceMotion = useCallback( + (element: HTMLElement): MenuSurfaceMotion => { + const current = surfaceMotionRef.current; + if (current?.element === element) { + return current.motion; + } + current?.detach(); + const motion = createMenuSurfaceMotion(element); + const handleBeforeToggle = (event: Event) => { + if ( + (event as ToggleEvent).newState === 'closed' && + isUikitMotionRef.current + ) { + motion.exit(); + } + }; + element.addEventListener('beforetoggle', handleBeforeToggle); + surfaceMotionRef.current = { + element, + motion, + detach: () => { + element.removeEventListener('beforetoggle', handleBeforeToggle); + motion.dispose(); + }, + }; + return motion; + }, + [], + ); + useEffect( + () => () => { + surfaceMotionRef.current?.detach(); + surfaceMotionRef.current = null; + }, + [], + ); + // Submenu flyouts are `popover="manual"` elements nested inside the menu's // `popover="auto"` element, so the browser's light dismiss of the menu never // reaches them: without a sweep they keep their open state and their @@ -1068,6 +1273,38 @@ function DropdownMenuPopover({ const handleLayerShow = useCallback(() => { acceptedOpenRef.current = true; resetFocusReturn(); + // UIKit's motion under a finger, decided as the menu opens: the touch + // modes of the resolved presentation (a pinned 'popover' keeps the + // layer's own entrance). + const surface = contentRef.current; + isTouchPresentationRef.current = touchModes === true; + const isUikitMotion = touchModes === true; + isUikitMotionRef.current = isUikitMotion; + const trigger = buttonRef.current; + if (surface != null && trigger != null) { + if (isUikitMotion) { + getSurfaceMotion(surface).enter(() => { + const triggerBox = trigger.getBoundingClientRect(); + const surfaceBox = surface.getBoundingClientRect(); + return { + surface: { + left: surfaceBox.left, + top: surfaceBox.top, + width: surfaceBox.width, + height: surfaceBox.height, + }, + anchor: { + x: triggerBox.left + triggerBox.width / 2, + y: triggerBox.top + triggerBox.height / 2, + }, + }; + }); + } else if (surfaceMotionRef.current?.element === surface) { + // A mouse runs nothing touch-related (spec:AST-094 FR24): it creates + // no motion, and stops one a finger's open left. + surfaceMotionRef.current.motion.reset(); + } + } if (renderTrigger != null) { setTriggerName(readAccessibleName(buttonRef.current)); } @@ -1079,7 +1316,15 @@ function DropdownMenuPopover({ if (!isControlled) { setInternalIsOpen(true); } - }, [isControlled, onClick, onOpenChange, renderTrigger, resetFocusReturn]); + }, [ + getSurfaceMotion, + isControlled, + onClick, + onOpenChange, + renderTrigger, + resetFocusReturn, + touchModes, + ]); // The menu's name: the dialog around it with a header or footer, and the // Back row of a drilled-in view ("Back to "), both read it. @@ -1113,11 +1358,76 @@ function DropdownMenuPopover({ dialogLabel: hasRegions ? (menuLabel ?? t(DEFAULT_BUTTON_I18N_KEY)) : undefined, + // The box's own pseudo-elements draw it while it grows around a + // sub-menu's card (menuDrillInMotion.ts). + className: stylex.props(styles.boxMotion).className, }); const closeMenu = useCallback(() => { popover.hide(); }, [popover]); + // usePopover returns a new object each render, so closeMenu changes too: + // the pan-outside tracking below reads it through a ref, so a re-render + // between an outside pointerdown and its pointercancel keeps the pointer. + const closeMenuRef = useRef(closeMenu); + closeMenuRef.current = closeMenu; + + // A pan that starts outside the open menu, in its touch presentation, + // dismisses it as the pan begins, and the same touch goes on to scroll the + // page; a tap or a hold outside dismisses at the lift (the browser's light + // dismiss already acts there). UIKit's popovers on iOS 26.2 behave this + // way. The browser announces that it took the touch for a pan with a + // pointercancel, which its light dismiss ignores. + const isPopoverOpen = popover.isOpen; + useEffect(() => { + if (!isPopoverOpen || !isTouchPresentationRef.current) { + return; + } + const doc = contentRef.current?.ownerDocument ?? document; + const outside = new Set(); + const isInsideMenu = (target: EventTarget | null) => { + const node = target instanceof Node ? target : null; + return ( + node != null && + (contentRef.current?.contains(node) === true || + buttonRef.current?.contains(node) === true) + ); + }; + // The press's end listeners are held only while an outside press is live + // (react-component-runtime INV5). + const release = () => { + doc.removeEventListener('pointerup', handleEnd, true); + doc.removeEventListener('pointercancel', handleCancel, true); + }; + const handleEnd = (event: PointerEvent) => { + if (outside.delete(event.pointerId) && outside.size === 0) { + release(); + } + }; + const handleCancel = (event: PointerEvent) => { + if (outside.delete(event.pointerId)) { + if (outside.size === 0) { + release(); + } + closeMenuRef.current(); + } + }; + const handleDown = (event: PointerEvent) => { + if (event.pointerType !== 'mouse' && !isInsideMenu(event.target)) { + if (outside.size === 0) { + doc.addEventListener('pointerup', handleEnd, true); + doc.addEventListener('pointercancel', handleCancel, true); + } + outside.add(event.pointerId); + } + }; + doc.addEventListener('pointerdown', handleDown, true); + return () => { + doc.removeEventListener('pointerdown', handleDown, true); + doc.removeEventListener('pointerup', handleEnd, true); + doc.removeEventListener('pointercancel', handleCancel, true); + }; + }, [isPopoverOpen]); // Single keyboard navigation path for both modes. // The selector matches plain items plus selectable items @@ -1383,12 +1693,16 @@ function DropdownMenuPopover({ // The press model: the row under a release acts, the highlight follows a // held pointer. A mouse released outside the menu dismisses it; a finger // released outside leaves it open, so the hook never calls onDismiss then. - const menuPress = useMenuPress({ + const menuPress = useDropdownMenuPress({ menuRef: listRef, triggerRef: buttonRef, itemSelector: MENU_ITEM_SELECTOR, onTriggerPress: handleTriggerPress, onDismiss: closeMenu, + // Under a finger, on every device, the trigger is UIMenu's pull-down + // button (spec:AST-094 FR29, FR43); `presentation="popover"` pins the + // pointer model, today's included. + touchModel: pressTouchModel, }); cancelMenuPressRef.current = menuPress.cancel; @@ -1497,7 +1811,43 @@ function DropdownMenuPopover({ : styles.popoverMatchTrigger; // The drill-in view stack for sub-menus on a phone. - const {drillIn, wrapContent} = useMenuDrillIn(isOpen); + // UIMenu's in-place sub-menu: the box grows to the drilled-in view, the + // parent stays behind as a card, and back reverses it (touch only; the + // drill-in itself happens only on a compact touch display). + const [drillInMotion] = useState(() => + createMenuDrillInMotion(() => ({ + surface: contentRef.current, + list: listRef.current, + })), + ); + useEffect(() => () => drillInMotion.dispose(), [drillInMotion]); + // Only a touch presentation plays it; a drill-in under a mouse (an explicit + // `presentation="drill-in"`) swaps the view as before. + const drillInTransition = useMemo( + () => ({ + capture: ( + kind: 'push' | 'pop', + row: HTMLElement | null, + atRoot: boolean, + ) => { + if (isTouchPresentationRef.current) { + drillInMotion.capture(kind, row, atRoot); + } + }, + play: (done?: () => void) => drillInMotion.play(done), + reset: () => drillInMotion.reset(), + }), + [drillInMotion], + ); + // Under a finger a sub-menu opens as UIMenu's does, a card over the + // parent's rows (useMenuDrillIn); a mouse's drill-in (an explicit + // `presentation="drill-in"`) replaces the rows as before (spec:AST-094: a + // mouse keeps today's behavior). + const {drillIn, isCardShown, isCardLeaving, wrapContent} = useMenuDrillIn( + isOpen, + drillInTransition, + {isCard: touchModes === true}, + ); // The surface host is owned through a ref. State keeps only a key that // changes whenever a host element attaches, the host included when it // moves (a header or footer coming or going), so sub-menus re-render into @@ -1709,6 +2059,8 @@ function DropdownMenuPopover({ : menuTouchActionStyles.fits, hasOverflow && styles.scrollable, hasRegions && styles.menuInFrame, + isCardShown && styles.cardStack, + isCardLeaving && styles.cardLeaving, // Between a header and a footer the menu is a Tab stop, so // keyboard focus on it must be visible (the base style removes // the ring for the pointer-open focus target). @@ -1757,9 +2109,12 @@ export function DropdownMenu(props: DropdownMenuProps) { 'control. `trigger` wins; drop `button`.', props.button != null && props.renderTrigger != null, ); - const requestedPresentation = - 'items' in props ? (props.presentation ?? 'popover') : 'popover'; - const resolvedPresentation = useAdaptivePresentation(requestedPresentation); + // The touch modes are the default on a touch device (spec:AST-094); + // `presentation="popover"` pins the fine-pointer surface and modes. + const {surface: resolvedPresentation, touchModes} = usePresentation( + props.presentation, + {whenOmitted: 'touch'}, + ); const isControlled = props.isMenuOpen !== undefined; const [internalIsOpen, setInternalIsOpen] = useState(false); const isOpen = isControlled ? props.isMenuOpen : internalIsOpen; @@ -1791,7 +2146,14 @@ export function DropdownMenu(props: DropdownMenuProps) { ); } - return ; + return ( + + ); } DropdownMenu.displayName = 'DropdownMenu'; diff --git a/packages/core/src/DropdownMenu/DropdownMenuContext.tsx b/packages/core/src/DropdownMenu/DropdownMenuContext.tsx index ca3bd3bba3b57..48a467673dacc 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuContext.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuContext.tsx @@ -43,15 +43,43 @@ export interface DropdownMenuDrillIn { isCompactTouch: boolean; /** The pushed view ids, outermost first; the last one is the view shown. */ viewStack: ReadonlyArray; + /** + * UIMenu's in-place sub-menu: a shown view is a card laid over the parent's + * rows from the row it opened from, and the parent's rows stay, receded, + * under it (inert and hidden from assistive technology). Otherwise a shown + * view replaces the parent's rows. + */ + isCard: boolean; /** The element a shown view renders its rows into (a portal target). */ host: HTMLElement | null; + /** + * The view going back while its card plays out (UIMenu's back): it stays + * rendered in `host`, inert and hidden from assistive technology, until + * the motion settles, so nothing copies it. Null otherwise. + */ + leaving: string | null; /** * Show a sub-menu's view in place of the current rows. A view with a header * or footer renders into the root's surface host instead of `host`. */ - push: (viewId: string) => void; - /** Return to the rows the shown view replaced. */ - pop: () => void; + push: ( + viewId: string, + row?: HTMLElement | null, + options?: { + /** + * Whether the view shows as a card over the rows (`isCard` menus); a + * view with a header or footer renders beside the menu instead, and the + * rows step aside for it. @default true + */ + isCard?: boolean; + }, + ) => void; + /** + * Return to the rows the shown view replaced. `row` (on push, the row the + * view opens from; on pop, the row it returns to) lets the menu animate the + * change from that row. + */ + pop: (row?: HTMLElement | null) => void; } export interface DropdownMenuContextValue { diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs index b74358faba509..10e74e0a3c3af 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs @@ -8,7 +8,7 @@ export const docs = { displayName: 'Dropdown Menu Submenu', isHiddenFromOverview: true, description: - 'A single menu row that reveals a nested flyout of its own items. The row adopts DropdownMenuItem semantics (label / icon / description / isDisabled); its children become the flyout content. Opens inline-end with viewport auto-flip; Right/Enter/Space opens and focuses the first item, Left/Escape closes and returns focus to the trigger (Right/Left swap in RTL). On a phone (a coarse pointer, decided when the menu opened) the row drills in instead: its rows replace the menu\'s rows in the same box, led by a Back row named "Back to ", and Back, Escape or ArrowLeft return to the row. Host attributes and DOM event handlers (aria-*, data-*, id) reach the trigger row, the element carrying role="menuitem" and aria-haspopup="menu"; a caller\'s handlers run after the row\'s own, except for the keys that open the flyout (the open arrow, Enter, Space) and a click on a disabled row. For data-driven menus, give a menu item a nested `items` array instead of using this component directly.', + 'A single menu row that reveals a nested flyout of its own items. The row adopts DropdownMenuItem semantics (label / icon / description / isDisabled); its children become the flyout content. Opens inline-end with viewport auto-flip; Right/Enter/Space opens and focuses the first item, Left/Escape closes and returns focus to the trigger (Right/Left swap in RTL). On a phone (a coarse pointer, decided when the menu opened) the row opens its sub-menu in place instead: its rows open as a card in the same box, over the menu\'s rows, led by a header that shows the row\'s label with its chevron turned down and is named by that label and then "Back to ", and the header, Escape or ArrowLeft return to the row; a sub-menu with its own header or footer replaces the menu\'s rows, led by a "Back to " row; a drill-in under a mouse (`presentation="drill-in"`) replaces the rows as before, led by a "Back to " row. As on iOS, the menu\'s box grows to the sub-menu from the edge it is anchored by while the parent stays behind as a slightly smaller card, and going back reverses it. Host attributes and DOM event handlers (aria-*, data-*, id) reach the trigger row, the element carrying role="menuitem" and aria-haspopup="menu"; a caller\'s handlers run after the row\'s own, except for the keys that open the flyout (the open arrow, Enter, Space) and a click on a disabled row. For data-driven menus, give a menu item a nested `items` array instead of using this component directly.', playground: { defaults: {label: 'Move to'}, }, @@ -66,7 +66,7 @@ export const docs = { name: 'presentation', type: "'flyout' | 'drill-in' | 'adaptive'", description: - "How the sub-menu shows its rows. 'flyout' opens them beside the row; 'drill-in' replaces the menu's rows with them and a Back row, in the same box; 'adaptive' drills in when a finger opened the menu (a coarse pointer, decided when the menu opened) and flies out otherwise.", + "How the sub-menu shows its rows. 'flyout' opens them beside the row; 'drill-in' shows them in the same box: on a phone as a card over the menu's rows, under a header named by the row's label and then \"Back to \", and otherwise (a mouse, or a sub-menu with its own header or footer) in place of the menu's rows, under a \"Back to \" row; 'adaptive' drills in when a finger opened the menu (a coarse pointer, decided when the menu opened) and flies out otherwise.", default: "'adaptive'", }, { @@ -119,7 +119,7 @@ export const docsDense = { 'minimum flyout width, capped to the viewport (default: content, min 160px)', onOpenChange: 'called when flyout opens/closes', presentation: - "'flyout' beside the row | 'drill-in' replaces the rows + Back row | 'adaptive' (default) drills in on a coarse pointer", + "'flyout' beside the row | 'drill-in' same box (a card over the rows on a phone; rows replaced + Back row otherwise) | 'adaptive' (default) drills in on a coarse pointer", header: 'content above the rows, outside the menu; surface becomes a modal dialog named by the row (Tab wraps, Escape returns to the row)', footer: 'content below the rows, outside the menu; same rules as header', diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx index 271fad83ff354..056e7929de7f5 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx @@ -26,6 +26,7 @@ import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet'; import {IconButton} from '../IconButton'; import {RadioList, RadioListItem} from '../RadioList'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +import {TOUCH_QUERY} from '../utils/touchQueries'; import {Theme, defineTheme} from '../theme'; import {rtlStyles} from '../utils'; @@ -1114,6 +1115,66 @@ describe('DropdownMenuSubMenu hover/click guard', () => { }); describe('DropdownMenuSubMenu press model', () => { + const finger = (pointerId = 1) => ({pointerType: 'touch', pointerId}); + const wait = async (ms: number) => { + await act(async () => { + await new Promise(resolve => { + setTimeout(resolve, ms); + }); + }); + }; + + it('a finger resting on a sub-menu row opens nothing; its lift there opens it (module FR10)', async () => { + const user = userEvent.setup(); + render(); + const root = screen.getByRole('button', {name: /Actions/}); + await user.click(root); + const trigger = screen.getByRole('menuitem', { + name: /Move to/, + hidden: true, + }); + const rename = screen.getByRole('menuitem', {name: 'Rename', hidden: true}); + fireEvent.pointerDown(rename, finger()); + fireEvent.pointerMove(trigger, finger()); + expect(trigger).toHaveFocus(); + // Well past a hover's open delay: a finger still choosing opens nothing. + await wait(600); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.pointerUp(trigger, finger()); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('a finger that slides from a row onto an open flyout\u2019s rows picks there (module FR10, ORD1)', async () => { + const onMove = vi.fn(); + const user = userEvent.setup(); + render(); + const root = screen.getByRole('button', {name: /Actions/}); + await user.click(root); + const trigger = screen.getByRole('menuitem', { + name: /Move to/, + hidden: true, + }); + fireEvent.pointerDown(trigger, finger()); + fireEvent.pointerUp(trigger, finger()); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // A new finger lands on another row of the menu, then slides, over real + // time, onto a row of the open flyout. + const rename = screen.getByRole('menuitem', {name: 'Rename', hidden: true}); + fireEvent.pointerDown(rename, finger(2)); + await wait(120); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + const folderB = screen.getByRole('menuitem', { + name: 'Folder B', + hidden: true, + }); + fireEvent.pointerMove(folderB, finger(2)); + await wait(50); + fireEvent.pointerUp(folderB, finger(2)); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove).toHaveBeenCalledWith('b'); + expect(root).toHaveAttribute('aria-expanded', 'false'); + }); + it('a press released on the sub-menu row opens it and keeps the menu', async () => { const user = userEvent.setup(); render(); @@ -1273,7 +1334,10 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { vi.stubGlobal( 'matchMedia', vi.fn().mockImplementation((query: string) => ({ - matches: matches && query === COMPACT_TOUCH_PRESENTATION_QUERY, + // A phone: a compact display and a coarse pointer (the touch modes). + matches: + matches && + (query === COMPACT_TOUCH_PRESENTATION_QUERY || query === TOUCH_QUERY), media: query, onchange: null, addEventListener: vi.fn(), @@ -1305,15 +1369,19 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { } // The popover is not open for jsdom's accessibility tree, so every query - // passes `hidden`; the rows a drilled-in view replaced sit under a `hidden` - // wrapper, which is what "shown" means here. + // passes `hidden`. The rows a drilled-in view lies over stay, receded, + // under an inert wrapper hidden from assistive technology (a view that + // replaces them hides them): "shown" means exposed. const h = {hidden: true} as const; - const isShown = (el: HTMLElement) => el.closest('[hidden]') == null; + const isShown = (el: HTMLElement) => + el.closest('[hidden], [inert], [aria-hidden="true"]') == null; + // Each shown row by its accessible name: a drilled-in view's header shows + // its row's label but is named for where it goes ("Back to …"). const visibleRows = () => screen .getAllByRole('menuitem', h) .filter(isShown) - .map(el => el.textContent); + .map(el => el.getAttribute('aria-label') ?? el.textContent); const shownRow = (name: string) => screen.getAllByRole('menuitem', {name, ...h}).find(isShown)!; const shownMenu = (name: string) => @@ -1340,7 +1408,24 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { // drilled list is named by its row and leads with a Back row. const drilled = await waitFor(() => shownMenu('Move to')); expect(rootMenu).toContainElement(drilled); - expect(visibleRows()).toEqual(['Back to Actions', 'Folder A', 'Archive']); + expect(visibleRows()).toEqual([ + 'Move to, Back to Actions', + 'Folder A', + 'Archive', + ]); + // UIMenu's header: the row's own label, its chevron turned down. + expect(shownRow('Move to, Back to Actions').textContent).toBe('Move to'); + // Its name starts with the text it shows, then says where it goes (WCAG + // 2.2 SC 2.5.3). + const header = shownRow('Move to, Back to Actions'); + expect( + header.getAttribute('aria-label')?.startsWith(header.textContent ?? '-'), + ).toBe(true); + expect( + shownRow('Move to, Back to Actions').hasAttribute( + 'data-astryx-menu-submenu-header', + ), + ).toBe(true); // The sibling row is still mounted (its state stays live) but hidden. expect(screen.getByRole('menuitem', {name: 'Rename', ...h})).not.toBe( undefined, @@ -1352,7 +1437,7 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { expect(drilled.closest('[popover]')).toBe(rootMenu.closest('[popover]')); // Back returns to the sub-menu row. - fireEvent.click(shownRow('Back to Actions'), { + fireEvent.click(shownRow('Move to, Back to Actions'), { detail: 1, }); await waitFor(() => expect(shownRow('Move to')).toHaveFocus()); @@ -1369,12 +1454,16 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { key: 'Enter', }); await waitFor(() => shownMenu('Archive')); - expect(visibleRows()).toEqual(['Back to Move to', '2025']); + expect(visibleRows()).toEqual(['Archive, Back to Move to', '2025']); expect(shownRow('2025')).toHaveFocus(); fireEvent.keyDown(document.activeElement!, {key: 'ArrowLeft'}); await waitFor(() => expect(shownRow('Archive')).toHaveFocus()); - expect(visibleRows()).toEqual(['Back to Actions', 'Folder A', 'Archive']); + expect(visibleRows()).toEqual([ + 'Move to, Back to Actions', + 'Folder A', + 'Archive', + ]); fireEvent.keyDown(document.activeElement!, {key: 'Escape'}); await waitFor(() => expect(shownRow('Move to')).toHaveFocus()); // One Escape popped one level; the menu is still open. @@ -1411,11 +1500,11 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { // Arrows, Home and End walk the drilled list only, Back included; the // hidden parent rows are not in the order. fireEvent.keyDown(folderA, {key: 'ArrowUp'}); - expect(shownRow('Back to Actions')).toHaveFocus(); + expect(shownRow('Move to, Back to Actions')).toHaveFocus(); fireEvent.keyDown(document.activeElement!, {key: 'End'}); expect(shownRow('Archive')).toHaveFocus(); fireEvent.keyDown(document.activeElement!, {key: 'Home'}); - expect(shownRow('Back to Actions')).toHaveFocus(); + expect(shownRow('Move to, Back to Actions')).toHaveFocus(); expect(drilled).toBeInTheDocument(); }); @@ -1454,6 +1543,12 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { detail: 1, }); await waitFor(() => shownRow('Back to Actions')); + // A mouse's drill-in replaces the rows as before: a Back row that shows + // where it goes, and no card over the parent's rows (spec:AST-094: a + // mouse keeps today's behavior). + expect(shownRow('Back to Actions').textContent).toBe('Back to Actions'); + expect(document.querySelector('[data-astryx-menu-card]')).toBeNull(); + expect(document.querySelector('[data-astryx-menu-parent]')).toBeNull(); }); it("follows the theme's md point: a 900 px touch screen drills in under md 1000, and keeps the flyout under the default (spec:AST-012 FR9)", async () => { @@ -1509,7 +1604,7 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { render({menu}); await user.click(screen.getByRole('button', {name: /Actions/})); fireEvent.click(await waitFor(() => shownRow('Move to')), {detail: 1}); - await waitFor(() => shownRow('Back to Actions')); + await waitFor(() => shownRow('Move to, Back to Actions')); }); it('closing the menu resets the drilled view', async () => { @@ -2320,7 +2415,7 @@ describe('DropdownMenuSubMenu header and footer', () => { labelsMenu().querySelectorAll('[role="menuitem"]'), ) .filter(el => isShown(el as HTMLElement)) - .map(el => el.textContent); + .map(el => el.getAttribute('aria-label') ?? el.textContent); expect(viewRows[0]).toBe('Back to Session'); for (const part of [field(), labelsMenu(), create()]) { expect(dialog).toContainElement(part); diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx index ba205437fc827..bb814f2509efa 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx @@ -34,10 +34,14 @@ * * Drill-in (the phone presentation): the root menu keeps a view stack * (useMenuDrillIn) and this row pushes its view onto it; the root shows the - * view — a Back row named "Back to ", then these rows — in place of - * its own, portalled into the root's host so the row stays mounted and its - * rows stay live. Back, Escape and ArrowLeft pop the view and return focus to - * the row. The list keeps this level's own roving focus, typeahead and press + * view, portalled into the root's host so the row stays mounted and its rows + * stay live. As a card (the touch modes, a view without regions) it leads with + * UIMenu's sub-menu header: this row's label with its chevron turned down, + * named by that label and then "Back to " (WCAG 2.5.3). Otherwise the + * view replaces the root's rows and leads with a Back row that shows and is + * named "Back to ". Back, Escape and ArrowLeft pop the view and + * return focus to the row. Under a finger the root animates each push and + * pop as UIMenu does (menuDrillInMotion.ts). The list keeps this level's own roving focus, typeahead and press * model, so nothing beside the row needs to know. * * SYNC: When modified, update these files to stay in sync: @@ -74,7 +78,7 @@ import {useMenuHover} from '../hooks/useMenuHover'; import { isMenuPressActivation, MENU_PRESS_SURFACE_ATTR, - useMenuPress, + useMenuSurfacePress, } from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {useMenuOverflow} from './useMenuOverflow'; @@ -87,6 +91,7 @@ import { durationVars, easeVars, shadowVars, + fontWeightVars, typographyVars, typeScaleVars, } from '../theme/tokens.stylex'; @@ -243,6 +248,19 @@ const flyoutStyles = stylex.create({ }), }); +// A drilled-in view's header turns the row's chevron to point down and sets +// its title in semibold, as UIMenu's sub-menu header does. +const headerStyles = stylex.create({ + chevronDown: { + rotate: '90deg', + }, + // Its title in semibold: a style the row's label cannot take from the + // row (architecture:component-style-authoring INV5). + title: { + fontWeight: fontWeightVars['--font-weight-semibold'], + }, +}); + // The drilled-in list shows in place of the parent rows, inside the parent's // own box: no surface, shadow or cap of its own. const drillInStyles = stylex.create({ @@ -257,6 +275,14 @@ const drillInStyles = stylex.create({ hidden: { display: 'none', }, + // UIMenu's in-place sub-menu: the view is a card over the parent's rows, + // painted as the menu's own box is. + card: { + backgroundColor: colorVars['--color-background-popover'], + borderRadius: + 'calc(var(--_dropdown-menu-radius) - var(--_dropdown-menu-padding))', + boxShadow: shadowVars['--shadow-low'], + }, }); // With a header or footer the sub-menu's surface is a frame holding the @@ -545,9 +571,15 @@ export function DropdownMenuSubMenu( ? drillIn : null; const isDrillIn = activeDrillIn != null; + // A view shows as a card over the parent's rows only in a card menu (the + // touch modes) and without regions of its own (useMenuDrillIn). + const isCardView = activeDrillIn?.isCard === true && !hasRegions; const viewId = useId(); const isOnStack = activeDrillIn?.viewStack.includes(viewId) === true; const isTopView = isOnStack && activeDrillIn?.viewStack.at(-1) === viewId; + // Going back as a card: still rendered while its motion plays out, inert + // and hidden from assistive technology. + const isLeaving = !isOnStack && activeDrillIn?.leaving === viewId; const stringLabel = typeof label === 'string' ? label : undefined; const parentLabel = menuCtx?.menuLabel; const backLabel = @@ -717,7 +749,8 @@ export function DropdownMenuSubMenu( pendingViewFocusRef.current = options?.focusFirst ? 'first' : 'container'; - activeDrillIn.push(viewId); + // A view with a header or footer renders beside the menu: no card. + activeDrillIn.push(viewId, triggerRef.current, {isCard: !hasRegions}); onOpenChange?.(true); return; } @@ -731,7 +764,16 @@ export function DropdownMenuSubMenu( } } }, - [canOpen, activeDrillIn, viewId, onOpenChange, layer, focusFirst, menuRef], + [ + canOpen, + activeDrillIn, + viewId, + hasRegions, + onOpenChange, + layer, + focusFirst, + menuRef, + ], ); const close = useCallback( @@ -739,7 +781,7 @@ export function DropdownMenuSubMenu( if (activeDrillIn != null) { if (isOnStack) { pendingTriggerFocusRef.current = options?.focusTrigger !== false; - activeDrillIn.pop(); + activeDrillIn.pop(triggerRef.current); onOpenChange?.(false); } return; @@ -1034,7 +1076,7 @@ export function DropdownMenuSubMenu( // The press model for this flyout: the row under a release acts, the // highlight follows a held pointer. A mouse released outside every level // dismisses the whole menu; a finger leaves it open. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: nestedMenuContext.closeMenu, @@ -1070,7 +1112,8 @@ export function DropdownMenuSubMenu( const nestedRows = ( - {isDrillIn && ( + {isDrillIn && !isCardView && ( + // A view in place of the rows leads with its Back row. close({focusTrigger: true})} /> )} + {isCardView && ( + // UIMenu's sub-menu header, on a card: the row's own label with its + // chevron turned to point down, which takes the sub-menu back. Its + // name starts with the text it shows (WCAG 2.5.3), then says where + // it goes. + + {stringLabel ?? backLabel} + + } + aria-label={ + stringLabel != null + ? t('@astryx.dropdownMenu.subMenuHeader', { + label: stringLabel, + back: backLabel, + }) + : backLabel + } + icon={icon} + endContent={ + // Down is down in either direction: no mirroring here. + + + + } + data-astryx-menu-submenu-header="" + hasCloseOnSelect={false} + onClick={() => close({focusTrigger: true})} + /> + )} {children} @@ -1241,7 +1325,7 @@ export function DropdownMenuSubMenu( })} /> {activeDrillIn != null - ? isOnStack && + ? (isOnStack || (isLeaving && !hasRegions)) && activeDrillIn.host != null && (hasRegions ? // A view with a header or footer renders beside the root's menu, @@ -1295,14 +1379,16 @@ export function DropdownMenuSubMenu( aria-label={stringLabel} aria-labelledby={stringLabel == null ? triggerId : undefined} // A deeper view shows in its place; this one waits, hidden. - hidden={!isTopView} + hidden={!isTopView && !isLeaving} inert={!isTopView} + aria-hidden={isLeaving || undefined} onKeyDown={handleContentKeyDown} data-testid={menuDataTestId} {...menuPress.menuProps} {...stylex.props( drillInStyles.list, - !isTopView && drillInStyles.hidden, + activeDrillIn.isCard && drillInStyles.card, + !isTopView && !isLeaving && drillInStyles.hidden, )}> {nestedContent} , diff --git a/packages/core/src/DropdownMenu/DropdownMenuTouch.bundle.test.ts b/packages/core/src/DropdownMenu/DropdownMenuTouch.bundle.test.ts new file mode 100644 index 0000000000000..d5a226a109f9a --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuTouch.bundle.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file DropdownMenuTouch.bundle.test.ts + * @input DropdownMenuTouch.ts, bundled with esbuild in a child process + * @output Fails when DropdownMenu's touch layer, its grow or its in-place + * sub-menu, carries a spring row it does not play + * (`architecture:component-style-authoring` INV6) + */ + +import {execFileSync} from 'node:child_process'; +import {describe, expect, it} from 'vitest'; + +/** The minified bundle of `entry`, an ES module next to this file. */ +function bundle(entry: string): string { + // esbuild runs in its own Node process: jsdom's TextEncoder fails its + // startup check. + return execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + [ + "import {build} from 'esbuild';", + 'const result = await build({', + ` stdin: {contents: ${JSON.stringify(entry)}, resolveDir: process.cwd(), loader: 'ts'},`, + " bundle: true, write: false, format: 'esm', minify: true, logLevel: 'silent',", + " packages: 'external',", + '});', + 'process.stdout.write(result.outputFiles[0].text);', + ].join('\n'), + ], + {cwd: __dirname, encoding: 'utf8'}, + ); +} + +// Numbers only one spring row carries, as a minified bundle writes them. +const OTHER_ROWS = { + keyboard: '555.0265', + alertPresent: '522.35', + sheetDetent: '.3441442326', + fingerTracking: '.86', + directChange: '.825', +}; + +describe('DropdownMenu touch layer', () => { + it('carries UIKit\u2019s two menu springs and no other spring row', () => { + const touch = bundle( + "import {createMenuSurfaceMotion} from './DropdownMenuTouch'; export const create = createMenuSurfaceMotion;", + ); + // The two roles it registers and plays (spec:AST-094 FR4, FR31). + expect(touch).toContain('menuOpen'); + expect(touch).toContain('menuClose'); + for (const [row, value] of Object.entries(OTHER_ROWS)) { + expect(touch, row).not.toContain(value); + } + }); + + it('the in-place sub-menu carries UIKit\u2019s two sub-menu springs and no other spring row', () => { + const drillIn = bundle( + "import {createMenuDrillInMotion} from './DropdownMenuTouch'; export const create = createMenuDrillInMotion;", + ); + // The two roles it registers and plays (spec:AST-094 FR4). + expect(drillIn).toContain('subMenuOpen'); + expect(drillIn).toContain('subMenuBack'); + for (const [row, value] of Object.entries(OTHER_ROWS)) { + expect(drillIn, row).not.toContain(value); + } + }); +}); diff --git a/packages/core/src/DropdownMenu/DropdownMenuTouch.ts b/packages/core/src/DropdownMenu/DropdownMenuTouch.ts new file mode 100644 index 0000000000000..55132b6863a70 --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuTouch.ts @@ -0,0 +1,68 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file DropdownMenuTouch.ts + * @input menuSurfaceMotion.ts (UIMenu's grow and collapse on the animator), + * menuDrillInMotion.ts (UIMenu's in-place sub-menu), MenuActionSheetCard.tsx + * (iOS 26's alert-style action sheet), and the register functions of the + * menu springs and the sub-menu springs + * @output DropdownMenu's touch layer: createMenuSurfaceMotion and its type, + * createMenuDrillInMotion, MenuActionSheetCard and isActionSheetCardEligible + * @position The one module DropdownMenu imports touch-only code through, and + * its only import site is DropdownMenu.tsx. The mouse paths stay outside + * it, so a desktop build can swap it for a no-op (AST-080 FR8). + * + * UIKit's two menu springs register here as their roles before the grow's + * first animation, and its two sub-menu springs before the in-place + * sub-menu's; the action-sheet card plays the alert's two springs through + * useAlertMotion. Nothing registers on import, and no other spring ships with + * it. + * + * SYNC: When modified, keep every touch-only import of DropdownMenu.tsx here. + */ + +import { + registerMenuSprings, + registerSubMenuSprings, +} from '../motion/uikit/springs'; +import {createMenuDrillInMotion as createDrillInMotion} from './menuDrillInMotion'; +import { + createMenuSurfaceMotion as createSurfaceMotion, + type MenuSurfaceMotion, +} from './menuSurfaceMotion'; + +export type {MenuSurfaceMotion}; + +/** + * UIMenu's grow and collapse for a menu surface. Registers UIKit's two menu + * springs first, the internal motion roles the motion reads + * (`springs.menuOpen`, `springs.menuClose`, `spec:AST-094` FR4); + * registering again is a no-op. The menu table's values are constants, read + * directly. + */ +export function createMenuSurfaceMotion( + ...args: Parameters +): MenuSurfaceMotion { + registerMenuSprings(); + return createSurfaceMotion(...args); +} + +/** + * The in-place sub-menu's motion (menuDrillInMotion.ts). Registers UIKit's + * two sub-menu springs first, the internal motion roles it reads + * (`springs.subMenuOpen`, `springs.subMenuBack`, `spec:AST-094` FR4); + * registering again is a no-op. The menu table's values are constants, read + * directly. + */ +export function createMenuDrillInMotion( + ...args: Parameters +): ReturnType { + registerSubMenuSprings(); + return createDrillInMotion(...args); +} +export { + MenuActionSheetCard, + isActionSheetCardEligible, +} from './MenuActionSheetCard'; diff --git a/packages/core/src/DropdownMenu/DropdownMenuTouchMotionScope.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuTouchMotionScope.test.tsx new file mode 100644 index 0000000000000..0c58a854597fc --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuTouchMotionScope.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +/** + * @file DropdownMenuTouchMotionScope.test.tsx + * @input DropdownMenu, its touch surface motion (menuSurfaceMotion.ts) under + * a counting stand-in, and stubbed pointer media queries + * @output Proof that the touch surface motion exists only for a touch + * presentation: a mouse open creates none (spec:AST-094 FR24), a finger's + * open creates one + * @position Sibling of DropdownMenu.test.tsx; its own file because the + * motion module is replaced here for every case + */ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {DropdownMenu} from './DropdownMenu'; +import {DropdownMenuItem} from './DropdownMenuItem'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; +import {__resetInteractionModalityForTest} from '../utils/interactionModality'; + +const motions = vi.hoisted(() => ({created: 0})); + +vi.mock('./menuSurfaceMotion', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createMenuSurfaceMotion: ( + ...args: Parameters + ) => { + motions.created += 1; + return actual.createMenuSurfaceMotion(...args); + }, + }; +}); + +function stubPointer(coarse: boolean) { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: coarse && query === TOUCH_QUERY, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +const originalShow = HTMLElement.prototype.showPopover; +const originalHide = HTMLElement.prototype.hidePopover; +const originalAnimate = HTMLElement.prototype.animate; + +beforeEach(() => { + __resetInteractionModalityForTest(); + motions.created = 0; + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute('popover-open', ''); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'open'}); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute('popover-open'); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'closed'}); + this.dispatchEvent(event); + }); + // jsdom runs no animations. + HTMLElement.prototype.animate = vi.fn(function () { + return { + cancel: vi.fn(), + finished: new Promise(() => {}), + startTime: null, + } as unknown as Animation; + }); +}); + +afterEach(() => { + HTMLElement.prototype.showPopover = originalShow; + HTMLElement.prototype.hidePopover = originalHide; + HTMLElement.prototype.animate = originalAnimate; + vi.unstubAllGlobals(); +}); + +function renderMenu() { + render( + + {}} /> + {}} /> + , + ); + return screen.getByRole('button', {name: /Actions/}); +} + +describe('DropdownMenu touch surface motion', () => { + it('a mouse open creates no touch motion (spec:AST-094 FR24)', async () => { + stubPointer(false); + const trigger = renderMenu(); + await userEvent.setup().click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + expect(motions.created).toBe(0); + }); + + it('a finger’s open creates it', async () => { + stubPointer(true); + const trigger = renderMenu(); + await userEvent.setup().click(trigger); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'true'), + ); + expect(motions.created).toBe(1); + }); +}); diff --git a/packages/core/src/DropdownMenu/MenuActionSheetCard.test.tsx b/packages/core/src/DropdownMenu/MenuActionSheetCard.test.tsx new file mode 100644 index 0000000000000..b872968208698 --- /dev/null +++ b/packages/core/src/DropdownMenu/MenuActionSheetCard.test.tsx @@ -0,0 +1,351 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file MenuActionSheetCard.test.tsx + * @input vitest, Testing Library, DropdownMenu with presentation="bottom-sheet" + * on a stubbed compact touch screen, isActionSheetCardEligible + * @output The action sheet's iOS 26 presentation on a phone: a centered + * alert-style card (a dialog named by the menu) with the actions and Cancel + * in one stack; an action runs and closes; Cancel and Escape close; a menu + * with sub-menus, links or regions keeps the bottom sheet; a mouse screen + * keeps the bottom sheet + * @position jsdom tests of MenuActionSheetCard.tsx and its DropdownMenu + * wiring; the motion and the finger tracking run in + * __tests__/DropdownMenuActionSheet.interaction.browser.spec.ts + */ + +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import {render, screen, waitFor, fireEvent} from '@testing-library/react'; +import {useState} from 'react'; +import userEvent from '@testing-library/user-event'; +import {COMPACT_QUERY} from '../utils/touchPresentation'; +import {DropdownMenu, type DropdownMenuOption} from './DropdownMenu'; +import {isActionSheetCardEligible} from './MenuActionSheetCard'; +import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet'; + +// The bottom sheet loads lazily: resolve it once, before any screen stub. +// Warmed on a compact touch screen, a plain menu would present the card and +// leave the sheet cold for the cases that keep it. +beforeAll(async () => { + await warmMenuBottomSheet(); +}, 20000); + +/** + * The screen: compact and under a finger, or not. The engine (WebKit on iOS, + * Chrome on Android) does not matter: one touch model. + */ +function stubScreen({compactTouch}: {compactTouch: boolean}) { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: + compactTouch && + (query === '(pointer: coarse)' || query === COMPACT_QUERY), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +beforeEach(() => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute('open', ''); + }); + HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) { + this.removeAttribute('open'); + this.dispatchEvent(new Event('close')); + }); + // jsdom runs no animations: the alert motion's exit must still end. + HTMLElement.prototype.animate = vi.fn(function () { + return { + cancel: vi.fn(), + finished: Promise.resolve(), + startTime: null, + currentTime: null, + playState: 'finished', + } as unknown as Animation; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); +}); + +const ACTIONS: DropdownMenuOption[] = [ + {label: 'Rename'}, + {label: 'Duplicate'}, + {type: 'divider'}, + {label: 'Delete', variant: 'destructive'}, +]; + +function renderActionSheet(items: DropdownMenuOption[] = ACTIONS) { + render( + , + ); + return screen.getByRole('button', {name: /Project actions/}); +} + +describe('isActionSheetCardEligible', () => { + it('takes plain actions, sections and dividers', () => { + expect( + isActionSheetCardEligible( + [ + {label: 'A'}, + {type: 'divider'}, + {type: 'section', title: 'More', items: [{label: 'B'}]}, + ], + {hasRegions: false}, + ), + ).toBe(true); + }); + + it('keeps the bottom sheet for sub-menus, links, rich labels, regions and an empty menu', () => { + expect( + isActionSheetCardEligible([{label: 'A', items: [{label: 'B'}]}], { + hasRegions: false, + }), + ).toBe(false); + expect( + isActionSheetCardEligible([{label: 'Docs', href: '/docs'}], { + hasRegions: false, + }), + ).toBe(false); + expect( + isActionSheetCardEligible([{label: Bold}], {hasRegions: false}), + ).toBe(false); + expect(isActionSheetCardEligible([{label: 'A'}], {hasRegions: true})).toBe( + false, + ); + expect(isActionSheetCardEligible([], {hasRegions: false})).toBe(false); + }); + + it('keeps the bottom sheet for a row with a description, end content, or that stays open when chosen', () => { + // The card's buttons show a label and an icon only. + expect( + isActionSheetCardEligible([{label: 'A', description: 'More'}], { + hasRegions: false, + }), + ).toBe(false); + expect( + isActionSheetCardEligible([{label: 'A', endContent: ⌘A}], { + hasRegions: false, + }), + ).toBe(false); + expect( + isActionSheetCardEligible([{label: 'A', hasCloseOnSelect: false}], { + hasRegions: false, + }), + ).toBe(false); + }); +}); + +describe('DropdownMenu action sheet on a phone, under a finger (iOS 26: an alert-style card)', () => { + beforeEach(() => { + stubScreen({compactTouch: true}); + }); + + it('presents a card named by the menu, with the actions and Cancel in one stack', async () => { + const user = userEvent.setup(); + const trigger = renderActionSheet(); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + const buttons = Array.from(card.querySelectorAll('button')).map( + button => button.textContent, + ); + expect(buttons).toEqual(['Rename', 'Duplicate', 'Delete', 'Cancel']); + // Every action, Cancel included, is tracked as one group. + expect( + card.querySelectorAll('[data-astryx-action-sheet-action]'), + ).toHaveLength(4); + // Not the bottom sheet. + expect(document.querySelector('.astryx-bottom-sheet')).toBeNull(); + }); + + it("each action carries the action-group item marker while the card tracks, so the document's press feedback leaves its press to the group's tracker (family:action-groups)", async () => { + const user = userEvent.setup(); + const trigger = renderActionSheet(); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + const actions = Array.from( + card.querySelectorAll('[data-astryx-action-sheet-action]'), + ); + expect(actions).toHaveLength(4); + for (const action of actions) { + expect(action).toHaveAttribute('data-astryx-press-group-item', ''); + } + }); + + it('runs the chosen action and closes', async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + const trigger = renderActionSheet([ + {label: 'Rename'}, + {label: 'Delete', variant: 'destructive', onClick: onDelete}, + ]); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + fireEvent.click( + Array.from(card.querySelectorAll('button')).find( + button => button.textContent === 'Delete', + )!, + ); + expect(onDelete).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'false'), + ); + }); + + it('a disabled action does not run', async () => { + const onArchive = vi.fn(); + const user = userEvent.setup(); + const trigger = renderActionSheet([ + {label: 'Archive', isDisabled: true, onClick: onArchive}, + {label: 'Rename'}, + ]); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + const archive = Array.from(card.querySelectorAll('button')).find( + button => button.textContent === 'Archive', + )!; + expect(archive).toBeDisabled(); + fireEvent.click(archive); + expect(onArchive).not.toHaveBeenCalled(); + }); + + it('Cancel closes it without running anything, and focus lands on Cancel when it opens', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + const trigger = renderActionSheet([{label: 'Rename', onClick: onRename}]); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + const cancel = Array.from(card.querySelectorAll('button')).find( + button => button.textContent === 'Cancel', + )!; + expect(cancel).toHaveAttribute('data-autofocus'); + fireEvent.click(cancel); + expect(onRename).not.toHaveBeenCalled(); + await waitFor(() => + expect(trigger).toHaveAttribute('aria-expanded', 'false'), + ); + }); + + it('closing it returns focus to the trigger, after a controlled open too', async () => { + function Controlled() { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + ); + } + render(); + const opener = screen.getByRole('button', {name: 'Open from elsewhere'}); + fireEvent.click(opener); + // Nothing focused the trigger: the open came from elsewhere. + opener.blur(); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + const cancel = Array.from(card.querySelectorAll('button')).find( + button => button.textContent === 'Cancel', + )!; + fireEvent.click(cancel); + const trigger = screen.getByRole('button', {name: /^Project actions/}); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('rows that stop being plain actions while it shows keep the card on its last rows', async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + const {rerender} = render( + , + ); + await user.click(screen.getByRole('button', {name: /Project actions/})); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + // An asynchronous load turns the row into a sub-menu while the card shows. + rerender( + , + ); + const rename = Array.from(card.querySelectorAll('button')).find( + button => button.textContent === 'Rename', + )!; + fireEvent.click(rename); + // The row the card opened with runs; nothing is silently dropped. + expect(onRename).toHaveBeenCalledTimes(1); + }); + + it('a section shows its title above its actions', async () => { + const user = userEvent.setup(); + const trigger = renderActionSheet([ + {label: 'Rename'}, + {type: 'section', title: 'Share', items: [{label: 'Copy link'}]}, + ]); + await user.click(trigger); + const card = await screen.findByRole('dialog', {name: 'Project actions'}); + expect(card).toHaveTextContent('Share'); + expect( + Array.from(card.querySelectorAll('button')).map(b => b.textContent), + ).toEqual(['Rename', 'Copy link', 'Cancel']); + }); + + it('a menu with a sub-menu keeps the bottom sheet', async () => { + const user = userEvent.setup(); + const trigger = renderActionSheet([ + {label: 'Rename'}, + {label: 'Move to', items: [{label: 'Archive'}]}, + ]); + await user.click(trigger); + await screen.findByRole('dialog', {name: 'Project actions'}); + expect( + document.querySelector('[data-astryx-action-sheet-action]'), + ).toBeNull(); + }); +}); + +describe('DropdownMenu action sheet elsewhere', () => { + it('without a compact touch screen keeps the bottom sheet', async () => { + stubScreen({compactTouch: false}); + const user = userEvent.setup(); + const trigger = renderActionSheet(); + await user.click(trigger); + await screen.findByRole('dialog', {name: 'Project actions'}); + expect( + document.querySelector('[data-astryx-action-sheet-action]'), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/DropdownMenu/MenuActionSheetCard.tsx b/packages/core/src/DropdownMenu/MenuActionSheetCard.tsx new file mode 100644 index 0000000000000..a813f269cdca2 --- /dev/null +++ b/packages/core/src/DropdownMenu/MenuActionSheetCard.tsx @@ -0,0 +1,319 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file MenuActionSheetCard.tsx + * @input Menu data entries, the menu's label, Dialog, Button, Heading, + * AlertDialog's touch motion (useAlertMotion) and the shared action-group + * tracking (useActionGroupTracking) + * @output Exports MenuActionSheetCard and isActionSheetCardEligible: the + * action sheet as UIKit presents it on a phone since iOS 26 + * @position Internal to DropdownMenu's bottom-sheet (action sheet) + * presentation, which renders it on a compact touch screen for a flat list + * of actions; imported only through DropdownMenu's touch layer + * (DropdownMenuTouch.ts) + * + * On iOS 26 an action sheet with no source view no longer slides up from the + * bottom (iOS 18 and earlier did): it presents exactly as an alert does, a + * card centered on the screen (measured on the iOS 26.2 simulator): + * + * - it scales from 1.2 to 1 and fades in while the dimming fades in, on the + * alert's spring, and leaves with a fade on the same spring; a tap or a + * drag during either transition reaches nothing, as useAlertMotion holds + * the card's layer while it enters and leaves (`spec:AST-094` FR35), + * while keys and assistive technology stay live; + * - the actions are one stack with Cancel last, at the same weight; + * - a finger is tracked across the actions as one group: the action under it + * highlights, sliding moves the highlight, the lift runs the action under + * it (useActionGroupTracking, with the alert's onset and expansion); + * - a tap outside the card dismisses it, as does Escape. + * + * A menu whose rows open sub-menus, link elsewhere or carry a header or + * footer keeps the bottom sheet: UIKit's action sheet has none of them. + * + * SYNC: When modified, update MenuActionSheetCard.test.tsx and + * __tests__/DropdownMenuActionSheet.interaction.browser.spec.ts + */ + +import React, { + useEffect, + useId, + useRef, + type ReactElement, + type ReactNode, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {useAlertMotion} from '../AlertDialog/useAlertMotion'; +import {Button} from '../Button'; +import {Dialog} from '../Dialog'; +import {Divider} from '../Divider'; +import {Heading} from '../Heading/Heading'; +import {renderIconSlot} from '../Icon'; +import {Layout} from '../Layout/Layout'; +import {LayoutContent} from '../Layout/LayoutContent'; +import {Stack} from '../Stack'; +import {Text} from '../Text/Text'; +import {useActionGroupTracking} from '../hooks/useActionGroupTracking'; +import {useMergedRefs} from '../hooks/useMergedRefs'; +import {alertController} from '../motion/uikit/alert'; +import {spacingVars} from '../theme/tokens.stylex'; +import {pressGroupItemProps} from '../utils/pressFeedback'; +import {useTouchPresentation} from '../utils/touchPresentation'; +import type { + DropdownMenuItemData, + DropdownMenuOption, + DropdownMenuSection, +} from './DropdownMenu'; +import {useIsScrollable} from '../hooks/useIsScrollable'; + +/** Marks the actions the touch tracking hit-tests. */ +const ACTION_ATTRIBUTE = 'data-astryx-action-sheet-action'; + +const styles = stylex.create({ + // The tracking claims a touch on the card the moment it lands, as a long + // press does: the browser may pinch-zoom there but never pans (Chrome on + // Android cancels a pointer it pans, which would end the slide between + // actions), and a double tap zooms nothing. A card too long for the screen + // keeps its pan (`isBodyScrollable`). The gesture host turns text + // selection and the callout off on the tracked card while a finger is down. + trackingSurface: { + touchAction: 'pinch-zoom', + }, + // UIKit's alert motion drives the card: the theme's entrance stands down. + uikitMotion: { + animationName: 'none', + }, + action: { + width: '100%', + minWidth: 0, + }, + sectionTitle: { + paddingBlockStart: spacingVars['--spacing-1'], + }, +}); + +function isSection(option: DropdownMenuOption): option is DropdownMenuSection { + return 'type' in option && option.type === 'section'; +} + +function isDivider(option: DropdownMenuOption): boolean { + return 'type' in option && option.type === 'divider'; +} + +/** + * A row UIKit's action sheet can show: an action with a text label, not a + * link or a level. + */ +function isPlainAction(item: DropdownMenuItemData): boolean { + return ( + typeof item.label === 'string' && + item.href == null && + // A row with nested items opens a level. + (item.items == null || item.items.length === 0) && + // The card's buttons show a label and an icon; a row that shows more, + // or stays open when chosen, keeps the sheet. + item.description == null && + item.endContent == null && + item.hasCloseOnSelect !== false + ); +} + +/** + * Whether a menu's level can present as the action-sheet card: every row is + * a plain action (sections and dividers allowed), and the level has no header + * or footer of its own. + */ +export function isActionSheetCardEligible( + items: ReadonlyArray, + {hasRegions}: {hasRegions: boolean}, +): boolean { + if (hasRegions || items.length === 0) { + return false; + } + return items.every(option => { + if (isDivider(option)) { + return true; + } + if (isSection(option)) { + return option.items.every(isPlainAction); + } + return isPlainAction(option as DropdownMenuItemData); + }); +} + +export interface MenuActionSheetCardProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + /** The card's title and accessible name: the menu's label. */ + label: ReactNode; + /** The flat actions (see isActionSheetCardEligible). */ + items: ReadonlyArray; + /** Runs a chosen action; the host closes the card unless it stays open. */ + onSelect: (item: DropdownMenuItemData, event: React.MouseEvent) => void; + cancelLabel: string; + /** Where focus goes once the card has closed: the menu's trigger. */ + finalFocusRef?: React.RefObject; + /** Spread onto the card's content (the id the trigger's aria-controls names). */ + contentProps?: React.HTMLAttributes & { + 'data-testid'?: string; + }; +} + +export function MenuActionSheetCard({ + isOpen, + onOpenChange, + label, + items, + onSelect, + cancelLabel, + contentProps, + finalFocusRef, +}: MenuActionSheetCardProps): ReactElement { + const titleId = useId(); + // Eligibility is decided as the menu opens. While the card shows it follows + // the live rows as long as they stay plain actions; rows that gain a link, + // a sub-menu or rich content keep the card on the last rows it could + // present (they get the sheet the next time the menu opens). + const shownItemsRef = useRef(items); + if (isActionSheetCardEligible(items, {hasRegions: false})) { + shownItemsRef.current = items; + } + const shownItems = shownItemsRef.current; + // UIKit's alert motion under a finger; the card only renders on a compact + // touch screen, so a mouse reaching it (a docked tablet) keeps the theme's. + const isMotionEnabled = useTouchPresentation().isTouch; + const motion = useAlertMotion({isOpen, isEnabled: isMotionEnabled}); + const surfaceRef = useRef(null); + const mergedRef = useMergedRefs(surfaceRef, motion.ref); + const isTracking = motion.isPresented; + const bodyRef = useRef(null); + const isBodyScrollable = useIsScrollable(bodyRef, isTracking); + // Closing returns focus to the trigger, as the bottom sheet's finalFocusRef + // does; the dialog alone would restore whatever held focus at open (the + // page, after a programmatic or controlled open). + const wasPresentedRef = useRef(false); + useEffect(() => { + if (motion.isPresented) { + wasPresentedRef.current = true; + return; + } + if (wasPresentedRef.current) { + wasPresentedRef.current = false; + finalFocusRef?.current?.focus({preventScroll: true}); + } + }, [motion.isPresented, finalFocusRef]); + // The actions are tracked as one group, as an alert's are; a touch on the + // backdrop (it targets the dialog itself) is not part of it. + useActionGroupTracking(surfaceRef, { + itemSelector: `[${ACTION_ATTRIBUTE}]`, + highlightOnsetMs: alertController.actions.highlightOnsetMs, + highlightExpansion: alertController.actions.highlightExpansion, + shouldTrack: target => target !== surfaceRef.current, + isEnabled: isTracking, + manageStyle: false, + }); + const actionProps = { + [ACTION_ATTRIBUTE]: '', + // While the group tracks, its tracker paints a pointer's press on each + // action and the document's press feedback leaves it alone + // (`family:action-groups`), as on an alert's actions. + ...(isTracking ? pressGroupItemProps : undefined), + }; + + const renderAction = (item: DropdownMenuItemData, key: string) => ( +
+ ); +} + +MenuActionSheetCard.displayName = 'MenuActionSheetCard'; diff --git a/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx b/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx index 86f23e4123108..4c83052ae133e 100644 --- a/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx +++ b/packages/core/src/DropdownMenu/MenuBottomSheetActionList.tsx @@ -15,7 +15,7 @@ import {Divider} from '../Divider'; import {Heading} from '../Heading'; import {Icon, renderIconSlot} from '../Icon'; import {List, ListItem} from '../List'; -import {useMenuPress} from '../hooks/useMenuPress'; +import {useMenuSurfacePress} from '../hooks/useMenuPress'; import {colorVars, spacingVars} from '../theme/tokens.stylex'; import {rtlStyles} from '../utils'; import {getInteractionModality} from '../utils/interactionModality'; @@ -70,7 +70,7 @@ export function MenuBottomSheetActionList({ // The press model: the row under a finger's RELEASE acts, and the // highlight (focus) follows the finger across the rows. The sheet is modal, // so a release outside it is the scrim's, not a dismissal of ours. - const menuPress = useMenuPress({ + const menuPress = useMenuSurfacePress({ menuRef: listRef, itemSelector: SHEET_ROW_SELECTOR, }); diff --git a/packages/core/src/DropdownMenu/__tests__/DropdownMenuActionSheet.interaction.browser.spec.ts b/packages/core/src/DropdownMenu/__tests__/DropdownMenuActionSheet.interaction.browser.spec.ts new file mode 100644 index 0000000000000..1e9079f731c5b --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/DropdownMenuActionSheet.interaction.browser.spec.ts @@ -0,0 +1,569 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file DropdownMenuActionSheet.interaction.browser.spec.ts + * @input The built "Presentation / action sheet card" DropdownMenu story on the + * phone profile, real touch input, the interaction harness and the alert + * entries of the UIKit constants table + * @output Proofs for the action sheet's iOS 26 presentation on a phone under + * a finger (one touch model on every engine): + * an alert-style card that presents from scale 1.2 (capped at the viewport + * width) and opacity 0 with its + * dimming on the alert spring; a finger that lands on one action and lifts + * on another runs the one it lifts on, once; a tap or a drag that lands + * while it enters, on an action or on the dimming, runs and dismisses + * nothing and the card stays, while a key during the entrance still acts + * (`spec:AST-094` FR35); a tap outside dismisses it with + * a fade alone; storyboards of the present and the dismiss. A mouse keeps the + * bottom sheet + * @position Evidence for DropdownMenu's action-sheet card + * (MenuActionSheetCard); the alert motion and the group tracking it reuses + * have their own proofs beside AlertDialog + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + compareCurve, + drag, + tap, + freezeTimers, + installTimerGate, + openStory, + releaseAnimations, + springStep, + thawTimers, + timeline, + touchDriver, + waitForAnimation, + withStorybook, + writeEvidence, + type Point, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {alertController} from '../../motion/uikit/alert'; +import {springs} from '../../motion/uikit/springs'; + +/** The element's rendered box; a missing one fails the spec. */ +async function boxOf(locator: Locator): Promise<{ + x: number; + y: number; + width: number; + height: number; +}> { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('the element has no rendered box'); + } + return box; +} + +/** The page's viewport; every spec here sets one. */ +function viewportOf(page: Page): {width: number; height: number} { + const viewport = page.viewportSize(); + if (viewport == null) { + throw new Error('no viewport'); + } + return viewport; +} + +/** The last sample; an empty series fails the spec. */ +function lastOf(values: ReadonlyArray): T { + if (values.length === 0) { + throw new Error('no samples'); + } + return values[values.length - 1]; +} + +const storybook = withStorybook(test); + +function progressOf(spring: { + mass: number; + stiffness: number; + damping: number; +}): (t: number) => number { + const response = (2 * Math.PI) / Math.sqrt(spring.stiffness / spring.mass); + const dampingFraction = + spring.damping / (2 * Math.sqrt(spring.stiffness * spring.mass)); + return t => springStep({response, dampingFraction}, t); +} + +const present = progressOf(springs.alertPresent); +const dismiss = progressOf(springs.alertDismiss); +const {scaleFrom} = alertController.present; + +/** + * The scale the card presents from: UIKit's 1.2, capped so the scaled card is + * never wider than the viewport (useAlertMotion's presentScaleFrom). + */ +const presentedFrom = async (card: Locator) => + card.evaluate( + (el, from) => + Math.max( + 1, + Math.min( + from, + document.documentElement.clientWidth / + (el as HTMLElement).offsetWidth, + ), + ), + scaleFrom, + ); +const PRESENT_MS = alertController.present.durationMs; +const DISMISS_MS = alertController.dismiss.durationMs; +const OPACITY_TOLERANCE = 0.004; +const SCALE_TOLERANCE = 0.001; + +const centerOf = async (locator: Locator): Promise => { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error('no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +}; + +interface Opened { + driver: TouchDriver; + trigger: Locator; + card: Locator; +} + +/** Opens the story with the sheet closed (the story opens it on load). */ +async function openPage(page: Page): Promise { + await openStory( + page, + storybook(), + 'core-dropdownmenu--action-sheet-card-presentation', + ); + await page.evaluate(() => { + const picked: string[] = []; + (window as unknown as {__picked: string[]}).__picked = picked; + // The story reports a pick by logging "