diff --git a/.changeset/action-group-tracker-core.md b/.changeset/action-group-tracker-core.md new file mode 100644 index 0000000000000..ee350e42a1fc4 --- /dev/null +++ b/.changeset/action-group-tracker-core.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[internal] AlertDialog's touch tracking runs on the shared action-group tracker, the one any gesture recognizer can drive (a menu tracking the finger that opened it from its trigger, a segmented control, a page control). AlertDialog's behavior is unchanged. + +@vjeux diff --git a/.changeset/action-group-tracker.md b/.changeset/action-group-tracker.md new file mode 100644 index 0000000000000..a3f9633a006e9 --- /dev/null +++ b/.changeset/action-group-tracker.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[internal] One tracker for a held finger across a group of targets (an alert's or an action sheet's actions, a menu's rows, a segmented control's segments, a page control's dots): it reads the targets' rects once, resolves the finger against them (or by the owner's rule), paints or reports the highlight, and runs the target through its own click at the lift. Nothing a consumer sees changes. + +@vjeux diff --git a/.changeset/alertdialog-touch-action-tracking.md b/.changeset/alertdialog-touch-action-tracking.md new file mode 100644 index 0000000000000..f6a2a70292fcd --- /dev/null +++ b/.changeset/alertdialog-touch-action-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] AlertDialog: on a touch screen, under a finger or a pen, the two actions are tracked as one group, the way iOS tracks an alert's actions. A finger 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 touched action highlights as soon as it is touched instead of 150 ms later, the highlight follows the finger between the actions, and lifting off both still runs nothing. A mouse, the keyboard and assistive technology keep each button's own click, and text stays selectable with a mouse. To keep the previous behavior under a finger, pass the new `presentation="dialog"`, which keeps the dialog a mouse gets on every device; the default is `presentation="adaptive"`. + +@vjeux diff --git a/.changeset/alertdialog-touch-motion.md b/.changeset/alertdialog-touch-motion.md new file mode 100644 index 0000000000000..a863e86afdb7d --- /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. With a mouse nothing changes, and `presentation="dialog"` keeps the previous motion and width under a finger too. + +@vjeux diff --git a/.changeset/build-stylex-cross-file-consts.md b/.changeset/build-stylex-cross-file-consts.md new file mode 100644 index 0000000000000..736b20da4327d --- /dev/null +++ b/.changeset/build-stylex-cross-file-consts.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/build': patch +--- + +[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. + +@vjeux diff --git a/.changeset/dropdownmenu-touch-selection.md b/.changeset/dropdownmenu-touch-selection.md new file mode 100644 index 0000000000000..601f128deb154 --- /dev/null +++ b/.changeset/dropdownmenu-touch-selection.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger, on every touch device, DropdownMenu tracks a finger that lands in its open menu as the list of an iOS menu does: the row under a resting finger lights at once in a menu that fits and, in a menu that scrolls, after UIKit's content-touch delay (150 ms), a tap picks at the lift, and a quick drag scrolls a menu that scrolls and slides the highlight in one that fits. A mouse, and `presentation="popover"`, keep the pointer model. It runs on the gesture system and the shared action-group tracker; `useMenuPress` itself is unchanged. + +@vjeux diff --git a/.changeset/dropdownmenu-uimenu-placement.md b/.changeset/dropdownmenu-uimenu-placement.md new file mode 100644 index 0000000000000..27ec44d69063e --- /dev/null +++ b/.changeset/dropdownmenu-uimenu-placement.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] On a compact touch screen (a phone, iOS or Android), the popover DropdownMenu and MoreMenu sit where UIMenu does: over their trigger, centered on it and top-aligned with it, or bottom-aligned when the trigger is in the lower half of the viewport, 8 px from the viewport's sides (or the safe area), and no taller than the room left, so they stay on screen and scroll. The finger that opened the menu rests on its first row and picks only once it moves, as on iOS. Larger screens, a mouse, and `presentation="popover"` keep the menu below its trigger. + +@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-browser-negotiation.md b/.changeset/gesture-browser-negotiation.md new file mode 100644 index 0000000000000..4ca7bd087a800 --- /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; DropdownMenu's press hook uses them. + +@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.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..e06ecc0113f7e --- /dev/null +++ b/.changeset/gesture-press-tracking.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: UIControl press tracking with control and cell profiles and the gesture hooks, with every press painter writing `data-astryx-press` through one internal writer in `utils/pressFeedback.ts`. Not exported from the package entry point; no component uses them yet. + +@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-swipe-pinch-rotation-edge-hover.md b/.changeset/gesture-swipe-pinch-rotation-edge-hover.md new file mode 100644 index 0000000000000..e8370c0709687 --- /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; no component uses them yet. + +@vjeux diff --git a/.changeset/layer-exit-copy.md b/.changeset/layer-exit-copy.md new file mode 100644 index 0000000000000..98da7c6d1fddf --- /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. DropdownMenu's touch close plays on it. + +@vjeux diff --git a/.changeset/menu-handoff-shared-tracker.md b/.changeset/menu-handoff-shared-tracker.md new file mode 100644 index 0000000000000..34d2831dc174d --- /dev/null +++ b/.changeset/menu-handoff-shared-tracker.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[internal] Under DropdownMenu's touch modes, the finger that opened the menu resolves the menu's rows through the shared action-group tracker, as a finger that lands in the menu already does: rects read once instead of a hit test per move. Nothing a consumer sees changes. + +@vjeux diff --git a/.changeset/menu-press-uimenu.md b/.changeset/menu-press-uimenu.md new file mode 100644 index 0000000000000..43a42da48646c --- /dev/null +++ b/.changeset/menu-press-uimenu.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feature] Under a finger, a DropdownMenu or MoreMenu trigger now opens its menu as the finger presses, instead of after a 500 ms hold (`spec:AST-094` FR29). 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. The finger that opened the menu picks a row only after it has moved 10 px, so a menu that appears under a resting finger never picks the row beneath it. To keep the previous behavior, a tap that opens at the lift, a 500 ms hold and the settle rule, pass `presentation="popover"`. A mouse is unchanged. + +@vjeux diff --git a/.changeset/presentation-resolver.md b/.changeset/presentation-resolver.md new file mode 100644 index 0000000000000..8ccf8732ad0eb --- /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. DropdownMenu (and MoreMenu, through it) reads it for its touch presentation. + +@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..7dbfb39b85e64 --- /dev/null +++ b/.changeset/touch-selection-lock-gestures.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: under a finger or pen on a coarse primary pointer, the gesture host turns text selection and the touch callout off for one held contact only on elements whose hold does something (`spec:AST-094` FR41, DEC-9). A long press with a hold brings that lock by default (one at 0 ms, a touch-down detector, brings none, and `selectionLock: null` opts out), and another recognizer opts in with the internal `selectionLock` option (a slider's thumb, a switch's drag); the host calls the locks and never imports one. Everything else keeps the browser's long-press selection. 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..f5cbe4324931c 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; DropdownMenu's touch presentation reads it. @vjeux diff --git a/.changeset/uikit-motion-animator-curves.md b/.changeset/uikit-motion-animator-curves.md new file mode 100644 index 0000000000000..86bc609560a8d --- /dev/null +++ b/.changeset/uikit-motion-animator-curves.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator plays any closed-form curve (such as a spring chasing a moving target) through `play`, with the same presentation values, velocities, stop and shared clock as a spring, and the Reduce Motion policy. 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-reduced-motion.md b/.changeset/uikit-motion-animator-reduced-motion.md new file mode 100644 index 0000000000000..4dc42ecde1f9c --- /dev/null +++ b/.changeset/uikit-motion-animator-reduced-motion.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the touch presentations' animator follows design:reduced-motion (under Reduce Motion, an opacity-only cross-fade of 200 ms on a coarse pointer or after a touch release and an immediate change on any other pointer, with no option or theme value that keeps travel or lengthens the fade; an entrance or exit role orients a slide that changes no opacity), decided by its own planner module, and starts a call from rest when the browser commits it, as Core Animation does. Not exported from the package entry point. + +@vjeux diff --git a/.changeset/uikit-motion-animator.md b/.changeset/uikit-motion-animator.md index 83e8f2b68c76f..766155c9db08b 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 a release's momentum, and under Reduce Motion cross-fades a spring back from past an edge) 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, 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/apps/docsite/next.config.mjs b/apps/docsite/next.config.mjs index de34c14a3bd41..05300b35a3a9f 100644 --- a/apps/docsite/next.config.mjs +++ b/apps/docsite/next.config.mjs @@ -9,9 +9,14 @@ * @position Next.js configuration for the existing Vercel docsite deployment. */ -import {existsSync, readFileSync, readdirSync} from 'node:fs'; +import {existsSync, readFileSync, readdirSync, realpathSync} from 'node:fs'; +import {createRequire} from 'node:module'; import {resolve} from 'node:path'; +const coreInternalModules = createRequire(import.meta.url)( + '../../scripts/core-internal-modules.cjs', +); + const playgroundCookieCompatibility = readFileSync( resolve( import.meta.dirname, @@ -156,6 +161,28 @@ const nextConfig = { {'@astryxdesign/cli/api': 'module @astryxdesign/cli/api'}, ]; } + // Lab's build imports core's internal motion and gesture modules by their + // module paths, which core's "exports" do not list. Resolve each to the + // file core's own built components import, so a page keeps one gesture + // host and one animator per element. + const coreDir = realpathSync( + resolve(import.meta.dirname, 'node_modules/@astryxdesign/core'), + ); + config.plugins.push( + new webpack.NormalModuleReplacementPlugin( + coreInternalModules.CORE_INTERNAL_SPECIFIER, + resource => { + const file = coreInternalModules.resolveCoreInternal( + resource.request, + {coreDir}, + ); + if (file != null) { + resource.request = file; + } + }, + ), + ); + // Webpack's CSS @import resolver doesn't follow package.json "exports". // Map each theme's /theme.css subpath to the actual dist file. const themesDir = resolve(import.meta.dirname, '../../packages/themes'); 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/sandbox/vite.config.mts b/apps/sandbox/vite.config.mts index f91d6d4d024bd..e57b15e35f0cd 100644 --- a/apps/sandbox/vite.config.mts +++ b/apps/sandbox/vite.config.mts @@ -15,6 +15,7 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {createRequire} from 'node:module'; import stylexResolution from '../../scripts/stylex-module-resolution.cjs'; +import coreInternalModules from '../../scripts/core-internal-modules.cjs'; import {categories} from './src/app/sandboxPages'; import { discoverRoutes, @@ -33,6 +34,29 @@ let routes = discoverRoutes( categories.map(category => category.slug), ); +/** + * Lab's build imports core's internal motion and gesture modules by their + * module paths, which core's `exports` do not list. Resolve each to the file + * core's own built components import (its source under ASTRYX_SOURCE), so the + * page keeps one gesture host and one animator per element. + */ +function coreInternalModulePaths(): Plugin { + const coreDir = fs.realpathSync( + path.join(appDir, 'node_modules/@astryxdesign/core'), + ); + const source = process.env.ASTRYX_SOURCE === '1'; + return { + name: 'sandbox-core-internal-modules', + enforce: 'pre', + resolveId(specifier) { + return coreInternalModules.resolveCoreInternal(specifier, { + coreDir, + source, + }); + }, + }; +} + function staticRoutes(): Plugin { const id = '\0virtual:sandbox-routes'; let failed = false; @@ -212,6 +236,7 @@ export default defineConfig({ ], }, }), + coreInternalModulePaths(), staticRoutes(), ], resolve: { diff --git a/apps/storybook/stories/AlertDialog.stories.tsx b/apps/storybook/stories/AlertDialog.stories.tsx index 21fb86fe149d1..4936bd2db58a3 100644 --- a/apps/storybook/stories/AlertDialog.stories.tsx +++ b/apps/storybook/stories/AlertDialog.stories.tsx @@ -51,6 +51,34 @@ export const Delete: Story = { }, }; +/** + * The delete confirmation with `presentation="dialog"`: under a finger it + * keeps the dialog a mouse gets, with each button's own press and click. + */ +export const DialogPresentation: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(false); + return ( + <> +
`; + const FIXTURE = ` +
`; + +async function busyFixture(page: Page) { + await page.route('http://busy.test/', route => + route.fulfill({contentType: 'text/html', body: BUSY_FIXTURE}), + ); + await page.goto('http://busy.test/'); +} + // A real navigation (not setContent, whose document.open() erases the window // listeners the input log installs). async function fixture(page: Page) { @@ -242,6 +297,829 @@ test.describe('touch input', () => { }); }); +test.describe('frame evidence', () => { + test('a compositor animation advances every frame through a blocked main thread; a rAF one freezes, whatever the screencast rate', async ({ + page, + }, testInfo) => { + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + + const run = async (driver: 'compositor' | 'raf', everyNthFrame = 1) => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page, {everyNthFrame}); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const tracked = await trackMarker(page, await cast.stop()); + return busyWindowProof(tracked, window, {axis: 'y'}); + }; + + const compositor = await run('compositor'); + testInfo.annotations.push({ + type: 'busy-main-thread compositor', + description: compositor.summary, + }); + expect(compositor.verdict.failures, compositor.summary).toEqual([]); + expect(compositor.monotonic).toBe(true); + // Resolvable on the cast's grid: two position steps or more per frame. + expect(compositor.notes, compositor.summary).toEqual([]); + expect(compositor.pxPerFrame).toBeGreaterThanOrEqual(2 * compositor.stepPx); + + const raf = await run('raf'); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control', + description: raf.summary, + }); + // Frozen for the whole block: no presented frame moved (the compositor + // sends none at all when nothing on screen changes), and the verdict that + // passes the compositor animation fails it on every criterion. + expect(raf.changed).toBe(0); + expect(raf.longestStallMs).toBeGreaterThan(600); + expect(raf.verdict.pass).toBe(false); + expect(raf.verdict.failures.join('\n')).toMatch(/longest stall/); + expect(raf.verdict.failures.join('\n')).toMatch(/expected frames moved/); + + // The verdict judges the frames the screencast delivered, not its rate: + // at one frame in six (a loaded machine's ~100 ms), the compositor + // animation still passes and the rAF one still fails. + const slow = await run('compositor', 6); + expect(slow.cadenceMs, slow.summary).toBeGreaterThan(60); + expect(slow.verdict.failures, slow.summary).toEqual([]); + const slowRaf = await run('raf', 6); + expect(slowRaf.verdict.pass, slowRaf.summary).toBe(false); + expect(slowRaf.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('frames the screencast skips while the compositor presents them are not a stall; a frozen animation still fails with the compositor record', async ({ + page, + browserName, + }, testInfo) => { + test.skip( + browserName !== 'chromium', + 'compositor traces are Chromium-only (README: engine limits)', + ); + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const run = async (driver: 'compositor' | 'raf') => { + await box.evaluate((el, how) => { + el.getAnimations().forEach(a => a.cancel()); + el.style.transform = 'none'; + if (how === 'compositor') { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + } else { + const start = performance.now(); + const step = (now: number) => { + const p = Math.min(1, (now - start) / 1600); + el.style.transform = `translateY(${560 * p}px)`; + if (p < 1) { + requestAnimationFrame(step); + } + }; + requestAnimationFrame(step); + } + }, driver); + const cast = await startScreencast(page); + const compositor = await startCompositorTrace(page); + await page.waitForTimeout(200); + const window = await blockMainThread(page, 700); + await page.waitForTimeout(200); + const presented = await compositor.stop(); + const tracked = await trackMarker(page, await cast.stop()); + return {window, tracked, presented}; + }; + + const moving = await run('compositor'); + expect(moving.presented.length).toBeGreaterThan(0); + // The screencast as a loaded machine delivers it: four frames in a row + // skipped mid-block, which the compositor still presented. + const mid = moving.tracked.findIndex(f => f.t > moving.window.from + 350); + const skipped = moving.tracked.filter((_, i) => i < mid || i >= mid + 4); + const alone = busyWindowProof(skipped, moving.window, {axis: 'y'}); + expect(alone.verdict.failures.join('\n'), alone.summary).toMatch( + /longest stall/, + ); + const proof = busyWindowProof(skipped, moving.window, { + axis: 'y', + presented: moving.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread compositor record', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.recorderSkipped).toBeGreaterThanOrEqual(4); + + // A rAF animation presents no change for the whole block: the record + // excuses nothing, whatever the compositor drew meanwhile. + const frozen = await run('raf'); + const still = busyWindowProof(frozen.tracked, frozen.window, { + axis: 'y', + presented: frozen.presented, + }); + testInfo.annotations.push({ + type: 'busy-main-thread rAF control, compositor record', + description: `${still.summary}; compositor frames inside the block: ${frozen.presented.filter(t => t > frozen.window.from && t < frozen.window.to).length}`, + }); + expect(still.verdict.pass).toBe(false); + expect(still.recorderSkipped).toBe(0); + expect(still.verdict.failures.join('\n')).toMatch(/longest stall/); + }); + + test('a block that starts during a just-started transition still lets the compositor advance it', async ({ + page, + }, testInfo) => { + // `during` blocks in the frame the transition has run 16 ms. In WebKit + // the transition reaches the compositor in that frame's rendering update, + // so a loop inside the rAF callback froze it (2 of 29 frames moved). + // Linear and longer than the block, so every frame of the block moves two + // position steps or more and the transition is still running when it + // ends. The cast shows the box at rest first, so a transition that reaches + // the screen after the block began is judged from there (onScreenFromMs). + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + const cast = await startScreencast(page); + await expect.poll(() => cast.frameCount()).toBeGreaterThan(0); + await box.evaluate(el => { + el.style.transition = 'transform 1600ms linear'; + el.getBoundingClientRect(); + el.style.transform = 'translateY(560px)'; + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 16, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread transition', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('a block never starts on an animation that is not on the compositor yet', async ({ + page, + }, testInfo) => { + // An animation created in the frame the helper looks is still pending (not + // committed to the compositor). A block that started on it then froze it + // in Chromium: no presented frame moved. + await busyFixture(page); + const box = page.locator('#box'); + await markElement(box, {mode: 'outline'}); + await box.evaluate(el => { + el.style.willChange = 'transform'; + }); + const cast = await startScreencast(page); + // A rAF loop registered before the helper's tick runs ahead of it in every + // frame; on its 12th frame it creates the animation, and the helper's tick + // looks in that same frame. + await box.evaluate(el => { + let n = 0; + const loop = () => { + n += 1; + if (n === 12) { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(560px)'}], + {duration: 1600, easing: 'linear'}, + ); + return; + } + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + }); + const window = await blockMainThread(page, 700, { + during: box, + minProgressMs: 0, + }); + await waitForRest(box); + const tracked = await trackMarker(page, await cast.stop()); + const proof = busyWindowProof(tracked, window, {axis: 'y'}); + testInfo.annotations.push({ + type: 'busy-main-thread pending animation', + description: proof.summary, + }); + expect(proof.verdict.failures, proof.summary).toEqual([]); + expect(proof.monotonic).toBe(true); + }); + + test('in the WebKit container, compiled shaders outlive the run', async ({ + browser, + }, testInfo) => { + // The official Playwright image keeps its browsers here. + test.skip( + testInfo.project.name !== 'webkit' || + process.env.PLAYWRIGHT_BROWSERS_PATH !== '/ms-playwright', + 'runs inside webkit-container.mjs', + ); + // A cache inside the container dies with it: every run then compiled each + // shader again, 450–700 ms in the first frame of a blurred layer. + const dir = process.env.MESA_SHADER_CACHE_DIR; + expect(dir, 'MESA_SHADER_CACHE_DIR names the host-kept cache').toBeTruthy(); + // Written on the host by the script before the container starts. + expect( + fs.readFileSync(path.join(dir ?? '', 'ASTRYX-SHADER-CACHE'), 'utf8'), + ).toContain('playwright'); + // Mesa writes what it compiles there once a page draws a blurred layer. + // Earlier runs have filled the cache already, so this run's browser gets + // an empty directory beside them, in the same host-kept mount, and the + // test watches its own writes arrive. + const fresh = fs.mkdtempSync(path.join(dir ?? '', 'self-test-')); + try { + const own = await browser.browserType().launch({ + env: {...process.env, MESA_SHADER_CACHE_DIR: fresh}, + }); + try { + const page = await own.newPage(); + await page.setContent( + '
', + ); + await page.evaluate( + () => + document + .getElementById('glass') + ?.animate([{scale: '1'}, {scale: '0.9'}], {duration: 200}) + .finished, + ); + } finally { + await own.close(); + } + expect(fs.readdirSync(fresh, {recursive: true})).not.toEqual([]); + } finally { + fs.rmSync(fresh, {recursive: true, force: true}); + } + }); + + test('rect sampling reads one rect per animation frame while the main thread is free', async ({ + page, + browserName, + }) => { + await fixture(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600, easing: 'linear'}, + ); + }); + const samples = await sampleRects(box, 400); + const xs = samples.map(s => s.box?.x ?? NaN); + const gaps = samples + .slice(1) + .map((s, i) => s.t - samples[i].t) + .sort((a, b) => a - b); + const cadence = gaps[Math.floor(gaps.length / 2)]; + expect(cadence).toBeLessThan(34); + expect(samples.length).toBeGreaterThanOrEqual(10); + if (browserName === 'chromium') { + // One sample per presented frame at 60 Hz. (Headless WebKit's animation + // frames are irregular, so only the cadence is checked there.) + expect(samples.length).toBeGreaterThanOrEqual(20); + } + expect(xs.every((x, i) => i === 0 || x >= xs[i - 1])).toBe(true); + expect(xs.at(-1) ?? 0).toBeGreaterThan(xs[0] + 80); + }); + + test('the trace counts no Layout for a transform animation and one per frame for `top`', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const box = page.locator('#box'); + // Count the animation's steady middle: starting and ending it may lay out once each. + const animate = async (property: 'transform' | 'top') => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await box.evaluate((el, prop) => { + const keyframes = + prop === 'transform' + ? [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(200px)'}, + ] + : [{top: '360px'}, {top: '560px'}]; + el.animate(keyframes, {duration: 600}); + return performance.timeOrigin + performance.now(); + }, property); + await page.waitForTimeout(700); + }); + return countBetween(trace, started + 100, started + 500); + }; + const transform = await animate('transform'); + const top = await animate('top'); + expect(transform.Layout ?? 0).toBe(0); + expect(transform.Paint ?? 0).toBe(0); + // ~24 frames in 400 ms: one layout per frame. + expect(top.Layout ?? 0).toBeGreaterThan(18); + }); + + test("the trace counts no Layout from the harness's own animation recorder, which stands down while it runs", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + // The recorder runs, as in any spec that waited for an animation first. + await waitForRest(pop); + const watch = await watchAnimations(pop); + const middle = async (askEveryFrame: boolean) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate((el, every) => { + const w = window as unknown as {stop?: boolean}; + w.stop = false; + if (every) { + const ask = () => { + if (!w.stop) { + requestAnimationFrame(ask); + } + }; + requestAnimationFrame(ask); + } + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }, askEveryFrame); + await page.waitForTimeout(700); + await page.evaluate(() => { + (window as unknown as {stop?: boolean}).stop = true; + }); + }); + // The steady middle: starting and ending it may lay out once each. + return countBetween(trace, started + 100, started + 500); + }; + expect((await middle(false)).Layout ?? 0).toBe(0); + // Control: a page that asks for a frame every frame lays it out in each. + expect((await middle(true)).Layout ?? 0).toBeGreaterThan(18); + // Nothing the traces ran went unrecorded. + const started = await watch.started(); + expect(started.map(a => a.durationMs)).toEqual([600, 600]); + }); + + test('a waitForRest awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // A per-frame wait here would make every frame a main-thread frame. + await waitForRest(pop); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // It still waited for the curve to end. + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForAnimation or watch.started() awaited inside a traced body stands down too, so the trace counts no Layout from it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + let waited: {durationMs: number}[] = []; + let watched: {durationMs: number}[] = []; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + el.animate( + [ + {transform: 'translateY(0px)', opacity: 1}, + {transform: 'translateY(100px)', opacity: 0.5}, + ], + {duration: 600}, + ); + return performance.timeOrigin + performance.now(); + }); + // Both poll until 450 ms of the curve has played, across the steady + // middle; a read of the page's animations each time would lay it out. + waited = await waitForAnimation(pop, {minProgressMs: 450}); + watched = await watch.started({minProgressMs: 450}); + await page.waitForTimeout(200); + }); + expect(countBetween(trace, started + 100, started + 500).Layout ?? 0).toBe( + 0, + ); + // Each still answered with the animation once 450 ms of it had played. + expect(waited.map(a => a.durationMs)).toEqual([600]); + expect(watched.map(a => a.durationMs)).toEqual([600]); + expect(trace.window.to - started).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for an animation the action starts two frames later', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + const trace = await traceRendering(page, async () => { + // The action starts the animation two frames later, as a component's + // effect often does. + await pop.evaluate(el => { + requestAnimationFrame(() => + requestAnimationFrame(() => { + (window as unknown as {__started: number}).__started = + performance.timeOrigin + performance.now(); + el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600}, + ); + }), + ); + }); + await waitForRest(pop); + }); + const started = await page.evaluate( + () => (window as unknown as {__started?: number}).__started ?? null, + ); + // The traced window holds the whole animation. + expect(started).not.toBeNull(); + expect(trace.window.to - (started as number)).toBeGreaterThanOrEqual(590); + }); + + test('a waitForRest awaited inside a traced body waits for a run going backward to its end, and no longer', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + // How long a traced waitForRest holds after a 600 ms move starts. + const traced = async (reverse: boolean, endDelay = 0) => { + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate( + (el, {reverse, endDelay}) => { + const run = el.animate( + [ + {transform: 'translateY(0px)'}, + {transform: 'translateY(100px)'}, + ], + {duration: 600, endDelay}, + ); + if (reverse) { + // Its current time falls from its end time to 0. + run.reverse(); + } + return performance.timeOrigin + performance.now(); + }, + {reverse, endDelay}, + ); + await waitForRest(pop); + }); + await waitForRest(pop); + return trace.window.to - started; + }; + // Reversed with a 200 ms end delay: the move plays from 200 to 800 ms. + expect(await traced(true, 200)).toBeGreaterThanOrEqual(790); + // A reversed run is waited for as long as the same run forward, not + // twice as long. + const forward = await traced(false); + const backward = await traced(true); + expect(backward).toBeGreaterThanOrEqual(590); + expect(backward).toBeLessThan(forward * 1.5); + }); + + test('a waitForRest awaited inside a traced body runs on the real timers, so a frozen timer gate cannot hold it', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + const pop = page.locator('#pop'); + await waitForRest(pop); + await installTimerGate(page); + await freezeTimers(page); + let outcome = ''; + await traceRendering(page, async () => { + await pop.evaluate(el => { + el.animate( + [{transform: 'translateY(0px)'}, {transform: 'translateY(100px)'}], + {duration: 300}, + ); + }); + // Held by the gate, the wait would neither resolve nor reach its + // deadline: the race reads that as "held". + outcome = await Promise.race([ + waitForRest(pop, {timeout: 2000}).then( + () => 'rested', + () => 'rejected', + ), + new Promise(resolve => setTimeout(() => resolve('held'), 4000)), + ]); + }); + await thawTimers(page); + expect(outcome).toBe('rested'); + }); + + test('inside a trace, waitForRest rests beside a scroll-timeline animation', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await fixture(page); + const scroller = page.locator('#scroller'); + await waitForRest(scroller); + let outcome = ''; + await traceRendering(page, async () => { + // A row driven by its scroller's timeline: it moves only on scroll. + await scroller.evaluate(el => { + const row = el.firstElementChild as HTMLElement; + row.animate([{opacity: 0.5}, {opacity: 1}], { + timeline: new ScrollTimeline({source: el}), + }); + }); + outcome = await waitForRest(scroller, {timeout: 2000}).then( + () => 'rested', + (error: Error) => error.message, + ); + }); + expect(outcome).toBe('rested'); + }); + + test('a CSS transition and animation that start and end inside a trace are reported, and lay out nothing in between', async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + await page.route('http://anchored.test/', route => + route.fulfill({contentType: 'text/html', body: ANCHORED}), + ); + await page.goto('http://anchored.test/'); + await page.addStyleTag({ + content: + '@keyframes harness-fade { from { opacity: 1 } to { opacity: 0.6 } }', + }); + const pop = page.locator('#pop'); + // The recorder runs, and a watch is taken before the trace. + await waitForRest(pop); + const watch = await watchAnimations(pop); + let started = 0; + const trace = await traceRendering(page, async () => { + started = await pop.evaluate(el => { + const pop = el as HTMLElement; + pop.style.transition = 'transform 400ms linear'; + void pop.offsetWidth; + pop.style.transform = 'translateY(100px)'; + pop.style.animation = 'harness-fade 400ms linear'; + return performance.timeOrigin + performance.now(); + }); + await page.waitForTimeout(600); + }); + // Its reads at the two starts fall before the steady middle. + expect(countBetween(trace, started + 100, started + 300).Layout ?? 0).toBe( + 0, + ); + const ended = await watch.started({timeout: 1_000}); + expect(ended.map(a => a.kind).sort()).toEqual([ + 'CSSAnimation', + 'CSSTransition', + ]); + expect(ended.every(a => a.finishedAt != null)).toBe(true); + }); + + test("the long-task log records the page's own long task but not a DevTools evaluate, which the trace reports apart", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'the Long Tasks API and rendering traces are Chromium-only', + ); + await installLongTaskLog(page); + await fixture(page); + // The page's own task: a timer that spins 120 ms. + const own = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 120; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + await frames(page, 2); + const recorded = await readLongTasks(page, own); + expect(recorded).toHaveLength(1); + expect(recorded[0].durationMs).toBeGreaterThanOrEqual(110); + // A DevTools evaluate that spins 300 ms: the trace reports it apart from + // the page's long tasks, and the log never sees it. + let block = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + block = await blockMainThread(page, 300); + }); + await frames(page, 2); + const inBlock = (t: {start: number}) => + t.start < block.to && t.start >= block.from - 20; + expect( + trace.devtoolsTasks.some(t => t.durationMs >= 290 && inBlock(t)), + ).toBe(true); + expect(trace.longTasks.filter(inBlock)).toEqual([]); + expect(await readLongTasks(page, block)).toEqual([]); + }); + + test("a harness read inside a trace is not the page's long task, while a test's own evaluate is, whether it calls the page directly or sets it off", async ({ + page, + browserName, + }) => { + test.skip( + browserName !== 'chromium', + 'rendering traces are Chromium-only (README: engine limits)', + ); + // Enough animated rows that describing their keyframes takes a DevTools + // task over 50 ms. + const rows = Array.from( + {length: 1200}, + (_, i) => `
row ${i}
`, + ).join(''); + await page.setContent( + `
${rows}
`, + ); + const slow = page.locator('#slow'); + await slow.evaluate(el => + el.addEventListener('click', () => { + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + }), + ); + // Page code a test can call directly, as through a story's own API. + await page.evaluate(() => { + (window as unknown as {pageWork: (ms: number) => void}).pageWork = ( + ms: number, + ) => { + const end = performance.now() + ms; + while (performance.now() < end) { + // spin + } + }; + }); + await frames(page, 3); + const now = () => + page.evaluate(() => performance.timeOrigin + performance.now()); + const read = {from: 0, to: 0}; + const click = {from: 0, to: 0}; + const direct = {from: 0, to: 0}; + let timer = {from: 0, to: 0}; + const trace = await traceRendering(page, async () => { + read.from = await now(); + await waitForAnimation(page.locator('#list')); + read.to = await now(); + // On a slow machine each frame of 1,200 running rows is a long task of + // the page's own; the windows below hold only their own work. + await page.evaluate(() => document.getElementById('list')?.remove()); + await frames(page, 2); + // The page's own task: a timer that spins 60 ms. + timer = await page.evaluate( + () => + new Promise<{from: number; to: number}>(resolve => { + setTimeout(() => { + const from = performance.timeOrigin + performance.now(); + const end = performance.now() + 60; + while (performance.now() < end) { + // spin + } + resolve({from, to: performance.timeOrigin + performance.now()}); + }, 0); + }), + ); + // Page script run inside a DevTools evaluate: a 60 ms click handler. + click.from = await now(); + await slow.evaluate(el => (el as HTMLElement).click()); + click.to = await now(); + // Page code a test's evaluate calls directly: 60 ms of the page's work. + direct.from = await now(); + await page.evaluate(() => + (window as unknown as {pageWork: (ms: number) => void}).pageWork(60), + ); + direct.to = await now(); + }); + const within = (w: {from: number; to: number}) => (t: {start: number}) => + t.start >= w.from - 20 && t.start < w.to; + // The read itself is set apart. The rows' frames around it may be long + // tasks too, the page's own: they stay in longTasks. + expect(trace.devtoolsTasks.some(within(read))).toBe(true); + expect( + trace.longTasks.some(t => within(timer)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(click)(t) && t.durationMs >= 55), + ).toBe(true); + expect( + trace.longTasks.some(t => within(direct)(t) && t.durationMs >= 55), + ).toBe(true); + expect(trace.devtoolsTasks.filter(within(direct))).toEqual([]); + }); +}); + test.describe('curve sampling', () => { test('a CSS transition samples onto its cubic-bezier to 0.05 px', async ({ page, @@ -961,6 +1839,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, }) => { @@ -1429,6 +2392,34 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { } }); + test("the harness's own waits run on the real timers: a frozen timer gate holds neither blockMainThread's block nor sampleRects", async ({ + page, + }) => { + await fixture(page); + await installTimerGate(page); + const box = page.locator('#box'); + await box.evaluate(el => { + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 1_500, easing: 'linear'}, + ); + }); + await freezeTimers(page); + // Settled, or still held after 5 s. + const within = (work: Promise) => + Promise.race([ + work.then(() => 'settled'), + new Promise(resolve => + setTimeout(() => resolve('held'), 5_000), + ), + ]); + expect( + await within(blockMainThread(page, 100, {during: box, timeout: 2_000})), + ).toBe('settled'); + expect(await within(sampleRects(box, 200))).toBe('settled'); + await thawTimers(page); + }); + test('the timer gate holds a component timer until the filmstrip is done', async ({ page, }, testInfo) => { @@ -1614,4 +2605,58 @@ test.describe('storyboards', {tag: '@storyboard'}, () => { expect(state).toMatchObject({timer: false, frame: false}); expect(state.heldFor).toBeGreaterThanOrEqual(140); }); + + test('a cast filmstrip keeps exactly the presented frames of its window, labelled from its zero', async ({ + page, + }, testInfo) => { + await fixture(page); + const box = page.locator('#box'); + // JPEG frames (WebKit's are always JPEG) take the conversion path. + const cast = await startScreencast(page, {format: 'jpeg'}); + await box.evaluate(el => + el.animate( + [{transform: 'translateX(0px)'}, {transform: 'translateX(200px)'}], + {duration: 600}, + ), + ); + await page.waitForTimeout(500); + const presented = await cast.stop(); + expect(presented.length).toBeGreaterThan(8); + // A window of the middle frames, zeroed on one of them. + const zero = presented[2].t; + const from = presented[3].t; + const to = presented[presented.length - 3].t; + const inside = presented.filter(f => f.t >= from && f.t <= to); + const seen: number[] = []; + const film = await castStoryboard(page, presented, { + name: 'harness-self-test-cast', + zero, + from, + to, + data: t => { + seen.push(t); + return {t}; + }, + }); + expect(film.frames.map(f => f.t)).toEqual(inside.map(f => f.t - zero)); + expect(film.frames.map(f => f.label)).toEqual( + inside.map(f => `t=${(f.t - zero).toFixed(1)} ms`), + ); + expect(seen).toEqual(inside.map(f => f.t)); + // Every cell is a PNG, whatever the screencast delivered. + for (const frame of film.frames) { + expect([...frame.png.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]); + } + const files = await film.write(testInfo); + expect(files['sheet.png']).toMatch(/sheet\.png$/); + // A window with no presented frame in it films nothing. + const empty = await castStoryboard(page, presented, { + name: 'harness-self-test-cast-empty', + zero, + from: to + 1, + to: to + 2, + }); + expect(empty.frames).toEqual([]); + await expect(empty.write(testInfo)).rejects.toThrow(/no frames/); + }); }); diff --git a/internal/interaction-harness/src/index.ts b/internal/interaction-harness/src/index.ts index 537bfc4d4beca..7a45ff45dfe41 100644 --- a/internal/interaction-harness/src/index.ts +++ b/internal/interaction-harness/src/index.ts @@ -12,10 +12,13 @@ * SYNC: When a module is added, export it here and describe it in README.md. */ +export * from './busy'; export * from './curves'; +export * from './frames'; export * from './math'; export * from './output'; export * from './scripts'; export * from './story'; export * from './storyboard'; export * from './touch'; +export * from './trace'; diff --git a/internal/interaction-harness/src/story.ts b/internal/interaction-harness/src/story.ts index 598f75d852cfe..cb57d59b50be2 100644 --- a/internal/interaction-harness/src/story.ts +++ b/internal/interaction-harness/src/story.ts @@ -6,7 +6,8 @@ * static server over a built Storybook * @output `phone` (the touch-phone browser context every interaction proof * runs in), `withStorybook` (serve the built Storybook for a spec file), and - * `openStory` (load one story and wait until it has rendered). + * `openStory` (load one story, with Storybook's highlight module off, and + * wait until it has rendered). * @position Stories are the reproduction path, as for every other browser * check in this repository: a scenario used for verification is checked in, * not built in a one-off page. @@ -59,13 +60,47 @@ export function withStorybook(test: TestType): () => string { }; } -/** Loads a story in the page and waits for its first render, its fonts and two frames. */ +/** Pages whose Storybook highlight module `openStory` turned off. */ +const highlightOff = new WeakSet(); + +export interface OpenStoryOptions { + /** + * Keeps Storybook's highlight module on. It is off by default: when on, it + * observes `#storybook-root` and, on every insertion, reads the computed + * style of every element on the page inside the inserting task, so a proof + * would measure Storybook as well as the component. + */ + readonly storybookHighlight?: boolean; +} + +/** + * Loads a story in the page and waits for its first render, its fonts and two + * frames. Storybook's highlight module is off (see `OpenStoryOptions`), and + * that is checked: its root element must be absent once the story renders. + */ export async function openStory( page: Page, origin: string, storyId: string, args?: Readonly>, + {storybookHighlight = false}: OpenStoryOptions = {}, ): Promise { + if (storybookHighlight) { + if (highlightOff.has(page)) { + throw new Error( + "openStory: this page already turned Storybook's highlight module off; open the story in a new page to keep it on", + ); + } + } else if (!highlightOff.has(page)) { + highlightOff.add(page); + // Storybook's highlight module returns at once when this is set before + // its preview runs. + await page.addInitScript(() => { + ( + globalThis as unknown as {__STORYBOOK_HIGHLIGHT_INITIALIZED?: boolean} + ).__STORYBOOK_HIGHLIGHT_INITIALIZED = true; + }); + } const params = new URLSearchParams({id: storyId, viewMode: 'story'}); if (args != null) { params.set( @@ -86,6 +121,14 @@ export async function openStory( requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); }); + if ( + !storybookHighlight && + (await page.locator('#storybook-highlights-root').count()) > 0 + ) { + throw new Error( + "openStory: Storybook's highlight module is on (#storybook-highlights-root exists), so every insertion would also read the style of every element on the page; this Storybook may no longer read __STORYBOOK_HIGHLIGHT_INITIALIZED", + ); + } } /** Resolves after `count` animation frames have been presented in the page. */ @@ -110,8 +153,9 @@ export async function frames(page: Page, count = 2): Promise { // A held callback is queued under its timer or frame id, so the page's own // clearTimeout and cancelAnimationFrame still cancel it while frozen; a held -// frame callback gets the time it actually runs at. `frame` stays the real -// requestAnimationFrame, for the harness's own waits. +// frame callback gets the time it actually runs at. `frame` and `timeout` +// stay the real requestAnimationFrame and setTimeout, for the harness's own +// waits. const TIMER_GATE = `(() => { if (window.__harnessTimerGate) return; const realTimeout = window.setTimeout.bind(window); @@ -119,7 +163,7 @@ const TIMER_GATE = `(() => { const realInterval = window.setInterval.bind(window); const realFrame = window.requestAnimationFrame.bind(window); const realCancelFrame = window.cancelAnimationFrame.bind(window); - const gate = {frozen: false, queue: [], frame: realFrame}; + const gate = {frozen: false, queue: [], frame: realFrame, timeout: realTimeout}; window.__harnessTimerGate = gate; const drop = key => { gate.queue = gate.queue.filter(entry => entry.key !== key); }; window.setTimeout = (fn, ms, ...args) => { diff --git a/internal/interaction-harness/src/storyboard.ts b/internal/interaction-harness/src/storyboard.ts index 2dc22fdc8cf04..4a9cbec360bb4 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,64 @@ 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'; + }[], + options: StoryboardOptions & { + 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..43095115bec91 --- /dev/null +++ b/internal/interaction-harness/src/trace.ts @@ -0,0 +1,365 @@ +// 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. + */ +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}, + }; +} + +/** + * 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`. + */ +export async function startCompositorTrace( + page: Page, +): Promise<{stop(): Promise}> { + let cdp: CDPSession; + try { + cdp = await page.context().newCDPSession(page); + } catch { + return {stop: async () => []}; + } + 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(() => {}); + 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 || typeof PerformanceObserver === 'undefined') return; + const tasks = []; + window.__harnessLongTasks = tasks; + 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}); + } 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. */ +export async function readLongTasks( + page: Page, + range?: {from: number; to: number}, +): Promise { + const tasks = await page.evaluate( + () => + (window as unknown as {__harnessLongTasks?: LongTask[]}) + .__harnessLongTasks ?? [], + ); + return range == null + ? tasks + : tasks.filter( + t => t.start < range.to && t.start + t.durationMs > range.from, + ); +} diff --git a/packages/build/__fixtures__/stylex-consts/App.tsx b/packages/build/__fixtures__/stylex-consts/App.tsx new file mode 100644 index 0000000000000..5e839e5372912 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/App.tsx @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Reads `defineConsts` values from other files, as a media-query key and as a + * plain value, from both sides of the layer split and from a file compiled + * with the library class-name prefix. Only the constants are + * imported: once StyleX compiles `stylex.create`, nothing in this file uses + * the imports at runtime any more. + */ + +import * as stylex from '@stylexjs/stylex'; +import {appConsts} from './appConsts.stylex'; +import {libraryConsts} from './library/libraryConsts.stylex'; +import {packageConsts} from './packages/core/packageConsts.stylex'; + +const styles = stylex.create({ + row: { + minHeight: appConsts.rowHeight, + transitionProperty: {default: 'color', [appConsts.narrow]: 'none'}, + }, + panel: { + paddingTop: libraryConsts.inset, + paddingBottom: packageConsts.gap, + maxWidth: {default: null, [libraryConsts.wide]: '640px'}, + }, +}); + +export function App() { + return ( +
+
row
+
+ ); +} diff --git a/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts new file mode 100644 index 0000000000000..5e41328b69880 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/appConsts.stylex.ts @@ -0,0 +1,9 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** Constants the app defines itself, beside the file that reads them. */ +export const appConsts = stylex.defineConsts({ + narrow: '@media (max-width: 599px)', + rowHeight: '52px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/index.html b/packages/build/__fixtures__/stylex-consts/index.html new file mode 100644 index 0000000000000..ba86fc975ff35 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/index.html @@ -0,0 +1,12 @@ + + + + + + stylex-consts fixture — defineConsts imported across files + + +
+ + + diff --git a/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts new file mode 100644 index 0000000000000..92dda6955e61f --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/library/libraryConsts.stylex.ts @@ -0,0 +1,12 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants on the library side of the layer split (the build test points + * `libraryPattern` at this folder), read by an app file on the product side. + */ +export const libraryConsts = stylex.defineConsts({ + wide: '@media (min-width: 900px)', + inset: '13px', +}); diff --git a/packages/build/__fixtures__/stylex-consts/main.tsx b/packages/build/__fixtures__/stylex-consts/main.tsx new file mode 100644 index 0000000000000..5811fe017bb3e --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/main.tsx @@ -0,0 +1,6 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {createRoot} from 'react-dom/client'; +import {App} from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts new file mode 100644 index 0000000000000..b4be85d7a3505 --- /dev/null +++ b/packages/build/__fixtures__/stylex-consts/packages/core/packageConsts.stylex.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Constants as an Astryx package defines them: the path holds `packages/core/`, + * so the babel wrapper compiles this file with the library class-name prefix + * (`astryx`), as it does a real package. The app file reading them compiles + * with its own prefix (`x`). + */ +export const packageConsts = stylex.defineConsts({ + gap: '17px', +}); diff --git a/packages/build/src/vite.build.test.ts b/packages/build/src/vite.build.test.ts index ee860275a2752..b0907675792c1 100644 --- a/packages/build/src/vite.build.test.ts +++ b/packages/build/src/vite.build.test.ts @@ -31,7 +31,7 @@ */ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {build} from 'vite'; +import {build, createLogger} from 'vite'; import react from '@vitejs/plugin-react'; import { mkdirSync, @@ -70,6 +70,21 @@ type Built = { async function buildFixture( name: string, htmlNames: string[] = ['index.html'], + { + libraryPattern = 'packages/core/', + zeroConfig = false, + stylexOverrides, + warnings = [], + }: { + /** Which source paths land in the library layer. */ + libraryPattern?: string; + /** Use `astryxStylex({...})` instead of the legacy `{stylexOptions}` form. */ + zeroConfig?: boolean; + /** Zero-config form only: `stylexOverrides`. */ + stylexOverrides?: Record; + /** Receives every warning the build logs. */ + warnings?: string[]; + } = {}, ): Promise { const root = path.join(FIXTURES, name); const outDir = mkdtempSync(path.join(tmpdir(), `astryx-${name}-`)); @@ -77,6 +92,11 @@ async function buildFixture( await build({ root, logLevel: 'error', + customLogger: { + ...createLogger('error'), + warn: (message: string) => void warnings.push(message), + warnOnce: (message: string) => void warnings.push(message), + }, build: { outDir, emptyOutDir: true, @@ -94,17 +114,27 @@ async function buildFixture( resolve: {alias: {'@astryxdesign/core': CORE_SRC}}, plugins: [ react(), - ...astryxStylex({ - stylexOptions: { - dev: false, - unstable_moduleResolution: {type: 'commonJS', rootDir: REPO_ROOT}, - aliases: { - '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], - '@astryxdesign/core': [CORE_SRC], - }, - }, - libraryPattern: 'packages/core/', - }), + ...(zeroConfig + ? astryxStylex({ + dev: false, + rootDir: REPO_ROOT, + libraryPattern, + stylexOverrides, + }) + : astryxStylex({ + stylexOptions: { + dev: false, + unstable_moduleResolution: { + type: 'commonJS', + rootDir: REPO_ROOT, + }, + aliases: { + '@astryxdesign/core/*': [path.join(CORE_SRC, '*')], + '@astryxdesign/core': [CORE_SRC], + }, + }, + libraryPattern, + })), ], }); } catch (error) { @@ -338,6 +368,67 @@ describe('a production build separates Astryx and product styles by layer', () = }); }); +// `defineConsts` values are compiled into the reading file as placeholders, +// `var(--)`, and the defining file contributes the values; the +// final CSS pass substitutes one for the other. Both halves must reach that +// pass. Storybook builds through the legacy `{stylexOptions}` form, whose +// StyleX instance ran without `treeshakeCompensation`: once `stylex.create` +// was compiled, nothing used the import at runtime, the TypeScript transform +// dropped it, and the defining module was never loaded. A media-query key was +// left as `var(--x…){…}` and failed the build ("Invalid empty selector"); a +// value shipped as a custom property nothing defines. And a constant defined +// on one side of the layer split and read on the other was substituted only +// within its own side, and one an Astryx package defines (compiled with the +// library class-name prefix) never matched an app file's placeholder (written +// with the product prefix). +describe('a production build resolves defineConsts imported across files', () => { + /** A constant reference StyleX left unsubstituted. */ + const PLACEHOLDER = /var\(--(?:astryx|x)[a-z0-9]{4,}\)/; + + for (const zeroConfig of [false, true]) { + describe( + zeroConfig ? 'zero-config form' : 'legacy stylexOptions form (Storybook)', + () => { + let built: Built; + beforeAll(async () => { + built = await buildFixture('stylex-consts', ['index.html'], { + libraryPattern: '__fixtures__/stylex-consts/library/', + zeroConfig, + }); + }, 180_000); + afterAll( + () => built && rmSync(built.outDir, {recursive: true, force: true}), + ); + + it('substitutes a constant read as a value', () => { + expect(built.css).toMatch(/min-height:\s*52px/); + }); + + it('substitutes a constant read as a media-query key', () => { + expect(built.css).toMatch( + /@media \((?:max-width: ?599px|width ?<= ?599px)\)\s*\{[^}]*transition-property:\s*none/, + ); + }); + + it('substitutes a library-side constant read from the product side', () => { + expect(built.css).toMatch(/padding-top:\s*13px/); + expect(built.css).toMatch( + /@media \((?:min-width: ?900px|width ?>= ?900px)\)\s*\{[^}]*max-width:\s*640px/, + ); + }); + + it('substitutes a constant an Astryx package defines under the library prefix', () => { + expect(built.css).toMatch(/padding-bottom:\s*17px/); + }); + + it('leaves no constant placeholder behind', () => { + expect(built.css).not.toMatch(PLACEHOLDER); + }); + }, + ); + } +}); + describe('a production build validates StyleX declarations', () => { it('fails with longhand guidance instead of dropping a border shorthand', async () => { await expect(buildFixture('invalid-border')).rejects.toThrow( diff --git a/packages/build/src/vite.ts b/packages/build/src/vite.ts index 0581b58675edb..edaa88ca55a1d 100644 --- a/packages/build/src/vite.ts +++ b/packages/build/src/vite.ts @@ -136,6 +136,12 @@ export function astryxStylex( const libraryLayer = layers.library ?? 'astryx-base'; const productLayer = layers.product ?? 'product'; + // Library files compile with `stylexPrefix`, product files with StyleX's + // `classNamePrefix` (the babel wrapper routes them). + const classNamePrefixes = [ + stylexPrefix, + (stylexOverrides as {classNamePrefix?: string}).classNamePrefix ?? 'x', + ]; const overrideLightningcssOptions = ( stylexOverrides as {lightningcssOptions?: Record} @@ -284,6 +290,7 @@ export function astryxStylex( libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -302,6 +309,7 @@ export function astryxStylex( libraryLayer, productLayer, lightningcssOptions: stylexOptions.lightningcssOptions, + classNamePrefixes, }), ]; } @@ -330,24 +338,43 @@ function renderSplitLayers( libraryPattern: string; libraryLayer: string; productLayer: string; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): string { if (!rulesById || rulesById.size === 0) return ''; const libraryRules: unknown[] = []; const productRules: unknown[] = []; + // `defineConsts` values. A file that reads a constant compiles it to a + // placeholder, `var(--)`, and `processStylexRules` substitutes + // the value from the defining file's rules, but only among the rules it is + // given. The defining file and the reading one can sit on opposite sides of + // the split, so every layer gets every constant, under every class-name + // prefix (see constantRulesForEveryPrefix). Constant rules emit no CSS of + // their own, so repeating them adds nothing to the output. + const collectedConstants: unknown[] = []; for (const [filePath, rules] of rulesById.entries()) { - if (filePath.includes(options.libraryPattern)) { - libraryRules.push(...rules); - } else { - productRules.push(...rules); + const own = filePath.includes(options.libraryPattern) + ? libraryRules + : productRules; + for (const rule of rules) { + if (isConstantRule(rule)) { + collectedConstants.push(rule); + } else { + own.push(rule); + } } } + const constantRules = constantRulesForEveryPrefix( + collectedConstants, + options.classNamePrefixes, + ); const render = (rules: unknown[], layer: string) => rules.length - ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules(rules as never, {useLayers: true})}\n}` + ? `@layer ${layer} {\n${stylexBabelPlugin.processStylexRules([...constantRules, ...rules] as never, {useLayers: true})}\n}` : ''; return [ @@ -358,6 +385,56 @@ function renderSplitLayers( .join('\n\n'); } +/** + * Each `defineConsts` rule once under every class-name prefix in play. + * + * StyleX keys a constant by its defining file's prefix, `` (or + * `-` with debug class names), where the hash depends on + * the export and key alone, and a file that reads it writes the placeholder + * with its own prefix. An Astryx package compiles with the library prefix and + * an app with the product one, so an app reading a package's constant writes + * `var(--x)` while the package's rule is keyed `astryx`; the + * copies make every prefix's placeholder resolve. A key StyleX took verbatim + * (`'--name'`) carries no prefix and is kept as it is. + */ +function constantRulesForEveryPrefix( + rules: readonly unknown[], + prefixes: readonly string[], +): unknown[] { + const ordered = [...new Set(prefixes)] + .filter(Boolean) + .sort((a, b) => b.length - a.length); + const out: unknown[] = []; + const keys = new Set(); + const add = (rule: unknown[], key: string) => { + if (keys.has(key)) return; + keys.add(key); + const [, body, ...rest] = rule; + out.push([key, {...(body as object), constKey: key}, ...rest]); + }; + for (const rule of rules as unknown[][]) { + const key = String((rule[1] as {constKey: unknown}).constKey); + add(rule, key); + for (const prefix of ordered) { + const match = new RegExp(`^(|.*-)${prefix}([0-9a-z]+)$`).exec(key); + if (match == null) continue; + for (const other of ordered) { + add(rule, `${match[1]}${other}${match[2]}`); + } + break; + } + } + return out; +} + +/** A `defineConsts` value as StyleX collects it: a rule with `constKey`/`constVal`. */ +function isConstantRule(rule: unknown): boolean { + const body = Array.isArray(rule) + ? (rule[1] as {constKey?: unknown; constVal?: unknown} | undefined) + : undefined; + return body?.constKey != null && body?.constVal != null; +} + /** * The build half of the split. StyleX appends one merged block of CSS to the * build's stylesheet, in its own top-level `@layer priority1…priorityN` — which @@ -387,6 +464,8 @@ function buildLayerSplitPlugin( libraryLayer: string; productLayer: string; lightningcssOptions?: unknown; + /** The StyleX class-name prefixes in play: library, then product. */ + classNamePrefixes: readonly string[]; }, ): Plugin { const stylex = basePlugin as { @@ -586,12 +665,25 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { ).lightningcssOptions; const validatedStylexOptions = { propertyValidationMode: 'throw', + // The unplugin applies its Vite default (true) only to its own StyleX + // instance, which runs after the babel wrapper below has already compiled + // every StyleX call. Without it here, a file that reads only `defineConsts` + // from a `.stylex.ts` module loses that import once `stylex.create` is + // compiled (the TypeScript transform drops it as unused), the module is + // never loaded, and its constants never reach the CSS: a media-query key + // fails the build and a value ships as an undefined custom property. + treeshakeCompensation: true, ...(stylexOptions as any), lightningcssOptions: { targets: LIGHTNINGCSS_TARGETS, ...legacyLightningcssOptions, }, }; + const classNamePrefixes = [ + stylexPrefix, + (validatedStylexOptions as {classNamePrefix?: string}).classNamePrefix ?? + 'x', + ]; const astryxBabelPlugin = path.resolve(__dirname, 'babel.js'); const existingPlugins = validatedStylexOptions.babelConfig?.plugins ?? []; @@ -658,6 +750,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { libraryPattern, libraryLayer, productLayer, + classNamePrefixes, }), ); }, @@ -677,6 +770,7 @@ function astryxStylexLegacy(options: AstryxVitePluginLegacyOptions): Plugin[] { lightningcssOptions: ( validatedStylexOptions as {lightningcssOptions?: unknown} ).lightningcssOptions, + classNamePrefixes, }), ]; } diff --git a/packages/core/package.json b/packages/core/package.json index 462f6f61adfa7..cf0824f3b9ca2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -854,6 +854,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..c78902bf0303f 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,14 @@ 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 finger gets the iOS alert width, 320, unless presentation is 'dialog'.", + }, + { + name: 'presentation', + type: "'adaptive' | 'dialog'", + default: "'adaptive'", + description: + "How the alert presents under a finger. 'adaptive' gives a touch screen the native alert: it appears and leaves the way an iOS alert does, 320 wide, and a finger is tracked across both actions as one group and runs the action it lifts on. 'dialog' keeps the dialog a mouse gets on every device: the theme's motion, 400 wide, and each button's own press and click.", }, { name: 'isInline', @@ -196,7 +203,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.test.tsx b/packages/core/src/AlertDialog/AlertDialog.test.tsx index 1c88d6a6d02ef..4be4403ac2265 100644 --- a/packages/core/src/AlertDialog/AlertDialog.test.tsx +++ b/packages/core/src/AlertDialog/AlertDialog.test.tsx @@ -11,6 +11,8 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import { + act, + cleanup, render, screen, fireEvent, @@ -18,9 +20,12 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import {StrictMode} from 'react'; +import {alertController} from '../motion/uikit/alert'; import {AlertDialog} from './AlertDialog'; import {useImperativeAlertDialog} from './useImperativeAlertDialog'; +/** Stubs the media queries AlertDialog reads. */ function stubAlertDialogMedia({ smallScreen, coarsePointer, @@ -168,8 +173,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 +235,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 +270,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 +442,278 @@ 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 touch 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; + } + + /** + * 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; + presentation?: 'adaptive' | 'dialog'; + } = {}, + ) { + const isTouch = props.touch ?? true; + stubAlertDialogMedia({ + 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. Lets + * the animator report its start (a promise), then moves the clock past the + * touch window. + */ + async function completePresentation(): Promise { + const presentedAt = performance.now(); + const clock = vi.spyOn(performance, 'now').mockReturnValue(presentedAt); + await act(async () => {}); + clock.mockReturnValue( + presentedAt + alertController.present.ignoresTouchesForMs, + ); + } + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('marks both actions for the tracking and takes them off the document press controller', () => { + const {cancel, action} = renderOpen(); + for (const button of [cancel, action]) { + expect(button).toHaveAttribute('data-astryx-alert-action'); + expect(button).not.toHaveAttribute('data-astryx-pressable'); + } + }); + + it('keeps 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).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('with presentation="dialog" under a finger keeps the theme motion and Dialog\'s 400: a click acts at once and a close is immediate', () => { + const {onOpenChange, cancel, close} = renderOpen({presentation: 'dialog'}); + // A coarse pointer, before anything is checked. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(true); + const dialog = screen.getByRole('alertdialog'); + expect(dialog.getAttribute('style')).toContain('--x-width: 400px'); + // 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 UIKit's alert width under a finger and Dialog's 400 with a mouse, unless the caller sets one", () => { + const widthOf = () => screen.getByRole('alertdialog').getAttribute('style'); + renderOpen(); + expect(widthOf()).toContain('--x-width: 320px'); + 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, runs nothing: a click or Enter on an action, Cancel, or Escape', async () => { + const {onOpenChange, onAction, cancel, action, close} = renderOpen(); + await completePresentation(); + const dialog = screen.getByRole('alertdialog'); + close(); + // Leaving: still open while it fades out. + expect(dialog).toHaveAttribute('open'); + fireEvent.click(action); + fireEvent.click(cancel); + 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('keeps each button its own press under a finger with presentation="dialog": no group, and a slide from the action to Cancel cancels nothing', () => { + const {onOpenChange, onAction, cancel, action} = renderOpen({ + presentation: 'dialog', + }); + // The document press controller keeps each button, as with a mouse. + for (const button of [cancel, action]) { + expect(button).toHaveAttribute('data-astryx-pressable'); + } + const id = touchId++; + touch('touchstart', action, {x: 60, y: 116}, id); + touch('touchmove', action, {x: 60, y: 156}, id); + expect(cancel).not.toHaveAttribute('data-astryx-press'); + touch('touchend', action, {x: 60, y: 156}, id); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + }); + + it('does not track a touch that starts on the backdrop', async () => { + const {onOpenChange, onAction, cancel} = renderOpen(); + await completePresentation(); + const dialog = screen.getByRole('alertdialog'); + const id = touchId++; + // A touch on ::backdrop targets the dialog element itself. + touch('touchstart', dialog, {x: 60, y: 400}, id); + touch('touchmove', dialog, {x: 60, y: 156}, id); + expect(cancel).not.toHaveAttribute('data-astryx-press'); + touch('touchend', dialog, {x: 60, y: 156}, id); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + }); +}); + describe('useImperativeAlertDialog', () => { const noop = () => {}; diff --git a/packages/core/src/AlertDialog/AlertDialog.tsx b/packages/core/src/AlertDialog/AlertDialog.tsx index 838e3b6fb11b4..ee1cbb770cedf 100644 --- a/packages/core/src/AlertDialog/AlertDialog.tsx +++ b/packages/core/src/AlertDialog/AlertDialog.tsx @@ -4,19 +4,41 @@ /** * @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, 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 * + * Under a finger or a pen the two actions are tracked as one group, as UIKit tracks an + * alert's actions: the action under the finger highlights at once, the + * highlight follows the finger from one action to the other, and lifting runs + * the action under the finger (none when it is off both). Tracking spans the + * whole surface, so a touch that lands on the message can still slide onto an + * action. A mouse, 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/AlertDialogTouch.ts (the touch layer) * - /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 +53,40 @@ import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; import {useTranslator} from '../i18n'; import {useMediaQuery} from '../hooks'; +import {useMergedRefs} from '../hooks/useMergedRefs'; +import {alertController} from '../motion/uikit/alert'; +import {useTouchPresentation} from '../utils/touchPresentation'; +import {useAlertDialogTouch} from './AlertDialogTouch'; const SMALL_SCREEN_QUERY = '(max-width: 640px)'; +/** Marks the actions the touch tracking hit-tests. */ +const ACTION_ATTRIBUTE = 'data-astryx-alert-action'; + const styles = stylex.create({ + // The action tracking claims a touch on the surface 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 + // touch), and a double tap zooms nothing. A long alert's content keeps its + // pan (see `isBodyScrollable`). The group's hold does something, so a held + // finger or pen starts no text selection and opens no callout there + // (`spec:AST-094` FR41; useActionGroupTracking); a mouse keeps selecting + // text, which is why the host's permanent long-press styles stay off + // (`manageStyle: false`). + trackingSurface: { + touchAction: 'pinch-zoom', + }, + // 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, @@ -44,6 +96,12 @@ const styles = stylex.create({ }, }); +/** + * How AlertDialog presents under a finger: `'adaptive'` follows the device, + * `'dialog'` keeps the dialog a mouse gets on every device. + */ +export type AlertDialogPresentation = 'adaptive' | 'dialog'; + export interface AlertDialogProps extends BaseProps { ref?: React.Ref; /** @@ -106,10 +164,22 @@ 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 under a finger with `presentation="adaptive"` UIKit's alert width, + * 320. * @default 400 */ width?: number | string; + /** + * How the alert presents under a finger. `'adaptive'` gives a touch screen + * the native alert: it appears and leaves the way an iOS alert does, 320 + * wide, and a finger is tracked across both actions as one group and runs + * the action it lifts on. `'dialog'` keeps the dialog a mouse gets on every + * device: the theme's motion, 400 wide, and each button's own press and + * click. + * @default 'adaptive' + */ + presentation?: AlertDialogPresentation; } /** @@ -150,7 +220,8 @@ export function AlertDialog({ actionVariant = 'destructive', isActionLoading, onAction, - width = 400, + width: widthProp, + presentation = 'adaptive', xstyle, className, style, @@ -166,9 +237,66 @@ export function AlertDialog({ // semantics without coupling action order to pointer or hover capability. const isSmallScreen = useMediaQuery(SMALL_SCREEN_QUERY); + // A finger is tracked across both actions as one group (UIKit's alert + // tracking, in the touch layer); the backdrop, where a touch targets the + // dialog itself, is not part of it. Each action opts out of its own press + // tracking while the group owns its touches, and runs through its own click + // at the lift. Under a finger the alert also appears and leaves the way + // UIKit's does (the touch layer's useAlertMotion): it stays presented while + // it fades out, and touches during either transition are taken and dropped. + // Only under a touch presentation (a coarse primary pointer, after + // hydration): where the primary pointer is fine nothing attaches, and a + // finger keeps the browser's click (`spec:AST-094` FR13, FR19). A caller's + // `presentation="dialog"` keeps the fine-pointer dialog on every device + // (`architecture:public-component-api` INV16). + const touch = useTouchPresentation(); + const isTouch = presentation !== 'dialog' && touch.isTouch; + // UIKit's alert width under a finger (alertController.width), so the + // presentation's 1.2 start stays on screen; with a mouse Dialog's 400. + const width = widthProp ?? (isTouch ? alertController.width : 400); + const surfaceRef = useRef(null); + const bodyRef = useRef(null); + const {isTracking, isBodyScrollable, motion} = useAlertDialogTouch( + surfaceRef, + { + isOpen, + isInline: isInline === true, + isTouch, + itemSelector: `[${ACTION_ATTRIBUTE}]`, + bodyRef, + }, + ); + // While the alert leaves (closed by the caller, still presented while it + // fades out under a finger), UIKit delivers no input to it: a click, Enter + // or an assistive-technology activation on an action, and Escape, do + // nothing until it is hidden. Focus stays where it is, so Dialog returns it + // to the opener when the fade ends. + const isLeaving = motion.isDriving && motion.isPresented && !isOpen; const handleCancel = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); + if (!isLeaving) { + onOpenChange(false); + } + }, [isLeaving, onOpenChange]); + const handleAction = useCallback(() => { + if (!isLeaving) { + void onAction(); + } + }, [isLeaving, onAction]); + const handleOpenChange = useCallback( + (next: boolean) => { + if (!isLeaving) { + onOpenChange(next); + } + }, + [isLeaving, onOpenChange], + ); + const mergedRef = useMergedRefs(ref, surfaceRef, motion.ref); + const actionTrackingProps = { + [ACTION_ATTRIBUTE]: '', + // Overrides Button's own marker for the document press controller, which + // would otherwise paint (and un-paint) the same attribute on its own clock. + 'data-astryx-pressable': isTracking ? undefined : '', + }; const buttonActionStyle = [ styles.action, @@ -181,6 +309,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 +322,20 @@ export function AlertDialog({ key="action" variant={actionVariant} label={actionLabel} - onClick={onAction} + onClick={handleAction} isLoading={isActionLoading} xstyle={buttonActionStyle} + {...actionTrackingProps} /> ); return ( + {title} diff --git a/packages/core/src/AlertDialog/AlertDialogTouch.ts b/packages/core/src/AlertDialog/AlertDialogTouch.ts new file mode 100644 index 0000000000000..ffed795c59f49 --- /dev/null +++ b/packages/core/src/AlertDialog/AlertDialogTouch.ts @@ -0,0 +1,91 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file AlertDialogTouch.ts + * @input useActionGroupTracking, useAlertMotion and the alert entries of the + * UIKit constants table (motion/uikit/alert.ts) + * @output useAlertDialogTouch: everything AlertDialog does only under a + * finger or a pen + * @position AlertDialog's touch layer, imported once, by AlertDialog.tsx + * + * Under a touch presentation (a coarse primary pointer, after hydration; + * where it is fine nothing attaches: `spec:AST-094` FR13, FR19), a finger or + * a pen tracks the two actions as one group, the way + * UIKit tracks an alert's actions: the touched action highlights in the + * touch-down frame, the highlight follows the finger from one action to the + * other, and the lift runs the action under it (none off both). A touch that + * lands on the message can slide onto an action; the backdrop, where a touch + * targets the dialog itself, is not part of the group. Under a finger the + * alert also appears and leaves the way UIKit's alert does (useAlertMotion), + * and a touch during either transition reaches nothing. A mouse, the + * keyboard and assistive technology keep each button's own click and the + * theme's motion, so a build for pointers alone can replace this module + * with one that does nothing. + * + * SYNC: When modified, update AlertDialog.tsx and AlertDialog.test.tsx + */ + +import type {RefObject} from 'react'; +import {useActionGroupTracking} from '../hooks/useActionGroupTracking'; +import {useIsScrollable} from '../hooks/useIsScrollable'; +import {alertController} from '../motion/uikit/alert'; +import {useAlertMotion, type AlertMotion} from './useAlertMotion'; + +export interface AlertDialogTouchOptions { + /** The caller's open state. */ + isOpen: boolean; + /** The inline preview: nothing is tracked and nothing moves. */ + isInline: boolean; + /** + * The touch presentation (a coarse primary pointer, after hydration): + * UIKit's motion and the action-group tracking; without it nothing + * attaches (`spec:AST-094` FR13, FR19). + */ + isTouch: boolean; + /** Selects the actions the tracking hit-tests. */ + itemSelector: string; + /** The content's scroller (the title and the message). */ + bodyRef: RefObject; +} + +export interface AlertDialogTouch { + /** The actions are tracked as one group: each opts out of its own press. */ + isTracking: boolean; + /** + * The content overflows its scroller (a long alert): a finger pans it + * there, and the press yields to that pan. Anywhere else on the surface + * the browser may not pan (it would cancel the touch the group holds). + */ + isBodyScrollable: boolean; + /** The alert's presentation under a finger (useAlertMotion). */ + motion: AlertMotion; +} + +/** AlertDialog's touch layer: the action-group tracking and the motion. */ +export function useAlertDialogTouch( + surfaceRef: RefObject, + {isOpen, isInline, isTouch, itemSelector, bodyRef}: AlertDialogTouchOptions, +): AlertDialogTouch { + const isDriving = isTouch && !isInline; + const motion = useAlertMotion({isOpen, isEnabled: isDriving}); + // Tracked while presented under a touch presentation, leaving included: a + // touch then is taken and dropped (isInert) rather than reaching the button + // under it. + const isTracking = isDriving && motion.isPresented; + const isBodyScrollable = useIsScrollable(bodyRef, isTracking); + useActionGroupTracking(surfaceRef, { + itemSelector, + highlightOnsetMs: alertController.actions.highlightOnsetMs, + highlightExpansion: alertController.actions.highlightExpansion, + shouldTrack: target => target !== surfaceRef.current, + isInert: () => !motion.acceptsTouches(), + isEnabled: isTracking, + // A mouse keeps selecting the alert's text: the host's permanent + // long-press styles stay off. A held finger selects nothing, since the + // group's hold does something (`spec:AST-094` FR41). + manageStyle: false, + }); + return {isTracking, isBodyScrollable, motion}; +} diff --git a/packages/core/src/AlertDialog/__tests__/AlertDialog.interaction.browser.spec.ts b/packages/core/src/AlertDialog/__tests__/AlertDialog.interaction.browser.spec.ts new file mode 100644 index 0000000000000..4f2a8af3861fb --- /dev/null +++ b/packages/core/src/AlertDialog/__tests__/AlertDialog.interaction.browser.spec.ts @@ -0,0 +1,426 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file AlertDialog.interaction.browser.spec.ts + * @input The built "Delete" AlertDialog story on the phone profile, real + * touch input (Chromium CDP / WebKit's input command) and the interaction + * harness + * @output Interaction proofs for a finger on the alert's actions, which UIKit + * tracks as one group (iOS 26.2): the touched action is highlighted in the + * frame of the touch; the highlight follows the finger from one action to + * the other; lifting runs the action under the finger, once, and nothing + * off both; a touch that lands on the message can slide onto an action; a + * tap and a mouse click still run their action once; a held finger starts + * no text selection; the tracking is the same under a finger in Chrome's + * engine; with `presentation="dialog"` a finger keeps each button's own + * press, so the slide from Delete to Cancel runs no Cancel; a storyboard of + * the slide from Delete to Cancel. + * @position Touch lane of AlertDialog, beside the accessibility bindings in + * this folder. Run with `pnpm test:interaction AlertDialog`. + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + drag, + openStory, + tap, + touchDriver, + touchStoryboard, + waitForRest, + withStorybook, + type Point, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +interface OpenAlert { + driver: TouchDriver; + dialog: Locator; + cancel: Locator; + del: Locator; + centers: {cancel: Point; del: Point; message: Point}; + /** Bottom edge of the alert surface, CSS px. */ + bottom: number; +} + +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}; +}; + +/** + * 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 held finger starts no text selection: the surface is locked while it is down, and selectable again after', async ({ + page, +}) => { + const {driver, dialog, centers, bottom} = await openAlert(page); + const userSelect = async () => + dialog.evaluate(element => { + const style = getComputedStyle(element); + return ( + style.getPropertyValue('user-select') || + style.getPropertyValue('-webkit-user-select') + ); + }); + expect(await userSelect()).not.toBe('none'); + 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()).toBe('none'); + }, + }); + await expect(dialog).toBeVisible(); + expect(await userSelect()).not.toBe('none'); +}); + +test("no pan can take the group's touch: the surface and a short alert's content allow only pinch-zoom, and a long alert's content 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 touch, the + // browser may pinch-zoom but not pan. + const {dialog} = await openAlert(page); + const message = dialog.getByText('This action cannot be undone', { + exact: false, + }); + const touchAction = async (locator: Locator) => + locator.evaluate(element => getComputedStyle(element).touchAction); + expect(await touchAction(dialog)).toBe('pinch-zoom'); + // The content's scroller (the message's parent), which fits: no pan. + expect(await touchAction(message.locator('..'))).toBe('pinch-zoom'); + // The long story overflows a phone in landscape. + await page.setViewportSize({width: 874, height: 402}); + await openStory(page, storybook(), 'core-alertdialog--long-content'); + const driver = await touchDriver(page); + await driver.tap( + await centerOf(page.getByRole('button', {name: 'Delete workspace'})), + ); + const long = page.getByRole('alertdialog'); + await expect(long).toBeVisible(); + await waitForRest(long); + const scroller = long.getByRole('heading').locator('..'); + expect( + await scroller.evaluate( + element => element.scrollHeight > element.clientHeight, + ), + ).toBe(true); + expect(await touchAction(scroller)).toBe('auto'); + expect(await touchAction(long)).toBe('pinch-zoom'); +}); + +test('a touch that lands on the message can slide onto Cancel and run it', async ({ + page, +}) => { + const {driver, dialog, centers} = await openAlert(page); + await driver.play(slide(centers.message, centers.cancel)); + await expect(dialog).toBeHidden(); + expect((await readLog(page)).clicks).toEqual(['Cancel']); +}); + +test('with presentation="dialog" a finger keeps each button\'s own press: no group tracking, so the slide from Delete to Cancel runs no Cancel', async ({ + page, +}) => { + const {driver, dialog, centers} = await openAlert( + page, + 'core-alertdialog--dialog-presentation', + ); + // The dialog a mouse gets: its surface keeps the browser's pan. + expect(await dialog.evaluate(el => getComputedStyle(el).touchAction)).toBe( + 'auto', + ); + await driver.play(slide(centers.del, centers.cancel)); + await page.waitForTimeout(300); + expect((await readLog(page)).clicks).not.toContain('Cancel'); +}); + +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('the dialog keeps its base styles: its text stays selectable', async ({ + page, + }) => { + // A held finger's suppressed selection (`spec:AST-094` FR41) is a + // finger's or a pen's, never a mouse's: with a mouse the dialog keeps what + // it had before. + 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 is still the browser click, as before', async ({ + page, +}) => { + const {dialog, centers} = await openAlert(page); + // A mouse on a touch screen: the primary pointer is coarse, so the group + // tracks touches here, and a mouse's click is left to the browser. + 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. + const cancel = page.locator('[data-astryx-alert-action]', { + hasText: 'Cancel', + }); + const del = page.locator('[data-astryx-alert-action]', { + 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..199f23641b4ca --- /dev/null +++ b/packages/core/src/AlertDialog/__tests__/AlertDialog.motion.interaction.browser.spec.ts @@ -0,0 +1,1080 @@ +// 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, and so does a finger with + * `presentation="dialog"` (Dialog's 400 width too); 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, + freezeTimers, + installTimerGate, + openStory, + 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); + 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 = {start: Number(start), touches: [] as number[]}; + (window as unknown as {__presentLog: typeof log}).__presentLog = log; + document.addEventListener( + 'touchstart', + event => log.touches.push(event.timeStamp), + true, + ); + }); + 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 tap while the alert leaves reaches nothing', async ({page}) => { + const {driver, trigger, dialog} = await openPage(page); + await driver.tap(await centerOf(trigger)); + await waitForRest(dialog); + const del = dialog.getByRole('button', {name: 'Delete'}); + const deletePoint = await centerOf(del); + await page.evaluate(() => { + const clicks: string[] = []; + (window as unknown as {__leaveClicks: string[]}).__leaveClicks = clicks; + document.addEventListener( + 'click', + event => { + const button = (event.target as Element).closest('button'); + if (button != null) { + clicks.push(button.textContent ?? ''); + } + }, + true, + ); + }); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + // A second tap on Delete while the alert fades out. + await driver.tap(deletePoint); + await expect(dialog).toBeHidden(); + const clicks = await page.evaluate( + () => (window as unknown as {__leaveClicks: string[]}).__leaveClicks, + ); + expect(clicks).toEqual(['Cancel']); + }); + + 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); + }, + ); +}); + +test.describe('under a finger with presentation="dialog"', () => { + test("keeps the dialog a mouse gets: the theme entrance, Dialog's 400 width, and a close at once", async ({ + page, + }) => { + await openStory(page, storybook(), 'core-alertdialog--dialog-presentation'); + const driver = await touchDriver(page); + const trigger = page.getByRole('button', {name: 'Delete item'}); + const dialog = page.getByRole('alertdialog', {name: 'Delete item?'}); + // A coarse pointer in this context, before anything is checked. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + // Recorded as it starts, as with a mouse: a short entrance can end before + // a poll after the tap first reads the page. + 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 driver.tap(await centerOf(trigger)); + await expect(dialog).toBeVisible(); + await waitForRest(dialog); + // The theme's CSS entrance ran on the alert, not UIKit's motion. + expect( + await page.evaluate( + () => (window as unknown as {__entrance: number}).__entrance, + ), + ).toBeGreaterThan(0); + expect(await dialog.getAttribute('style')).toContain('--x-width: 400px'); + const element = await dialog.elementHandle(); + await driver.tap( + await centerOf(dialog.getByRole('button', {name: 'Cancel'})), + ); + // No exit: the dialog is closed by the next frame. + await expect.poll(async () => openOnNextFrame(element)).toBe(false); + }); +}); + +/** + * 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/index.ts b/packages/core/src/AlertDialog/index.ts index 0d05d03544708..e8a9b0477af90 100644 --- a/packages/core/src/AlertDialog/index.ts +++ b/packages/core/src/AlertDialog/index.ts @@ -3,7 +3,7 @@ 'use client'; export {AlertDialog} from './AlertDialog'; -export type {AlertDialogProps} from './AlertDialog'; +export type {AlertDialogPresentation, AlertDialogProps} from './AlertDialog'; export {useImperativeAlertDialog} from './useImperativeAlertDialog'; export type {ImperativeAlertDialogReturn} from './useImperativeAlertDialog'; diff --git a/packages/core/src/AlertDialog/useAlertMotion.test.tsx b/packages/core/src/AlertDialog/useAlertMotion.test.tsx new file mode 100644 index 0000000000000..6631e3c301cca --- /dev/null +++ b/packages/core/src/AlertDialog/useAlertMotion.test.tsx @@ -0,0 +1,574 @@ +// 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 {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + settlingDuration, + toSpring, + type SpringDescription, +} from '../motion/spring'; +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 {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; +}; + +/** 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('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 () => { + 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(motion().acceptsTouches()).toBe(false); + await act(async () => { + panel.starts.at(-1)?.(1216); + }); + now = 1216 + alertController.present.ignoresTouchesForMs; + expect(motion().acceptsTouches()).toBe(true); + // The abandoned exit ending late does not hide the reopened alert. + await act(async () => { + exitEnding?.(true); + }); + expect(motion().isPresented).toBe(true); + }); + + it('refuses touches until the presentation completes, counted from its actual start, and while it leaves', async () => { + const view = render(); + const {panel} = animators(); + // The browser has not reported its start yet (a busy main thread can + // push it later): refused, however long that takes. + now = 9000; + expect(motion().acceptsTouches()).toBe(false); + await act(async () => { + panel.starts.at(-1)?.(6100); + }); + now = 6100 + alertController.present.ignoresTouchesForMs - 1; + expect(motion().acceptsTouches()).toBe(false); + now = 6100 + alertController.present.ignoresTouchesForMs; + expect(motion().acceptsTouches()).toBe(true); + view.rerender(); + expect(motion().acceptsTouches()).toBe(false); + }); + + 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(motion().acceptsTouches()).toBe(false); + // The cross-fade ends (its duration is the animator's): accepted. + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(motion().acceptsTouches()).toBe(true); + }); + + 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(motion().acceptsTouches()).toBe(false); + await act(async () => { + panel.starts.at(-1)?.(1000); + }); + now = 1000 + alertController.present.ignoresTouchesForMs + 50; + expect(motion().acceptsTouches()).toBe(false); + await act(async () => { + panel.endings.at(-1)?.(true); + }); + expect(motion().acceptsTouches()).toBe(true); + }); + + 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, + }); + 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); + now = 2000 + present + lag - 1; + expect(motion().acceptsTouches()).toBe(false); + now = 2000 + present + lag; + expect(motion().acceptsTouches()).toBe(true); + 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 () => { + render( +
+ +
, + ); + const {panel} = animators(); + expect(panel.calls.find(call => call.kind === 'animate')).toMatchObject({ + spring: ALERT_PRESENT_SPRING, + }); + await act(async () => { + panel.starts.at(-1)?.(2000); + }); + now = 2000 + alertController.present.ignoresTouchesForMs - 1; + expect(motion().acceptsTouches()).toBe(false); + now = 2000 + alertController.present.ignoresTouchesForMs; + expect(motion().acceptsTouches()).toBe(true); + }); + + 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(motion().acceptsTouches()).toBe(false); + }); + + 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(motion().acceptsTouches()).toBe(true); + }); + + 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..7fd0c13c20bc9 --- /dev/null +++ b/packages/core/src/AlertDialog/useAlertMotion.ts @@ -0,0 +1,322 @@ +// 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 and the alert entries of the UIKit constants table + * @output Exports useAlertMotion and AlertMotion: when the Dialog is + * presented (it stays open while the alert leaves), the callback ref that + * puts the animators on the dialog, and whether a touch may act right now + * @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, ALERT_PRESENT_SPRING and ALERT_DISMISS_SPRING): + * + * - 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); + * - touches during either transition are ignored: input comes back when the + * presentation completes (counted from when the browser started it, + * `handle.started`), and none is delivered while the alert leaves; + * - 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. Neither the springs nor the + * timings are motion roles (`spec:AST-094` FR4, DEC-3): they are read from the + * alert's tables, and no theme variable changes them. + * + * 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 {alertController} from '../motion/uikit/alert'; +import { + ALERT_DISMISS_SPRING, + ALERT_PRESENT_SPRING, +} from '../motion/uikit/springs'; + +/** 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(ALERT_PRESENT_SPRING) + 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; + /** Whether a touch landing now may act (false during the transitions). */ + readonly acceptsTouches: () => boolean; +} + +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'); + const transitionEndsAtRef = useRef(0); + // The exit in flight: only its own end may hide the dialog. + const exitRef = useRef(null); + // The presentation in flight: only its own start times the touch window. + const presentRef = useRef(null); + const acceptsTouches = useCallback( + () => + !isEnabledRef.current || + (statusRef.current === 'open' && + performance.now() >= transitionEndsAtRef.current), + [], + ); + + 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; + // Touches come back when the presentation completes: counted from when + // the browser actually started it, or, under reduced motion, when the + // animator's cross-fade ends (its duration is the animator's). + const armTouchWindow = (handle: MotionHandle) => { + const isCurrent = () => + presentRef.current === handle && statusRef.current === 'open'; + if (panel.reducedMotion() !== 'off') { + void handle.finished.then(() => { + if (isCurrent()) { + transitionEndsAtRef.current = performance.now(); + } + }); + return; + } + void handle.started.then(start => { + if (isCurrent()) { + transitionEndsAtRef.current = start + touchWindowMs(); + } + }); + }; + 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, so Mobile Safari runs it on the compositor from that + // first frame through a busy main thread (measured on iOS 26.2). + transitionEndsAtRef.current = Number.POSITIVE_INFINITY; + panel.set({opacity: 0, scale: presentScaleFrom(elementRef.current)}); + backdrop.set({opacity: 0}); + const handle = present(panel, backdrop); + presentRef.current = handle; + armTouchWindow(handle); + return; + } + presentRef.current = null; + 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; + transitionEndsAtRef.current = Number.POSITIVE_INFINITY; + const handle = fadeFromHere(panel, backdrop, 1, ALERT_PRESENT_SPRING); + presentRef.current = handle; + armTouchWindow(handle); + return; + } + if (now === 'leaving') { + transitionEndsAtRef.current = Number.POSITIVE_INFINITY; + const handle = fadeFromHere(panel, backdrop, 0, ALERT_DISMISS_SPRING); + 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; + }, [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(ALERT_DISMISS_SPRING) + EXIT_BACKSTOP_MS, + ); + return () => clearTimeout(backstop); + }, [isLeaving]); + + return {isPresented, isDriving: isEnabled, ref, acceptsTouches}; +} + +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 handle = panel.animate( + {opacity: 1, scale: 1}, + {spring: ALERT_PRESENT_SPRING}, + ); + backdrop.animate({opacity: 1}, {spring: ALERT_PRESENT_SPRING}); + return handle; +} diff --git a/packages/core/src/ContextMenu/ContextMenu.spec.md b/packages/core/src/ContextMenu/ContextMenu.spec.md index 9ad771e40db7f..0f58423511a90 100644 --- a/packages/core/src/ContextMenu/ContextMenu.spec.md +++ b/packages/core/src/ContextMenu/ContextMenu.spec.md @@ -85,17 +85,17 @@ Consumer props, content modes, and presentation policy remain documented in ## Behavioral and layout contract -| ID | Candidate invariant | Basis | Draft review state | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | -| FR1 | Every render contains a caller-provided Trigger area. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | Current behavior; verified | -| FR2 | The current `context-menu` target remains on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | Current source, docs, and tests | Current inventory; verified | -| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | Current delegation; verified | -| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | Current branches; verified | -| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `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 stray click never acts, and `touch-action` follows overflow. Under a finger the press follows `spec:AST-094` FR26–FR28 and FR30, and the menu's grow, dismissal, long-press lift and outside press follow FR31–FR33 and FR35 (pending implementation); a mouse press outside is unchanged. Right-click, Shift+F10, long-press invocation and keyboard navigation keep their existing behavior. | `module:DropdownMenu/useMenuPress`; `ContextMenu.test.tsx` press model suite | Current module projection; verified in jsdom | -| FR6 | With `triggerAs="span"` the Trigger area MUST be an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | `component:ContextMenu/DEC-1` | Accepted; verified in jsdom | -| FR7 | Sub-menu rows follow `component:DropdownMenu` FR12 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner decision pending | -| FR8 | 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 page as in DropdownMenu FR6; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. | Current source and tests; shared `useListFocus` and `useTypeahead` suites | Proposed; verified in jsdom; owner decision pending | -| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | Current family projection; verified in jsdom | +| ID | Candidate invariant | Basis | Draft review state | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| FR1 | Every render contains a caller-provided Trigger area. Pointer presentation opens a Pointer menu surface at the current local cursor anchor; touch presentation opens a Touch sheet frame containing a Touch menu surface and, for data mode, a Touch action list and Touch action rows. | Current source, docs, and tests | Current behavior; verified | +| FR2 | The current `context-menu` target remains on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | Current source, docs, and tests | Current inventory; verified | +| FR3 | Pointer action rows retain DropdownMenu ownership. The touch frame retains BottomSheet ownership, while data-driven touch lists and rows retain List ownership. | Current source and owner docs | Current delegation; verified | +| FR4 | Compound `menuContent` remains a caller-supplied pointer-menu interior and may also render inside the touch frame; the data-driven touch path instead converts the same item data to List and ListItem presentation. | Current source and tests | Current branches; verified | +| FR5 | The Pointer menu surface carries `data-astryx-menu-press` and follows `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 stray click never acts, and `touch-action` follows overflow. Under a finger the press follows `spec:AST-094` FR26–FR28 and FR30, and the menu's grow, dismissal, long-press lift and outside press follow FR31–FR33 and FR35 (pending implementation); a mouse press outside is unchanged. Right-click, Shift+F10, long-press invocation and keyboard navigation keep their existing behavior. | `module:DropdownMenu/useMenuPress`; `ContextMenu.test.tsx` press model suite | Current module projection; verified in jsdom; the AST-094 FR31–FR33 and FR35 clause pending implementation | +| FR6 | With `triggerAs="span"` the Trigger area MUST be an inline element that owns a box, so the cursor anchor measures from the trigger's own rect on every invocation path. Right-click, Shift+F10, long-press, and `context-menu` target placement all use that box. | `component:ContextMenu/DEC-1` | Accepted; verified in jsdom | +| FR7 | Sub-menu rows follow `component:DropdownMenu` FR12 inside the pointer menu: ContextMenu mounts the same drill-in view stack, named after its `label`, so a sub-menu drills in on a phone here too. | `component:DropdownMenu/DEC-2` | Proposed; verified in jsdom; owner decision pending | +| FR8 | 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 page as in DropdownMenu FR6; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. | Current source and tests; shared `useListFocus` and `useTypeahead` suites | Proposed; verified in jsdom; owner decision pending | +| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case | Current family projection; verified in jsdom | ### Allowed variation 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..37e4cf0fa4c7b 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' 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. Omitted, the menu is anchored and takes the touch modes under a finger: 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', @@ -243,14 +242,14 @@ export const docs = { name: 'placement', type: "'above' | 'below' | 'start' | 'end'", description: - "Popover placement relative to the trigger. Ignored by the bottom-sheet presentation. Logical: start/end resolve against the menu's own inherited direction (RTL mirrors).", + "Popover placement relative to the trigger. Ignored by the bottom-sheet presentation, and on a compact touch screen under the touch modes (unless presentation is 'popover'), where the menu sits over its trigger as UIMenu does. Logical: start/end resolve against the menu's own inherited direction (RTL mirrors).", default: "'below'", }, { name: 'alignment', type: "'start' | 'center' | 'end'", description: - "Popover alignment along the placement axis. Ignored by the bottom-sheet presentation. Logical: start/end follow the menu's own inherited direction (RTL mirrors).", + "Popover alignment along the placement axis. Ignored by the bottom-sheet presentation, and on a compact touch screen under the touch modes (unless presentation is 'popover'), where the menu is centered on its trigger as UIMenu is. Logical: start/end follow the menu's own inherited direction (RTL mirrors).", default: "'start'", }, { @@ -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 it behaves like UIMenu on iOS: a finger resting on the trigger for 150 ms opens the menu, 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. A finger that lands in the open menu lights its row at once in a menu that fits and, in a menu that scrolls, after UIKit\u2019s content-touch delay (150 ms); a tap picks at the lift; a quick drag scrolls a menu that scrolls and slides the highlight in one that fits. On a phone the popover menu sits over its trigger as UIMenu does: centered on it, top-aligned with it (bottom-aligned when the trigger is in the lower half of the viewport), 8 px from the viewport\u2019s sides.', bestPractices: [ { guidance: true, diff --git a/packages/core/src/DropdownMenu/DropdownMenu.spec.md b/packages/core/src/DropdownMenu/DropdownMenu.spec.md index d1091b9a15444..a94bec711cd73 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.spec.md +++ b/packages/core/src/DropdownMenu/DropdownMenu.spec.md @@ -104,29 +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 | +| 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. Trigger opening and keyboard navigation are unchanged. | `module:DropdownMenu/useMenuPress`; `DropdownMenu.test.tsx` press model suite | Proposed; FR26–FR34 verified in jsdom and real Chromium; 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 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. | Proposed; `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | Proposed; verified in jsdom and Chromium, pending owner review | ### Allowed variation @@ -338,6 +339,7 @@ than adding a DropdownMenu-owned heading target. | FR10 | `DropdownMenuSubMenu.test.tsx` safe-triangle suite; `useMenuHover.test.tsx` `isPointInSafeTriangle` and click-guard suites | diagonal path toward the flyout, a pause inside it, path away, click-opened and hover-opened guard windows | A flyout that closes while the pointer is inside the triangle or paused in it, one that never closes after leaving it, or a changed guard-window rule, fails. | `audit:DropdownMenu/behavior` | | FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. | | FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` | +| FR21 | `DropdownMenu.test.tsx` touch motion and pan-outside cases; `menuSurfaceMotion.test.ts`; `__tests__/DropdownMenuTouchMotion.interaction.browser.spec.ts` | open, close, an interrupted open and close, Reduce Motion, a mouse; a tap, a hold and a pan outside; focus in the menu at a pan dismissal | A pinned `'popover'` that animates, a pan outside that leaves the menu open, or focus left in a hidden menu fails | | FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` | | FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` | diff --git a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx index b154b7cfb6107..fb702ec0e2c9c 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.test.tsx @@ -19,7 +19,14 @@ import { afterEach, } from 'vitest'; import {readFileSync} from 'node:fs'; -import {render, screen, fireEvent, waitFor, act} from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + act, + cleanup, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {createRef, useState} from 'react'; import * as stylex from '@stylexjs/stylex'; @@ -28,6 +35,7 @@ import {DropdownMenuItem} from './DropdownMenuItem'; import {DropdownMenuDivider} from './DropdownMenuDivider'; import {DropdownMenuGroup} from './DropdownMenuGroup'; import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet'; +import {finger, place, placeRows, rowCenter} from './__tests__/fingerTouch'; import {DropdownMenuSubMenu} from './DropdownMenuSubMenu'; import {Divider} from '../Divider'; import {rtlStyles} from '../utils'; @@ -35,6 +43,14 @@ 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 { + MENU_PRESS_AUTOSCROLL_ZONE_PX, + 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 {getGestureHost} from '../gestures/GestureHost'; +import {UIKIT_GESTURES} from '../motion/uikit/gestures'; // Mock showPopover and hidePopover methods since they're not implemented in jsdom beforeEach(() => { @@ -371,10 +387,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(), @@ -2700,6 +2722,308 @@ 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): over its trigger, as UIMenu', () => { + 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('is centered on its trigger, 8 px (or the safe area) off the viewport\u2019s sides, and top-aligned with a trigger in the upper half', async () => { + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const {style} = popoverOf(); + expect(style.getPropertyValue('justify-self')).toBe('anchor-center'); + // UIKit's 8 pt margin, or the safe area where larger, plus the + // LayerProvider inset for that edge: the layer runtime's gutter terms. + expect(style.getPropertyValue('left')).toBe( + 'calc(max(8px, env(safe-area-inset-left, 0px)) + var(--astryx-layer-inset-inline-start, 0px))', + ); + expect(style.getPropertyValue('right')).toBe( + 'calc(max(8px, env(safe-area-inset-right, 0px)) + var(--astryx-layer-inset-inline-end, 0px))', + ); + // jsdom drops anchor() values; the bottom edge is released and the top + // edge is the one pinned (its value is checked in a real browser). + expect(style.getPropertyValue('bottom')).toBe('auto'); + expect(style.getPropertyValue('top')).not.toBe('auto'); + // Not the layer's below-the-trigger placement. + expect(style.getPropertyValue('position-area')).toBe(''); + }); + + it('is bottom-aligned with a trigger whose center is in the lower half', async () => { + const lowRect = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function ( + this: HTMLElement, + ) { + const rect = lowRect.call(this); + if (this.hasAttribute('popover')) { + return rect; + } + const top = window.innerHeight - 100; + return {...rect, top, y: top, bottom: top + rect.height}; + }; + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const {style} = popoverOf(); + expect(style.getPropertyValue('top')).toBe('auto'); + expect(style.getPropertyValue('bottom')).not.toBe('auto'); + }); + + it('a mouse on a compact screen keeps the layer\u2019s placement below its trigger', async () => { + asMouse(); + const user = userEvent.setup(); + const trigger = renderMenu(); + await user.click(trigger); + const {style} = popoverOf(); + expect(style.getPropertyValue('justify-self')).toBe(''); + expect(style.getPropertyValue('left')).toBe(''); + expect(style.getPropertyValue('right')).toBe(''); + }); + + it('presentation="popover" on a compact touch screen keeps the layer\u2019s placement below its trigger', async () => { + const user = userEvent.setup(); + render( + + {}} /> + {}} /> + , + ); + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(true); + await user.click(screen.getByRole('button', {name: /Pinned/})); + const {style} = popoverOf(); + expect(style.getPropertyValue('justify-self')).toBe(''); + expect(style.getPropertyValue('top')).toBe(''); + expect(style.getPropertyValue('bottom')).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,21 +3045,352 @@ 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 closed menu adds no listener: its finger tracking joins the gesture system only while it shows', async () => { + const add = vi.spyOn(document, 'addEventListener'); + const remove = vi.spyOn(document, 'removeEventListener'); + const touches = (spy: typeof add) => + spy.mock.calls.filter(([type]) => + ['pointercancel', 'touchstart'].includes(type), + ).length; + try { + const user = userEvent.setup(); + const trigger = renderMenu(); + expect(touches(add)).toBe(0); + await user.click(trigger); + expect(touches(add)).toBeGreaterThan(0); + await user.keyboard('{Escape}'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(touches(remove)).toBe(touches(add)); + } finally { + add.mockRestore(); + remove.mockRestore(); + } + }); + + /** + * Lay the open menu out: its rows stacked from y = 0 (placeRows) inside the + * menu's box. A finger the trigger hands to the menu is resolved against + * these rects, read once (the shared action-group tracker). + */ + function layOutMenu() { + placeRows([item('Edit'), item('Duplicate'), item('Delete')]); + const menu = item('Edit').closest('[data-astryx-menu-press]'); + if (menu == null) { + throw new Error('no menu root'); + } + place(menu, {left: 0, top: 0, width: 200, height: 140}); + } + + it('where the primary pointer is fine, an open menu joins no gesture system and adds no touch listener, and a finger\u2019s tap acts once through the press model; where it is coarse, it joins one (spec:AST-094 FR19, FR24)', async () => { + const attach = vi.spyOn(getGestureHost(document), 'attach'); + const add = vi.spyOn(document, 'addEventListener'); + const touchListeners = () => + add.mock.calls.filter(([type]) => type.startsWith('touch')).length; + try { + const user = userEvent.setup(); + // jsdom's primary pointer is fine: the released finger rules apply. + expect(window.matchMedia(TOUCH_QUERY).matches).toBe(false); + const onPick = vi.fn(); + const trigger = renderMenu(onPick); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(attach).not.toHaveBeenCalled(); + expect(touchListeners()).toBe(0); + // A finger's tap there acts at its release; the browser's own click + // that follows is swallowed, so the row acts once. + fireEvent.pointerDown(item('Edit'), touch); + fireEvent.pointerUp(item('Edit'), touch); + fireEvent.click(item('Edit'), {detail: 1}); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Edit'); + cleanup(); + // A phone: the menu's finger tracking attaches while it shows. + stubCoarsePointer(); + const coarseTrigger = renderMenu(); + await user.click(coarseTrigger); + expect(attach).toHaveBeenCalledTimes(1); + expect(touchListeners()).toBeGreaterThan(0); + } finally { + attach.mockRestore(); + add.mockRestore(); + } + }); + it('a finger that lands on one row and lifts on another acts on the second, once', async () => { + stubCoarsePointer(); const onPick = vi.fn(); const user = userEvent.setup(); const trigger = renderMenu(onPick); await user.click(trigger); - fireEvent.pointerDown(item('Edit'), touch); - fireEvent.pointerMove(item('Delete'), touch); + // The finger is tracked by the gesture system (as UIMenu's list tracks + // it): touch events over rows with a layout box. + placeRows([item('Edit'), item('Duplicate'), item('Delete')]); + const contact = finger(); + contact.down(item('Edit'), rowCenter(0)); + contact.move(item('Edit'), rowCenter(2)); expect(item('Delete')).toHaveFocus(); - fireEvent.pointerUp(item('Delete'), touch); + contact.up(item('Edit'), rowCenter(2)); // The WebKit tail: a click aimed at the row the touch began on. fireEvent.click(item('Edit'), {detail: 1}); expect(onPick).toHaveBeenCalledTimes(1); expect(onPick).toHaveBeenCalledWith('Delete'); }); + describe('a finger in the menu, tracked as UIMenu\u2019s list tracks it', () => { + // iOS 26.2: in a menu that scrolls, a row highlights about 160 ms after + // the finger lands (the list's content-touch delay); in one that fits it + // highlights at once (spec:AST-094 FR11). A tap picks at the lift, and a + // quick drag scrolls a long menu but scrubs a short one. One model on + // every touch device. + const stubFinger = () => { + 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 (UIMenu's open under a finger). + HTMLElement.prototype.animate = vi.fn(function () { + return { + cancel: vi.fn(), + finished: new Promise(() => {}), + startTime: null, + } as unknown as Animation; + }); + }; + beforeEach(() => { + stubFinger(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(HTMLElement.prototype, 'animate'); + }); + async function openPlaced(onPick: (label: string) => void) { + const user = userEvent.setup(); + const trigger = renderMenu(onPick); + await user.click(trigger); + const rows = [item('Edit'), item('Duplicate'), item('Delete')]; + placeRows(rows); + return {trigger, rows}; + } + const ONSET = 150; + + it('in a menu that fits, a finger lights its row at once, and the lift picks it once (spec:AST-094 FR11)', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + expect(rows[0]).toHaveFocus(); + contact.up(rows[0], rowCenter(0), 100); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Edit'); + }); + + it('in a menu that scrolls, a resting finger highlights its row only after the content-touch delay, and the lift picks it once', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const menu = screen.getByRole('menu', {hidden: true}); + Object.defineProperty(menu, 'scrollHeight', {value: 600}); + Object.defineProperty(menu, 'clientHeight', {value: 300}); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + contact.move(rows[0], {...rowCenter(0), y: rowCenter(0).y + 2}, 100); + expect(rows[0]).not.toHaveFocus(); + contact.move( + rows[0], + {...rowCenter(0), y: rowCenter(0).y + 3}, + ONSET - 90, + ); + expect(rows[0]).toHaveFocus(); + contact.up(rows[0], rowCenter(0)); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Edit'); + }); + + it('a tap picks the row at the lift', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const contact = finger(); + contact.down(rows[1], rowCenter(1)); + contact.up(rows[1], rowCenter(1), 60); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Duplicate'); + }); + + it('in a menu that scrolls, a second finger that lands before the row lights ends the press with nothing acting (FR4)', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const menu = screen.getByRole('menu', {hidden: true}); + Object.defineProperty(menu, 'scrollHeight', {value: 600}); + Object.defineProperty(menu, 'clientHeight', {value: 300}); + const first = finger(); + const second = finger(); + first.down(rows[0], rowCenter(0)); + second.down(rows[2], rowCenter(2), {after: 40}); + first.up(rows[0], rowCenter(0), 40); + second.up(rows[2], rowCenter(2)); + expect(onPick).not.toHaveBeenCalled(); + expect(rows[0]).not.toHaveFocus(); + }); + + it('in a menu that fits, a second finger that lands before the press begins puts out the lit row, and nothing acts (FR4)', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const first = finger(); + const second = finger(); + first.down(rows[0], rowCenter(0)); + expect(rows[0]).toHaveFocus(); + second.down(rows[2], rowCenter(2), {after: 40}); + first.up(rows[0], rowCenter(0), 40); + second.up(rows[2], rowCenter(2)); + expect(onPick).not.toHaveBeenCalled(); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).not.toHaveBeenCalled(); + }); + + it('a second finger that lands once the row is lit ends the press with nothing acting (FR4)', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const first = finger(); + const second = finger(); + first.down(rows[0], rowCenter(0)); + first.move(rows[0], {...rowCenter(0), y: rowCenter(0).y + 2}, ONSET + 30); + expect(rows[0]).toHaveFocus(); + second.down(rows[2], rowCenter(2), {after: 20}); + first.up(rows[0], rowCenter(0)); + second.up(rows[2], rowCenter(2)); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).not.toHaveBeenCalled(); + }); + + it('a finger that lands on a row and lifts outside the menu acts on nothing and leaves it open (FR3)', async () => { + const onPick = vi.fn(); + const {trigger, rows} = await openPlaced(onPick); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + contact.move(rows[0], {x: rowCenter(0).x, y: 900}, ONSET + 30); + contact.up(rows[0], {x: rowCenter(0).x, y: 900}); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).not.toHaveBeenCalled(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + + it('in a menu that fits, a quick drag scrubs from where it landed and picks the row it lifts on', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + contact.move(rows[0], rowCenter(2), 30); + expect(rows[2]).toHaveFocus(); + contact.up(rows[0], rowCenter(2), 30); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Delete'); + }); + + it('in a menu that scrolls, a quick drag is the list\u2019s scroll: nothing highlights, nothing is picked', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const menu = screen.getByRole('menu', {hidden: true}); + Object.defineProperty(menu, 'scrollHeight', {value: 600}); + Object.defineProperty(menu, 'clientHeight', {value: 300}); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + contact.move(rows[0], rowCenter(2), 30); + expect(rows[2]).not.toHaveFocus(); + expect(rows[0]).not.toHaveFocus(); + contact.up(rows[0], rowCenter(2), 30); + expect(onPick).not.toHaveBeenCalled(); + }); + + it('in a menu that scrolls, a finger resting near its bottom edge autoscrolls it, and leaving the edge stops it (FR30)', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const menu = screen.getByRole('menu', {hidden: true}); + Object.defineProperty(menu, 'scrollHeight', {value: 600}); + Object.defineProperty(menu, 'clientHeight', {value: 150}); + place(menu, {left: 0, top: 0, width: 200, height: 150}); + const wait = async (ms: number) => { + await act(async () => { + await new Promise(resolve => { + setTimeout(resolve, ms); + }); + }); + }; + // The bottom row, inside the edge zone (the menu ends at 150). + const edge = {x: 100, y: 150 - MENU_PRESS_AUTOSCROLL_ZONE_PX / 2}; + const contact = finger(); + contact.down(rows[2], edge); + expect(menu.scrollTop).toBe(0); + // Resting: the press begins after the content-touch delay, lights the + // row under the finger, and the menu scrolls toward the edge. + contact.move(rows[2], {...edge, y: edge.y + 1}, ONSET + 30); + expect(rows[2]).toHaveFocus(); + const first = menu.scrollTop; + expect(first).toBeGreaterThan(0); + await wait(80); + expect(menu.scrollTop).toBeGreaterThan(first); + // Off the edge, the scrolling stops. + contact.move(rows[1], rowCenter(1)); + const atRest = menu.scrollTop; + await wait(80); + expect(menu.scrollTop).toBe(atRest); + contact.up(rows[1], rowCenter(1)); + await wait(40); + expect(menu.scrollTop).toBe(atRest); + }); + + describe('an Android finger (no iOS engine) is tracked the same way', () => { + it('in a menu that scrolls, its row lights only after the content-touch delay, and the lift picks it once', async () => { + const onPick = vi.fn(); + const {rows} = await openPlaced(onPick); + const menu = screen.getByRole('menu', {hidden: true}); + Object.defineProperty(menu, 'scrollHeight', {value: 600}); + Object.defineProperty(menu, 'clientHeight', {value: 300}); + const contact = finger(); + contact.down(rows[0], rowCenter(0)); + contact.move(rows[0], {...rowCenter(0), y: rowCenter(0).y + 2}, 100); + expect(rows[0]).not.toHaveFocus(); + contact.move( + rows[0], + {...rowCenter(0), y: rowCenter(0).y + 3}, + ONSET - 90, + ); + expect(rows[0]).toHaveFocus(); + contact.up(rows[0], rowCenter(0)); + fireEvent.click(rows[0], {detail: 1}); + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick).toHaveBeenCalledWith('Edit'); + }); + }); + }); + it('a mouse released outside closes the menu; a finger released outside leaves it open', async () => { const user = userEvent.setup(); const trigger = renderMenu(); @@ -2871,20 +3526,31 @@ 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); + layOutMenu(); + // UIMenu's handoff: the finger picks once it travels from the first + // sample the menu saw. Its rows are the tracker's (rects read once). + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 105}); + expect(item('Delete')).not.toHaveFocus(); + fireEvent.pointerMove(trigger, { + ...touch, + clientX: 100, + clientY: 105 + UIKIT_GESTURES.menuPress.handoff.value, + }); expect(item('Delete')).toHaveFocus(); - fireEvent.pointerUp(item('Delete'), touch); + fireEvent.pointerUp(trigger, { + ...touch, + clientX: 100, + clientY: 105 + 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 +3560,124 @@ describe('DropdownMenu press model', () => { } }); + it('presentation="popover" pins the released trigger under a finger: it opens at 500 ms and its opening press follows the settle rule', () => { + // 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'); + // No handoff distance: once the press has settled, a finger that barely + // moved picks the row under it, as before. + act(() => { + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + }); + 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(); + } + }); + + describe('the finger a trigger hands to the menu (the shared tracker)', () => { + /** Press the trigger: the menu opens under the finger (FR29). */ + function holdOpen(trigger: HTMLElement) { + fireEvent.pointerDown(trigger, {...touch, clientX: 100, clientY: -30}); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + } + + beforeEach(() => { + stubCoarsePointer(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('reads the rows\u2019 rects once and never hit-tests the page per move', () => { + const onPick = vi.fn(); + const trigger = renderMenu(onPick); + holdOpen(trigger); + layOutMenu(); + const reads = [item('Edit'), item('Duplicate'), item('Delete')].map(row => + vi.spyOn(row, 'getBoundingClientRect'), + ); + const elementFromPoint = vi.fn(() => null); + Object.assign(document, {elementFromPoint}); + try { + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 5}); + for (const y of [20, 30, 60, 70, 110, 120, 125]) { + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: y}); + } + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(trigger, {...touch, clientX: 100, clientY: 125}); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(reads.map(read => read.mock.calls.length)).toEqual([1, 1, 1]); + expect(elementFromPoint).not.toHaveBeenCalled(); + } finally { + delete (document as {elementFromPoint?: unknown}).elementFromPoint; + } + }); + + it('reads them again while the menu\u2019s surface still moves, and once it rests no more', () => { + const trigger = renderMenu(); + holdOpen(trigger); + layOutMenu(); + const surface = item('Edit').closest('[popover]') as HTMLElement; + let isMoving = true; + surface.getAnimations = () => + (isMoving ? [{playState: 'running'}] : []) as unknown as Animation[]; + const read = vi.spyOn(item('Edit'), 'getBoundingClientRect'); + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 5}); + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 20}); + expect(read).toHaveBeenCalledTimes(2); + isMoving = false; + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 25}); + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 30}); + expect(read).toHaveBeenCalledTimes(3); + expect(item('Edit')).toHaveFocus(); + }); + + it('a row outside the menu\u2019s box (scrolled out of it) is not under the finger', () => { + const onPick = vi.fn(); + const trigger = renderMenu(onPick); + holdOpen(trigger); + layOutMenu(); + // The menu shows only its first two rows: Delete sits below its box. + place(item('Delete').closest('[data-astryx-menu-press]')!, { + left: 0, + top: 0, + width: 200, + height: 90, + }); + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 60}); + fireEvent.pointerMove(trigger, {...touch, clientX: 100, clientY: 120}); + expect(item('Delete')).not.toHaveFocus(); + fireEvent.pointerUp(trigger, {...touch, clientX: 100, clientY: 120}); + expect(onPick).not.toHaveBeenCalled(); + }); + }); + 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 +3709,157 @@ describe('DropdownMenu press model', () => { fireEvent.click(trigger, {detail: 1}); expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); + + // spec:AST-094 FR19 and module:DropdownMenu/useMenuPress FR9: the touch + // rules follow the PRIMARY pointer. On a touchscreen whose primary pointer + // is fine (a laptop), a finger keeps the released finger behavior; finger + // or mouse is still read per gesture on both devices. + describe('the primary pointer picks the rules; 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\u2019s press does not open the menu; its tap opens it through the click', () => { + stubFinePointer(); + const trigger = renderMenu(); + 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: a finger held for the 500 ms delay opens the menu with the finger down, holds the page still until the lift, and picks under the released rules', () => { + stubFinePointer(); + vi.useFakeTimers(); + try { + const onPick = vi.fn<(label: string) => void>(); + const trigger = renderMenu(onPick); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + act(() => { + vi.advanceTimersByTime(MENU_PRESS_LONG_PRESS_MS - 1); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + // The finger's later touchmoves are the menu's, not a scroll. + expect(touchmoveIsPrevented()).toBe(true); + // A release before the settle time acts on nothing (FR8's rule). + fireEvent.pointerMove(item('Edit'), {...touch, clientX: 0, clientY: 2}); + // No handoff distance: once settled, a finger that barely moved + // lights the row under it and its lift picks it. + act(() => { + vi.advanceTimersByTime(MENU_PRESS_SETTLE_MS); + }); + fireEvent.pointerMove(item('Delete'), { + ...touch, + clientX: 0, + clientY: 3, + }); + expect(item('Delete')).toHaveFocus(); + fireEvent.pointerUp(item('Delete'), {...touch, clientX: 0, clientY: 3}); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(touchmoveIsPrevented()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + 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]]); + // In the open menu the gesture system tracks the finger: a tap on a + // row with a layout box acts at the lift. + placeRows([item('Edit'), item('Delete')]); + const contact = finger(); + contact.down(item('Delete'), rowCenter(1)); + contact.up(item('Delete'), rowCenter(1)); + expect(onPick).toHaveBeenCalledWith('Delete'); + expect(onOpenChange.mock.calls).toEqual([[true], [false]]); + }); + + it('fine primary pointer: a finger opens the menu once, through its tap\u2019s click, and it closes once at a pick (module AR5)', () => { + stubFinePointer(); + const {trigger, onOpenChange, onPick} = renderCountingMenu(); + fireEvent.pointerDown(trigger, {...touch, clientX: 0, clientY: 0}); + fireEvent.pointerUp(trigger, {...touch, clientX: 0, clientY: 0}); + 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 55c9ed01649f5..a989b931e6ff3 100644 --- a/packages/core/src/DropdownMenu/DropdownMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenu.tsx @@ -22,6 +22,16 @@ * 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 a compact touch screen it also sits + * where UIMenu does: over its trigger, centered on it, top-aligned + * (bottom-aligned when the trigger is in the lower half of the viewport), + * 8 px from the viewport's sides. Under a mouse it keeps the layer's own + * entrance and placement 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 +78,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'; @@ -80,10 +90,15 @@ import {useDevWarning} from '../hooks/useDevWarning'; import {useMenuDrillIn} from './useMenuDrillIn'; import {resolveMenuWidth} from './menuWidth'; 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'; + COVER_TRIGGER_STYLE, + createMenuSurfaceMotion, + placeOverTrigger, + type MenuSurfaceMotion, +} from './DropdownMenuTouch'; // Loaded on demand: only the bottom-sheet presentation renders the sheet. import { MenuBottomSheet, @@ -430,7 +445,9 @@ interface DropdownMenuBaseProps extends BaseProps { hasChevron?: boolean; /** * Popover position relative to the trigger. Ignored by bottom-sheet - * presentation. + * presentation, and on a compact touch screen under the touch modes (unless + * `presentation` is `'popover'`), where the menu sits over its trigger as + * UIMenu does. * Uses the same placement values as other Astryx layer-based components. * @default 'below' */ @@ -438,7 +455,9 @@ interface DropdownMenuBaseProps extends BaseProps { /** * Popover alignment along the placement axis. Ignored by bottom-sheet - * presentation. + * presentation, and on a compact touch screen under the touch modes (unless + * `presentation` is `'popover'`), where the menu is centered on its trigger + * as UIMenu is. * Uses the same alignment values as other Astryx layer-based components. * @default 'start' */ @@ -463,7 +482,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 with the touch modes under a + * finger: 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[]; @@ -471,6 +493,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, a finger gets the touch modes: + * the trigger opens as UIMenu's pull-down button does. + */ presentation?: 'popover'; items?: undefined; children: ReactNode; @@ -913,6 +940,7 @@ function DropdownMenuPopover({ placement = 'below', alignment = 'start', presentation: _presentation, + touchModes, header, footer, className, @@ -920,7 +948,10 @@ function DropdownMenuPopover({ xstyle, 'data-testid': testId, ...props -}: DropdownMenuProps) { +}: DropdownMenuProps & { + /** Whether the touch input modes apply (the resolved presentation). */ + touchModes?: boolean; +}) { const t = useTranslator(); const button = buttonFromProps ?? {label: t(DEFAULT_BUTTON_I18N_KEY)}; @@ -974,6 +1005,64 @@ 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); + // On a compact touch screen, under the touch modes, the popover covers its + // trigger as UIMenu does (iOS 26.2): centered on it, kept off the + // viewport's sides by CSS, with its vertical edge chosen as it opens + // (placeOverTrigger). Elsewhere, and with a pinned 'popover', it opens + // below it. + const {isTouch, isCompact} = useTouchPresentation(); + const coversTrigger = touchModes === true && isTouch && isCompact; + const coversTriggerRef = useRef(coversTrigger); + coversTriggerRef.current = coversTrigger; + 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 @@ -1079,6 +1168,45 @@ 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) { + placeOverTrigger( + surface, + trigger, + coversTriggerRef.current, + // The menu's own scrolling element (its first role="menu"; a nested + // flyout comes after it in DOM order). + surface.querySelector('[role="menu"]'), + ); + const motion = getSurfaceMotion(surface); + if (isUikitMotion) { + motion.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 { + motion.reset(); + } + } if (renderTrigger != null) { setTriggerName(readAccessibleName(buttonRef.current)); } @@ -1090,7 +1218,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. @@ -1129,6 +1265,68 @@ function DropdownMenuPopover({ 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 @@ -1394,12 +1592,17 @@ 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 the trigger opens as spec:AST-094 FR29 states and a + // finger in the menu is tracked as UIMenu's list tracks it (FR26–FR30); + // `presentation="popover"` pins the pointer model, today's included. + touchModel: touchModes ? 'uikit' : 'pointer', + isOpen, }); cancelMenuPressRef.current = menuPress.cancel; @@ -1539,8 +1742,9 @@ function DropdownMenuPopover({ host: surfaceHost, ownerId: menuId, menuMaxHeight: resolvedMaxHeight ?? undefined, + touchModel: touchModes ? 'uikit' : 'pointer', }), - [surfaceHost, menuId, resolvedMaxHeight], + [surfaceHost, menuId, resolvedMaxHeight, touchModes], ); // Resolve menu content: data-driven items become components @@ -1740,18 +1944,29 @@ function DropdownMenuPopover({
, ), - { - placement, - alignment, - offset: spacingVars['--spacing-1'], - xstyle: [ - styles.popoverViewport, - resolvedMaxHeight != null && - dynamicStyles.popoverViewportMaxBlockSize(resolvedMaxHeight), - popoverXstyle, - layerAnimations[placement], - ], - }, + coversTrigger + ? { + positioning: 'custom', + style: COVER_TRIGGER_STYLE, + xstyle: [ + styles.popoverViewport, + resolvedMaxHeight != null && + dynamicStyles.popoverViewportMaxBlockSize(resolvedMaxHeight), + popoverXstyle, + ], + } + : { + placement, + alignment, + offset: spacingVars['--spacing-1'], + xstyle: [ + styles.popoverViewport, + resolvedMaxHeight != null && + dynamicStyles.popoverViewportMaxBlockSize(resolvedMaxHeight), + popoverXstyle, + layerAnimations[placement], + ], + }, )} ); @@ -1766,9 +1981,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 under a finger (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; @@ -1800,7 +2018,13 @@ 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..7d1491fe0fb6e 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuContext.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuContext.tsx @@ -78,7 +78,10 @@ export interface DropdownMenuSurfaceHost { getElement: () => HTMLElement | null; } -/** What a sub-menu with a header or footer needs from the root. Internal. */ +/** + * What a sub-menu needs from its DropdownMenu root: the surface a sub-menu + * with a header or footer renders into, and the root's touch model. Internal. + */ export interface DropdownMenuSurfaceValue { /** * An element inside the root's popover but outside every `role="menu"` @@ -93,6 +96,13 @@ export interface DropdownMenuSurfaceValue { ownerId: string; /** The root's resolved `menuMaxHeight`, which a drilled-in view caps at. */ menuMaxHeight?: string; + /** + * The root's touch model (`'uikit'` in its touch modes, `'pointer'` when + * its presentation pins the pointer model). A sub-menu's press model takes + * it, so a finger in a flyout follows the root's rules: a cancelled touch + * clears its lit row (`spec:AST-094` FR28). + */ + touchModel?: 'pointer' | 'uikit'; } /** diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx index 271fad83ff354..74898db4f36c6 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.test.tsx @@ -22,12 +22,14 @@ import * as stylex from '@stylexjs/stylex'; import {DropdownMenu} from './DropdownMenu'; import {DropdownMenuItem} from './DropdownMenuItem'; import {DropdownMenuSubMenu} from './DropdownMenuSubMenu'; +import {finger, place, placeRows, rowCenter} from './__tests__/fingerTouch'; import {warmMenuBottomSheet} from './__tests__/warmMenuBottomSheet'; import {IconButton} from '../IconButton'; import {RadioList, RadioListItem} from '../RadioList'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; import {Theme, defineTheme} from '../theme'; import {rtlStyles} from '../utils'; +import {TOUCH_QUERY} from '../utils/touchPresentation'; beforeEach(() => { HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { @@ -1114,7 +1116,84 @@ describe('DropdownMenuSubMenu hover/click guard', () => { }); describe('DropdownMenuSubMenu press model', () => { + // A finger on a phone or tablet: the touch modes track it through the + // gesture system, as the root does. + const originalMatchMedia = window.matchMedia; + const asFinger = () => { + 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(), + })), + ); + }; + afterEach(() => { + vi.stubGlobal('matchMedia', originalMatchMedia); + }); + // A touch pointer where the primary pointer is fine (jsdom's default): the + // menu keeps the pointer model, which tracks the finger's pointer events. + const touchPointer = (pointerId = 1) => ({pointerType: 'touch', pointerId}); + + + it('a cancelled finger in a flyout clears its lit row where the root menu\u2019s touch modes apply, as the root\u2019s does (spec:AST-094 FR28); pinned to the pointer model, the row stays lit', async () => { + // A coarse primary pointer on a large screen: the root is an anchored + // popover, and its sub-menu flies out. + asFinger(); + const openFlyout = async (presentation?: 'popover') => { + const user = userEvent.setup(); + const view = render( + + {}} /> + + {}} /> + {}} /> + + , + ); + await user.click(screen.getByRole('button', {name: /Actions/})); + const trigger = screen.getByRole('menuitem', { + name: /Move to/, + hidden: true, + }); + await user.click(trigger); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + return { + view, + folderA: screen.getByRole('menuitem', {name: 'Folder A', hidden: true}), + folderB: screen.getByRole('menuitem', {name: 'Folder B', hidden: true}), + }; + }; + // In the touch modes the gesture system tracks the finger: it lands in + // the flyout and slides onto Folder B, which lights; then the browser + // takes the touch (a scroll, a system gesture). + const touch = await openFlyout(); + placeRows([touch.folderA, touch.folderB], 300); + const contact = finger(); + contact.down(touch.folderA, rowCenter(0, 300)); + contact.move(touch.folderA, rowCenter(1, 300)); + expect(touch.folderB).toHaveFocus(); + contact.cancel(touch.folderA, rowCenter(1, 300)); + expect(touch.folderB).not.toHaveFocus(); + touch.view.unmount(); + // Pinned to the pointer model, the finger's pointer events drive the + // press, and a cancel leaves the row lit, as the root's does. + const pinned = await openFlyout('popover'); + fireEvent.pointerDown(pinned.folderA, touchPointer(2)); + fireEvent.pointerMove(pinned.folderB, touchPointer(2)); + expect(pinned.folderB).toHaveFocus(); + fireEvent.pointerCancel(pinned.folderB, touchPointer(2)); + expect(pinned.folderB).toHaveFocus(); + }); + it('a press released on the sub-menu row opens it and keeps the menu', async () => { + asFinger(); const user = userEvent.setup(); render(); const root = screen.getByRole('button', {name: /Actions/}); @@ -1124,14 +1203,19 @@ describe('DropdownMenuSubMenu press model', () => { hidden: true, }); const rename = screen.getByRole('menuitem', {name: 'Rename', hidden: true}); - fireEvent.pointerDown(rename, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerMove(trigger, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(trigger, {pointerType: 'touch', pointerId: 1}); + // The finger is tracked by the gesture system: touch events over rows + // with a layout box. + placeRows([rename, trigger]); + const contact = finger(); + contact.down(rename, rowCenter(0)); + contact.move(rename, rowCenter(1)); + contact.up(rename, rowCenter(1)); expect(trigger).toHaveAttribute('aria-expanded', 'true'); expect(root).toHaveAttribute('aria-expanded', 'true'); }); it('a release inside the flyout acts on the flyout row and closes the whole menu', async () => { + asFinger(); const onMove = vi.fn(); const user = userEvent.setup(); render(); @@ -1150,9 +1234,11 @@ describe('DropdownMenuSubMenu press model', () => { name: 'Folder A', hidden: true, }); - fireEvent.pointerDown(folderA, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerMove(folderB, {pointerType: 'touch', pointerId: 1}); - fireEvent.pointerUp(folderB, {pointerType: 'touch', pointerId: 1}); + placeRows([folderA, folderB], 300); + const contact = finger(); + contact.down(folderA, rowCenter(0, 300)); + contact.move(folderA, rowCenter(1, 300)); + contact.up(folderA, rowCenter(1, 300)); fireEvent.click(folderA, {detail: 1}); expect(onMove).toHaveBeenCalledTimes(1); expect(onMove).toHaveBeenCalledWith('b'); @@ -1289,6 +1375,49 @@ describe('DropdownMenuSubMenu drill-in on a phone', () => { vi.stubGlobal('matchMedia', originalMatchMedia); }); + it('a finger tap on the Back row of a drilled-in view with a header pops it, though that view sits beside the root menu', async () => { + // A compact touch screen whose primary pointer is coarse. + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: + query === COMPACT_TOUCH_PRESENTATION_QUERY || + query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + const user = userEvent.setup(); + render( + + {}} /> + }> + {}} /> + + , + ); + await user.click(screen.getByRole('button', {name: /Actions/})); + const row = screen.getByRole('menuitem', {name: /Labels/, hidden: true}); + fireEvent.click(row); + const back = await screen.findByRole('menuitem', { + name: 'Back to Actions', + hidden: true, + }); + expect(row).toHaveAttribute('aria-expanded', 'true'); + place(back, {left: 0, top: 0, width: 200, height: 40}); + const contact = finger(); + contact.down(back, {x: 100, y: 20}); + contact.up(back, {x: 100, y: 20}, 40); + await waitFor(() => expect(row).toHaveAttribute('aria-expanded', 'false')); + }); + function PhoneMenu({onMove}: {onMove?: (folder: string) => void} = {}) { return ( diff --git a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx index ba205437fc827..c9e001fda0e16 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx @@ -74,7 +74,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'; @@ -1033,11 +1033,16 @@ 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({ + // dismisses the whole menu; a finger leaves it open. It takes the root's + // touch model, so a finger here follows the root's rules (spec:AST-094 + // FR26-FR28 apply to sub-menus too): under its touch modes a finger is + // tracked as UIMenu's list tracks it. + const menuPress = useMenuSurfacePress({ menuRef, itemSelector: MENU_ITEM_SELECTOR, onDismiss: nestedMenuContext.closeMenu, + touchModel: rootSurface?.touchModel, + isOpen, }); const endAffordance = hasSpinner ? ( diff --git a/packages/core/src/DropdownMenu/DropdownMenuTouch.ts b/packages/core/src/DropdownMenu/DropdownMenuTouch.ts new file mode 100644 index 0000000000000..406dfb39377e8 --- /dev/null +++ b/packages/core/src/DropdownMenu/DropdownMenuTouch.ts @@ -0,0 +1,43 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file DropdownMenuTouch.ts + * @input menuSurfaceMotion.ts (UIMenu's grow and collapse on the animator), + * menuTouchPlacement.ts (the menu over its trigger on a compact touch + * screen), and the springs table's register function + * @output DropdownMenu's touch layer: createMenuSurfaceMotion and its type, + * COVER_TRIGGER_STYLE and placeOverTrigger + * @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). + * + * The springs table registers here, before the first animation: nothing + * registers on import. + * + * SYNC: When modified, keep every touch-only import of DropdownMenu.tsx here. + */ + +import {registerSpringsTable} from '../motion/uikit/springs'; +import { + createMenuSurfaceMotion as createSurfaceMotion, + type MenuSurfaceMotion, +} from './menuSurfaceMotion'; + +export type {MenuSurfaceMotion}; + +/** + * UIMenu's grow and collapse for a menu surface. Registers the springs table + * first, whose 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 { + registerSpringsTable(); + return createSurfaceMotion(...args); +} + +export {COVER_TRIGGER_STYLE, placeOverTrigger} from './menuTouchPlacement'; 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__/DropdownMenuTouchMotion.interaction.browser.spec.ts b/packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchMotion.interaction.browser.spec.ts new file mode 100644 index 0000000000000..55a78806350db --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/DropdownMenuTouchMotion.interaction.browser.spec.ts @@ -0,0 +1,866 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file DropdownMenuTouchMotion.interaction.browser.spec.ts + * @input The built Default DropdownMenu story, real touch input (Chromium + * CDP / WebKit's input command), the interaction harness, and UIMenu's + * values from the UIKit constants table + * @output Animation and dismissal proofs for the touch presentation (one + * touch model on every engine): the open grows out of the trigger on UIKit's open spring and + * the close collapses into it on the close spring, on screen through the + * light dismiss; the open keeps advancing through a blocked main thread; + * neither does Layout or Paint per frame; Reduce Motion cross-fades in place, opacity only, in 200 ms or less (design:reduced-motion DR2); + * storyboards of the open and the close. A pan outside dismisses as it + * begins. A mouse, with and without Reduce Motion, keeps the layer's own + * entrance and close + * @position Evidence for DropdownMenu's touch presentation (and MoreMenu, + * which renders it); the model itself is unit-tested in + * menuSurfaceMotion.test.ts. The finger's press model has its own spec, + * DropdownMenuTouchPress.interaction.browser.spec.ts + */ + +import {expect, test, type Locator, type Page} from '@playwright/test'; +import { + animationStoryboard, + blockMainThread, + busyWindowProof, + castStoryboard, + compareCurve, + cubicBezier, + countBetween, + drag, + freezeTimers, + holdAnimations, + installLongTaskLog, + installTimerGate, + markElement, + openStory, + readLongTasks, + releaseAnimations, + startScreencast, + tap, + thawTimers, + timeline, + touchDriver, + traceRendering, + trackMarker, + waitForAnimation, + waitForRest, + withStorybook, + writeEvidence, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {settlingDuration, solveSpring, toSpring} from '../../motion/spring'; +import {menu} from '../../motion/uikit/menu'; +import {springs} from '../../motion/uikit/springs'; +import {timing} from '../../motion/uikit/timing'; + +/** 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 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 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); + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +interface OpenStory { + driver: TouchDriver; + trigger: Locator; + popover: Locator; + center: {x: number; y: number}; + triggerBox: Box; +} + +/** Page-side log of the gesture and the menu: performance.now() times. */ +async function installMenuLog(page: Page) { + await page.evaluate(() => { + const log = { + down: [] as number[], + up: [] as number[], + cancel: [] as number[], + opened: [] as number[], + closed: [] as number[], + picked: [] as string[], + }; + (window as unknown as {__menuLog: typeof log}).__menuLog = log; + document.addEventListener( + 'pointerdown', + () => log.down.push(performance.now()), + true, + ); + document.addEventListener( + 'pointerup', + () => log.up.push(performance.now()), + true, + ); + document.addEventListener( + 'pointercancel', + () => log.cancel.push(performance.now()), + true, + ); + document.addEventListener( + 'toggle', + event => { + const state = event.newState; + (state === 'open' ? log.opened : log.closed).push(performance.now()); + }, + true, + ); + // The story reports a pick by logging "