From 2bcc49a4622f3a024a2975c8bc487c473a2eb553 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 02:26:34 -0800 Subject: [PATCH 01/50] feat(ContextMenu): open from a held finger on iOS 26's timeline A finger held on the trigger opens the menu on UIKit's timeline: the hold commits at 400 ms and the menu shows at 735 ms. One outside-press watch closes the menu for every pointer, and a touch closes it as it begins to pan. It carries this pull request's own diff from 6d7fceb72a, replayed on main after #9814's squash, and folds these 12 commits (the history of 6d7fceb72a has each one): - chore(motion): ContextMenu's touch timeline and press curve, measured on iOS 26.2 - test(ContextMenu): a held finger opens the menu on iOS 26's timeline, and a touch outside closes it as it pans - feat(ContextMenu): open from a held finger on iOS 26's timeline, with no page lock - docs(ContextMenu): FR11 cites AST-094 FR45 for the hold's measured moments - test(ContextMenu): the long-press a11y contract follows the touch model - docs(ContextMenu): the touch changeset and docs name their condition, not every touch device - test(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - feat(Layer): the outside-press watch closes a layer at the press for a mouse or a pen - refactor(ContextMenu): one outside-press watch closes the menu for every pointer, and its mousedown Effect goes - docs(ContextMenu): split long file-header sentences - test(Indicator): the ContextMenu box-hold case pins the popover menu - test(ContextMenu): the long-press spec note keeps to 20 words --- .changeset/contextmenu-touch-timeline.md | 7 + .changeset/outside-press-pan-watch.md | 2 +- .../storybook/stories/ContextMenu.stories.tsx | 69 ++ .../ContextMenu/ContextMenu.spec.md | 95 +-- .../core/src/ContextMenu/ContextMenu.doc.mjs | 9 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 796 ++++++++++++++++-- packages/core/src/ContextMenu/ContextMenu.tsx | 237 ++++-- .../core/src/ContextMenu/ContextMenuTouch.ts | 17 + .../src/ContextMenu/ContextMenuTouchLayer.tsx | 99 +++ ...ContextMenuLongPress.a11y.chromium.spec.ts | 130 ++- ...tMenuLongPress.interaction.browser.spec.ts | 182 ++++ .../src/ContextMenu/contextMenuPress.test.ts | 214 +++++ .../core/src/ContextMenu/contextMenuPress.ts | 198 +++++ packages/core/src/Indicator/boxHold.test.tsx | 5 +- packages/core/src/Layer/outsidePress.test.ts | 112 ++- packages/core/src/Layer/outsidePress.ts | 49 +- packages/core/src/motion/README.md | 1 + packages/core/src/motion/uikit/contextMenu.ts | 94 +++ 18 files changed, 2054 insertions(+), 262 deletions(-) create mode 100644 .changeset/contextmenu-touch-timeline.md create mode 100644 packages/core/src/ContextMenu/ContextMenuTouch.ts create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx create mode 100644 packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts create mode 100644 packages/core/src/ContextMenu/contextMenuPress.test.ts create mode 100644 packages/core/src/ContextMenu/contextMenuPress.ts create mode 100644 packages/core/src/motion/uikit/contextMenu.ts diff --git a/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md new file mode 100644 index 0000000000000..b8c1a1e95a6c7 --- /dev/null +++ b/.changeset/contextmenu-touch-timeline.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. The hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. + +@vjeux diff --git a/.changeset/outside-press-pan-watch.md b/.changeset/outside-press-pan-watch.md index e71ae7e4c072a..3cb9f8e136f72 100644 --- a/.changeset/outside-press-pan-watch.md +++ b/.changeset/outside-press-pan-watch.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: the layer's outside-press watch can follow chosen pointer types, close as a press that starts outside begins to pan (on either axis, at core's pan threshold) or when the browser takes it, and judge a press inside a closed shadow root, which the document sees as its host. No component passes these options yet, so every watch keeps today's behavior. Not exported from the package entry point. +[chore] Internal: the layer's outside-press watch can follow chosen pointer types, close as a press that starts outside begins to pan (on either axis, at core's pan threshold) or when the browser takes it, and judge a press inside a closed shadow root, which the document sees as its host. ContextMenu's touch model uses them; every other watch keeps today's behavior. Not exported from the package entry point. @vjeux diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 891b4ca1c4aa1..7d8dff9642034 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -647,3 +647,72 @@ export const BottomSheetSubmenu: Story = { } }, }; + +const touchStyles = stylex.create({ + card: { + width: '200px', + height: '120px', + display: 'grid', + placeItems: 'center', + borderRadius: '12px', + backgroundColor: '#e5e7eb', + color: '#374151', + userSelect: 'none', + }, +}); + +export const TouchLongPress: Story = { + name: 'Touch / long press', + parameters: { + docs: { + description: { + story: + 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan.', + }, + }, + }, + render: () => ( + {}}, + {label: 'Share', icon: ShareIcon, onClick: () => {}}, + {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}}, + {type: 'divider'}, + { + label: 'Delete', + icon: TrashIcon, + variant: 'destructive', + onClick: () => {}, + }, + ]}> +
Hold for actions
+
+ ), +}; + +export const TouchLongPressPopover: Story = { + name: 'Touch / long press, presentation="popover"', + parameters: { + docs: { + description: { + story: + 'With `presentation="popover"`, ContextMenu keeps today’s menu on every device: a finger held for 500 ms opens it at the finger, with no growth and no iOS timeline. A finger on a device whose primary pointer is fine (a mouse or a trackpad) gets the same long press.', + }, + }, + }, + render: () => ( + {}}, + {label: 'Share', icon: ShareIcon, onClick: () => {}}, + {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}}, + ]}> +
Hold for actions
+
+ ), +}; diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 9fa75032743cb..dac8d53543eaf 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -36,15 +36,15 @@ system_specs: [spec:AST-094, spec:AST-107] ## Contract at a glance -| Area | Contract | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | -| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | -| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger, and a scroll that starts outside leaves it open; mouse and keyboard opens keep today's look. | -| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | -| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow, pan dismissal, and trigger lift (FR5) are listed under Current-state impact. | -| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | -| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | +| Area | Contract | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Public contract | None changes. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | +| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | +| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger; mouse and keyboard opens keep today's look. | +| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | +| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow and trigger lift (FR5) are listed under Current-state impact. | +| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | +| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | This table is a review projection; the body below is authoritative. @@ -100,19 +100,20 @@ Consumer props, content modes, and presentation policy remain documented in ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. 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 | -| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | -| 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 | -| 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 | -| 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. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, a scroll that starts outside leaves it open, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | -| FR6 | With `triggerAs="span"` the Trigger area is 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. | DEC-1 | -| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 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. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | -| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | -| 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 | -| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | -| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. 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 | +| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | +| 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 | +| 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 | +| 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. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | +| FR6 | With `triggerAs="span"` the Trigger area is 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. | DEC-1 | +| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 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. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | +| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | +| 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 | +| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | +| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| FR12 | Under a finger, the hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured: UIContextMenuInteraction on the iOS 26.2 simulator commits at 400 ms and displays its menu at 735 ms (`motion/uikit/contextMenu.ts`); `spec:AST-094` FR4, FR45 | ### Allowed variation @@ -244,24 +245,25 @@ anchor is positioning infrastructure rather than consumer anatomy. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens on touch long-press", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | -| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | -| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | -| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and scroll outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, or a tap or mouse press outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift; the scroll-outside and pen cases assert the current gaps. | `audit:ContextMenu/behavior` | -| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | -| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | -| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | -| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | -| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | -| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | -| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | -| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | -| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "composes consumer touch handlers with long-press detection") | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | -| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | -| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | +| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | +| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | +| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | +| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | +| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | +| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | +| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | +| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | +| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | +| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | +| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | +| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | +| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | +| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts` | A held finger: the commit and the menu's display | A commit or a display off UIKit's moments fails. | `audit:ContextMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | +| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | ## Current-state impact @@ -271,13 +273,12 @@ anchor is positioning infrastructure rather than consumer anatomy. - **No headless factory (FR10).** No headless context menu ships, so ContextMenu owns its invocation, open state, and outside-press listener itself, against `spec:AST-107` FR8. -- **Touch grow, pan dismissal, handoff, and lift (FR5).** A touch-opened - menu keeps the layer's own entrance and exit instead of growing from the - finger, a scroll that starts outside leaves it open, the held finger that - opened it cannot slide to a row, and a block trigger is not lifted over a - tinted page; a pen press outside closes it at the press. All are against - `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or keyboard - already keeps its released appearance and motion. +- **Touch grow, handoff, and lift (FR5).** A touch-opened menu keeps the + layer's own entrance and exit instead of growing from the finger, the held + finger that opened it cannot slide to a row, and a block trigger is not + lifted over a tinted page; a pen press outside closes it at the press. All + are against `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or + keyboard already keeps its released appearance and motion. ## Decision log diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs index f8ee2c5659074..4a5b1745c7bbe 100644 --- a/packages/core/src/ContextMenu/ContextMenu.doc.mjs +++ b/packages/core/src/ContextMenu/ContextMenu.doc.mjs @@ -132,8 +132,7 @@ export const docs = { name: 'presentation', type: "'popover' | 'bottom-sheet' | 'adaptive'", description: - 'Presentation policy. `popover` opens at the pointer position, `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet on a compact touch screen: a coarse primary pointer, and a viewport narrower than the theme `md` breakpoint (768px by default) or shorter than 500px.', - default: "'popover'", + 'Presentation policy. Omitted, the menu opens at the pointer position, and under a finger a held finger opens it on iOS timing (the touch model). `popover` opens at the pointer position on every device, and a held finger opens it at 500 ms (the long press), as before; so does a finger where the primary pointer is fine. `bottom-sheet` always uses an action sheet, and `adaptive` uses the BottomSheet on a compact touch screen: a coarse primary pointer, and a viewport narrower than the theme `md` breakpoint (768px by default) or shorter than 500px.', }, { name: 'isDisabled', @@ -160,7 +159,7 @@ export const docs = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', bestPractices: [ { guidance: true, @@ -224,7 +223,7 @@ export const docs = { export const docsZh = { usage: { description: - '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。', + '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', bestPractices: [ {guidance: true, description: '保持菜单项简洁和面向操作。'}, {guidance: true, description: '有很多项时使用分组和分隔线。'}, @@ -260,7 +259,7 @@ export const docsDense = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms.', bestPractices: [ {guidance: true, description: 'Keep items concise and action-oriented.'}, { diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index d68f7a78dcc1d..3b3957152001d 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -10,7 +10,15 @@ import {useRef, useState} from 'react'; import {createPortal} from 'react-dom'; -import {describe, it, expect, vi, beforeAll, beforeEach} from 'vitest'; +import { + describe, + it, + expect, + vi, + afterEach, + beforeAll, + beforeEach, +} from 'vitest'; import { render, screen, @@ -37,6 +45,9 @@ import {Divider} from '../Divider'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {warmMenuBottomSheet} from '../DropdownMenu/__tests__/warmMenuBottomSheet'; import {COMPACT_TOUCH_PRESENTATION_QUERY} from '../hooks/useAdaptivePresentation'; +import {COMPACT_QUERY, TOUCH_QUERY} from '../utils/touchPresentation'; +import {HAPTIC_VIBRATION_PATTERNS} from '../motion/uikit'; +import {contextMenu} from '../motion/uikit/contextMenu'; // The touch sheet is loaded lazily; resolve its modules once up front, so // ContextMenu's own lazy sheet renders within the cases' waitFor. @@ -180,6 +191,17 @@ describe('ContextMenu', () => { ).toBeInTheDocument(); }); + it('suppresses native text selection on the long-press trigger', () => { + const source = readFileSync( + 'packages/core/src/ContextMenu/ContextMenu.tsx', + 'utf8', + ); + + expect(source).toMatch( + /trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/, + ); + }); + it('does not put aria-haspopup on the role-less trigger wrapper (menus-15)', () => { render( @@ -374,7 +396,11 @@ describe('ContextMenu', () => { vi.stubGlobal( 'matchMedia', vi.fn().mockImplementation((query: string) => ({ - matches: query === COMPACT_TOUCH_PRESENTATION_QUERY, + // A compact touch screen: a coarse primary pointer, a narrow viewport. + matches: + query === COMPACT_TOUCH_PRESENTATION_QUERY || + query === TOUCH_QUERY || + query === COMPACT_QUERY, media: query, onchange: null, addEventListener: vi.fn(), @@ -414,6 +440,8 @@ describe('ContextMenu', () => { it.each(['open', 'closed', 'nested'] as const)( 'judges presses once inside a %s shadow root', mode => { + // Under the touch model, so a touch on the host closes the menu. + stubPointer(true); const host = document.createElement('div'); document.body.append(host); let root = host.attachShadow({mode: mode === 'open' ? 'open' : 'closed'}); @@ -431,16 +459,33 @@ describe('ContextMenu', () => { , {container}, ); - fireEvent.contextMenu(container.querySelector('div div')!); + const trigger = container.querySelector('div div')!; + fireEvent.contextMenu(trigger); // At the document a press on the menu is retargeted to a shadow host. const item = container.querySelector('[role="menuitem"]')!; fireEvent.mouseDown(item, {composed: true}); expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); - // A press on the host itself is outside the menu. + // A touch on the menu is inside too, through its lift. + const onItem = {pointerType: 'touch', pointerId: 1, composed: true}; + fireEvent.pointerDown(item, onItem); + fireEvent.pointerUp(item, onItem); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + // A mouse press on the host itself is outside the menu. Its + // pointerdown comes first, as a browser sends it. + fireEvent.pointerDown(host, {pointerType: 'mouse', pointerId: 3}); fireEvent.mouseDown(host); expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); + // A touch on the host closes the menu at its lift. + vi.mocked(HTMLElement.prototype.hidePopover).mockClear(); + fireEvent.contextMenu(trigger); + const onHost = {pointerType: 'touch', pointerId: 2}; + fireEvent.pointerDown(host, onHost); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + fireEvent.pointerUp(host, onHost); + expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); } finally { host.remove(); + vi.unstubAllGlobals(); } }, ); @@ -665,114 +710,598 @@ describe('ContextMenu', () => { expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); }); }); +}); - it('opens on touch long-press', () => { - vi.useFakeTimers(); - try { - render( - -
Long-press me
-
, - ); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, { - touches: [{clientX: 20, clientY: 20}], - }); - // Not open until the long-press threshold elapses. - expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - act(() => { - vi.advanceTimersByTime(500); +/** + * A finger on the real gesture host: touch events with explicit contacts, + * stamped on the (faked) `performance.now()` clock the host reads. + */ +interface Contact { + id: number; + x: number; + y: number; +} + +function dispatchTouch( + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', + target: EventTarget, + changed: Contact[], + all: Contact[], +): Event { + const event = new Event(type, {bubbles: true, cancelable: true}); + const toTouch = (contact: Contact) => ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }); + Object.defineProperty(event, 'changedTouches', {value: changed.map(toTouch)}); + Object.defineProperty(event, 'touches', {value: all.map(toTouch)}); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + target.dispatchEvent(event); + return event; +} + +function finger(target: EventTarget, id = 1, x = 20, y = 20) { + const contact = {id, x, y}; + return { + down: () => dispatchTouch('touchstart', target, [contact], [contact]), + moveTo: (nx: number, ny: number) => { + contact.x = nx; + contact.y = ny; + return dispatchTouch('touchmove', target, [contact], [contact]); + }, + up: () => dispatchTouch('touchend', target, [contact], []), + }; +} + +const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + +/** The primary pointer answers `matches` to `(pointer: coarse)`. */ +function stubPointer(isCoarse: boolean) { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: isCoarse && query === '(pointer: coarse)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +/** + * A finger on iOS in jsdom: a coarse pointer, on an engine that supports + * `-webkit-touch-callout` (WebKit on iOS). Undone by vi.unstubAllGlobals(). + */ +function stubIosTouch() { + 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(), + })), + ); + vi.stubGlobal('CSS', { + ...(globalThis as {CSS?: object}).CSS, + supports: (condition: string) => + condition.includes('-webkit-touch-callout'), + }); +} + +/** A finger on Android: a coarse pointer, no WebKit touch callout. */ +function stubAndroidTouch() { + stubPointer(true); +} + +// The gesture host lives as long as the document and remembers the latest +// event time. Each install of fake timers restarts `performance.now()` near 0. +// So every case moves the faked clock past the ones before it. +let clockEpoch = 0; + +// One touch model on every touch device (#8316): iOS and Android alike. +describe.each([ + ['iOS', stubIosTouch], + ['Android', stubAndroidTouch], +] as const)( + 'ContextMenu under a finger on %s: UIKit’s long-press timeline', + (_platform, stubTouch) => { + beforeEach(() => { + stubTouch(); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], }); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); - } finally { + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { vi.useRealTimers(); - } - }); + vi.unstubAllGlobals(); + }); - it('composes consumer touch handlers with long-press detection', () => { - vi.useFakeTimers(); - try { - const onTouchStart = vi.fn(); + function renderMenu( + props: { + isDisabled?: boolean; + presentation?: 'popover' | 'bottom-sheet'; + triggerAs?: 'div' | 'span'; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchEnd?: (event: React.TouchEvent) => void; + } = {}, + ) { render( - +
Long-press me
, ); + return screen.getByTestId('ctx'); + } - fireEvent.touchStart(screen.getByTestId('ctx'), { - touches: [{clientX: 20, clientY: 20}], - }); - expect(onTouchStart).toHaveBeenCalledOnce(); + it('opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs - 1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs); + // The focus moves on the next frame. + advance(20); + expect(screen.getByRole('menu', {hidden: true})).toHaveFocus(); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).not.toHaveFocus(); + press.up(); + }); + + it('opens at the lift for a finger lifted after the commit (400 ms), as UIKit does', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(529); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + // A first open mounts the menu's layer, which shows it as it attaches. act(() => { - vi.advanceTimersByTime(500); + press.up(); }); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + }); - it('clears a pending long-press when a consumer prevents default on touch end', () => { - vi.useFakeTimers(); - try { - render( + it('opens nothing for a drag past the tolerance after the commit, before the menu', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(500); + press.moveTo(20, 50); + advance(500); + press.up(); + advance(100); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('keeps the menu open as the finger lifts: the lift sends no click or compatibility mouse events', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.menuDisplayMs); + const lift = press.up(); + // A cancelled touchend is the one signal that stops the browser's click + // and its compatibility mousedown, which would read as a press outside. + expect(lift.defaultPrevented).toBe(true); + expect(screen.getByRole('menu', {hidden: true})).toBeInTheDocument(); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + }); + + it('still opens at 735 ms beside a consumer onTouchStart, which runs (#7167)', () => { + const onTouchStart = vi.fn(); + const trigger = renderMenu({onTouchStart}); + const press = finger(trigger); + press.down(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + advance(contextMenu.press.menuDisplayMs - 1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('opens nothing for an early lift whose consumer onTouchEnd prevents default (#7167)', () => { + const onTouchEnd = vi.fn((event: React.TouchEvent) => { + event.preventDefault(); + }); + const trigger = renderMenu({onTouchEnd}); + const press = finger(trigger); + press.down(); + advance(200); + press.up(); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('moves the recognizer to the new trigger when triggerAs swaps it: a hold on the new node opens the menu', () => { + const {rerender} = render( + +
Long-press me
+
, + ); + rerender( event.preventDefault()}> -
Long-press me
+ triggerAs="span"> + Long-press me
, ); + const press = finger(screen.getByTestId('ctx')); + press.down(); + advance(contextMenu.press.menuDisplayMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, { - touches: [{clientX: 20, clientY: 20}], + it('opens nothing for a press lifted before the commit, and leaves its click alone', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(250); + const lift = press.up(); + expect(lift.defaultPrevented).toBe(false); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }); + + it('cancels when the finger drifts past the tolerance on one axis (11.5 px)', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(50); + press.moveTo(20, 31.6); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + press.up(); + }); + + it('still opens after a drift inside the tolerance', () => { + const trigger = renderMenu(); + const press = finger(trigger, 1, 20, 20); + press.down(); + advance(50); + press.moveTo(31, 31); // 15.6 px away, 11 px on each axis + advance(contextMenu.press.menuDisplayMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('cancels when a second finger lands before the commit', () => { + const trigger = renderMenu(); + const first = {id: 1, x: 20, y: 20}; + const second = {id: 2, x: 60, y: 20}; + dispatchTouch('touchstart', trigger, [first], [first]); + advance(100); + dispatchTouch('touchstart', trigger, [second], [first, second]); + advance(1000); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + dispatchTouch('touchend', trigger, [first, second], []); + }); + + it('lets the long press decide while a finger is down: the browser’s own long-press menu opens nothing', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(380); + // Android Chrome fires contextmenu for a held finger on its own clock. + const native = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 20, }); - fireEvent.touchEnd(trigger); + trigger.dispatchEvent(native); + expect(native.defaultPrevented).toBe(true); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(contextMenu.press.menuDisplayMs - 380); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + press.up(); + }); + + it('plays the click haptic at the commit where the platform vibrates, and none for an early lift', () => { + const vibrate = vi.fn(() => true); + Object.defineProperty(navigator, 'vibrate', { + value: vibrate, + configurable: true, + }); + try { + const trigger = renderMenu(); + const early = finger(trigger); + early.down(); + advance(250); + early.up(); + advance(500); + expect(vibrate).not.toHaveBeenCalled(); + + const held = finger(trigger); + held.down(); + advance(contextMenu.press.commitMs - 1); + expect(vibrate).not.toHaveBeenCalled(); + advance(1); + // At the commit, before the menu shows. + expect(vibrate).toHaveBeenCalledTimes(1); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + expect(vibrate).toHaveBeenCalledWith([ + ...HAPTIC_VIBRATION_PATTERNS[contextMenu.commitHaptic].value, + ]); + held.up(); + } finally { + // jsdom has no vibration API of its own. + delete (navigator as {vibrate?: unknown}).vibrate; + } + }); + it('leaves the page free to scroll while a menu a finger opened is up; a touch outside closes it as it begins to pan', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs); + // Lifted after the commit: the menu opens at the lift. act(() => { - vi.advanceTimersByTime(500); + press.up(); + }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(document.body.style.position).toBe(''); + const outside = {pointerType: 'touch', pointerId: 9, clientX: 200}; + act(() => { + fireEvent.pointerDown(document.body, {...outside, clientY: 200}); + fireEvent.pointerMove(document.body, {...outside, clientY: 195}); + }); + expect(HTMLElement.prototype.hidePopover).not.toHaveBeenCalled(); + act(() => { + fireEvent.pointerMove(document.body, {...outside, clientY: 180}); }); + expect(HTMLElement.prototype.hidePopover).toHaveBeenCalled(); + }); + + it('leaves the page alone for a menu a right-click opened', () => { + const trigger = renderMenu(); + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20}); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(document.body.style.position).toBe(''); + }); + + it('passes touch events through to the consumer’s own handlers', () => { + const onTouchStart = vi.fn(); + const onTouchEnd = vi.fn(); + const trigger = renderMenu({onTouchStart, onTouchEnd}); + const press = finger(trigger); + press.down(); + press.up(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + expect(onTouchEnd).toHaveBeenCalledTimes(1); + }); + + it('keeps today’s menu with presentation="popover": a held finger opens it at 500 ms through today’s long press, with no growth and no scroll lock', () => { + const trigger = renderMenu({presentation: 'popover'}); + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + const press = finger(trigger); + press.down(); + advance(499); + expect(shows()).toBe(0); + advance(1); + expect(shows()).toBe(1); + // Today’s menu: the first row takes the focus, nothing grows, the page + // is not locked. + expect(trigger.style.getPropertyValue('scale')).toBe(''); + expect(document.body.style.overflow).not.toBe('hidden'); + press.up(); + // The browser’s own long-press menu (Android Chrome) still opens it. + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20, detail: 1}); + expect(shows()).toBeGreaterThanOrEqual(1); + }); + + it('with presentation="popover", a drift past 10 px or a lift before 500 ms opens nothing (today’s long press)', () => { + const trigger = renderMenu({presentation: 'popover'}); + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + let press = finger(trigger); + press.down(); + advance(300); + press.up(); + advance(400); + expect(shows()).toBe(0); + press = finger(trigger); + press.down(); + press.moveTo(31, 20); + advance(600); + expect(shows()).toBe(0); + press.up(); + }); + + it('opens nothing on touch when disabled', () => { + const trigger = renderMenu({isDisabled: true}); + const press = finger(trigger); + press.down(); + advance(1000); expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + press.up(); + }); + }, +); + +describe('ContextMenu under a fine pointer: the touch model costs nothing', () => { + beforeEach(() => { + stubPointer(false); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); }); - it('suppresses native text selection on the long-press trigger', () => { - const source = readFileSync( - 'packages/core/src/ContextMenu/ContextMenu.tsx', - 'utf8', + it('opens today’s menu for a finger held 500 ms (today’s long press), with no touch model: no growth, no scroll lock, the first row focused', () => { + // A fine pointer in this context, before any check. + expect(window.matchMedia('(pointer: coarse)').matches).toBe(false); + const onTouchStart = vi.fn(); + render( + +
Right-click me
+
, ); + const trigger = screen.getByTestId('ctx'); + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + const press = finger(trigger); + press.down(); + expect(onTouchStart).toHaveBeenCalledTimes(1); + advance(499); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(1); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(trigger.style.getPropertyValue('scale')).toBe(''); + expect(document.body.style.overflow).not.toBe('hidden'); + advance(20); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).toHaveFocus(); + press.up(); + }); - expect(source).toMatch( - /trigger:[\s\S]*?WebkitTouchCallout: 'none',[\s\S]*?WebkitUserSelect: 'none',[\s\S]*?userSelect: 'none'/, + it('does not hand the finger that opened the menu on to it: a slide onto a row and a lift pick nothing', () => { + const onClick = vi.fn(); + render( + +
Right-click me
+
, ); + const trigger = screen.getByTestId('ctx'); + const press = finger(trigger); + press.down(); + advance(500); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + const row = screen.getByRole('menuitem', {name: 'Item 1', hidden: true}); + const box = row.getBoundingClientRect(); + press.moveTo(box.left + 4, box.top + 4); + press.up(); + advance(50); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('opens the cursor menu on a right-click, as before', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + fireEvent.contextMenu(trigger, {clientX: 20, clientY: 20, detail: 1}); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + advance(20); + expect( + screen.getByRole('menuitem', {name: 'Item 1', hidden: true}), + ).toHaveFocus(); }); +}); - it('cancels the long-press when the finger moves past the threshold', () => { - vi.useFakeTimers(); - try { - render( +describe('ContextMenu: the primary pointer alone picks a finger’s rules (spec:AST-094 FR13, FR33)', () => { + beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('opens for the same held finger at 735 ms where the primary pointer is coarse, and at 500 ms where it is fine', () => { + const shows = () => + vi.mocked(HTMLElement.prototype.showPopover).mock.calls.length; + const renderTrigger = () => { + const view = render( -
Long-press me
+
Hold me
, ); - const trigger = screen.getByTestId('ctx'); - fireEvent.touchStart(trigger, {touches: [{clientX: 20, clientY: 20}]}); - // Move past MOVE_CANCEL_PX (10px) — treated as a scroll, not a press. - fireEvent.touchMove(trigger, {touches: [{clientX: 20, clientY: 40}]}); - act(() => { - vi.advanceTimersByTime(500); - }); - expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + return {trigger: screen.getByTestId('ctx'), unmount: view.unmount}; + }; + // Coarse: the touch model's timeline, no menu at 500 ms. + stubPointer(true); + const coarse = renderTrigger(); + let press = finger(coarse.trigger); + press.down(); + advance(600); + expect(shows()).toBe(0); + advance(contextMenu.press.menuDisplayMs - 600); + expect(shows()).toBe(1); + press.up(); + coarse.unmount(); + vi.mocked(HTMLElement.prototype.showPopover).mockClear(); + // Fine: today's long press, the same finger opens at 500 ms. + stubPointer(false); + const fine = renderTrigger(); + press = finger(fine.trigger); + press.down(); + advance(499); + expect(shows()).toBe(0); + advance(1); + expect(shows()).toBe(1); + press.up(); }); }); @@ -1632,6 +2161,16 @@ describe('ContextMenu inline trigger (triggerAs)', () => { describe('ContextMenu outside dismissal', () => { const finger = (pointerId: number) => ({pointerType: 'touch', pointerId}); + // Under the touch model, a coarse primary pointer. Undone at the end. + function underTouchModel(run: () => void) { + stubPointer(true); + try { + run(); + } finally { + vi.unstubAllGlobals(); + } + } + // Holds a finger on the trigger until the long-press opens the menu. Only // the long-press delay is faked. function longPress(trigger: HTMLElement, pointerId: number) { @@ -1724,7 +2263,25 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); - it('a tap outside closes the menu through its compatibility mousedown; a scroll that starts outside leaves it open', () => { + it('under the touch model, a tap outside closes the menu at its lift, and a scroll that starts outside closes it as the browser takes the finger', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger, outside} = renderMenu(onOpenChange); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + // The browser takes this finger for a scroll: no compatibility events. + fireEvent.pointerDown(outside, finger(3)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerCancel(outside, finger(3)); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + fireEvent.pointerDown(outside, finger(4)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + lift(outside, 4); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + it('outside the touch model, a tap outside closes the menu through its compatibility mousedown, and a scroll that starts outside leaves it open', () => { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); const {trigger, outside} = renderMenu(onOpenChange); fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); @@ -1737,6 +2294,37 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).toHaveBeenLastCalledWith(false); }); + it('with presentation="popover", a finger keeps the released outside dismissal: a scroll or a drag outside leaves the menu open', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + render( + <> + +
Long-press me
+
+ + , + ); + const outside = screen.getByRole('button', {name: 'Outside'}); + fireEvent.contextMenu(screen.getByTestId('ctx'), { + clientX: 50, + clientY: 15, + detail: 1, + }); + fireEvent.pointerDown(outside, {...finger(3), clientX: 0, clientY: 0}); + fireEvent.pointerCancel(outside, finger(3)); + fireEvent.pointerDown(outside, {...finger(4), clientX: 0, clientY: 0}); + fireEvent.pointerMove(outside, {...finger(4), clientX: 0, clientY: 40}); + fireEvent.pointerUp(outside, finger(4)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); + it('a mouse press or a pen press outside closes the menu at the press', () => { for (const pointerType of ['mouse', 'pen']) { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); @@ -1749,7 +2337,7 @@ describe('ContextMenu outside dismissal', () => { } }); - it('presses that send no mousedown leave the menu open, as before: a cancelled pointerdown, a finger drag, a second finger', () => { + it('a mouse press that sends no mousedown leaves the menu open, as before', () => { const onOpenChange = vi.fn<(isOpen: boolean) => void>(); const {trigger, outside} = renderMenu(onOpenChange); fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); @@ -1757,18 +2345,48 @@ describe('ContextMenu outside dismissal', () => { // resize handle): the browser sends no mousedown. fireEvent.pointerDown(outside, {pointerType: 'mouse', pointerId: 1}); fireEvent.pointerUp(outside, {pointerType: 'mouse', pointerId: 1}); - // A finger drag on a surface that does not pan (a Slider), lifted. - fireEvent.pointerDown(outside, finger(5)); - fireEvent.pointerMove(outside, finger(5)); - fireEvent.pointerUp(outside, finger(5)); - // A second finger's tap. - fireEvent.pointerDown(outside, finger(6)); - fireEvent.pointerDown(outside, finger(7)); - fireEvent.pointerUp(outside, finger(7)); - fireEvent.pointerCancel(outside, finger(6)); expect(onOpenChange).toHaveBeenLastCalledWith(true); expect(onOpenChange).not.toHaveBeenCalledWith(false); }); + + it('under the touch model, a finger outside closes the menu as it pans past the pan threshold, or at its lift; a second finger waits for the first one', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger, outside} = renderMenu(onOpenChange); + const open = () => + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + // A drag on a surface that does not pan (a Slider): it closes the menu + // as it travels past the pan threshold, before the lift. + open(); + fireEvent.pointerDown(outside, {...finger(5), clientX: 0, clientY: 0}); + fireEvent.pointerMove(outside, {...finger(5), clientX: 8, clientY: 0}); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerMove(outside, {...finger(5), clientX: 30, clientY: 0}); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + fireEvent.pointerUp(outside, finger(5)); + // A second finger's tap does not end the first finger's touch. + open(); + fireEvent.pointerDown(outside, finger(6)); + fireEvent.pointerDown(outside, finger(7)); + fireEvent.pointerUp(outside, finger(7)); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerUp(outside, finger(6)); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + it('under the touch model, a touch on the menu itself leaves it open', () => { + underTouchModel(() => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + const {trigger} = renderMenu(onOpenChange); + fireEvent.contextMenu(trigger, {clientX: 50, clientY: 15, detail: 1}); + const menu = screen.getByRole('menu', {hidden: true}); + fireEvent.pointerDown(menu, {...finger(8), clientX: 0, clientY: 0}); + fireEvent.pointerMove(menu, {...finger(8), clientX: 0, clientY: 40}); + fireEvent.pointerUp(menu, finger(8)); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); }); describe('ContextMenu: Tab from a flyout leaves the whole menu (APG menu button)', () => { diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index b3b1536ec9e11..d59a02ab30a90 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -4,7 +4,8 @@ /** * @file ContextMenu.tsx * @input Uses React, StyleX, useLayer, shared layer dismissal, BottomSheet, - * useListFocus, and the shared menu-presentation resolver + * useListFocus, the presentation resolver (usePresentation), useLongPress, + * and the touch layer (ContextMenuTouchLayer) * @output Exports ContextMenu with cursor-popover and touch-sheet presentations * @position Core implementation; consumed by index.ts * @@ -35,6 +36,8 @@ * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/ContextMenu/ContextMenu.doc.mjs * - /packages/core/src/ContextMenu/ContextMenu.test.tsx + * - /packages/core/src/ContextMenu/ContextMenuTouch.ts (the touch layer's one import site) + * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline) * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks) @@ -42,7 +45,6 @@ import React, { useCallback, - useEffect, useId, lazy, useMemo, @@ -59,7 +61,7 @@ import {useLayerInternal} from '../Layer/useLayer'; import {LayerDepthProvider} from '../Layer/LayerDepthContext'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {currentGesture} from '../Layer/gestureCounter'; -import {noteOutsideDismissal} from '../Layer/outsidePress'; +import {useOutsidePress} from '../Layer/outsidePress'; import {MenuBottomSheetActionList} from '../DropdownMenu/MenuBottomSheetActionList'; import {menuBottomSheetStyles} from '../DropdownMenu/menuBottomSheet.stylex'; import {renderDropdownItems} from '../DropdownMenu/renderDropdownItems'; @@ -77,9 +79,15 @@ import { import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; +import {useLongPress} from '../hooks/useLongPress'; import {useMenuPress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; -import {useLongPress} from '../hooks/useLongPress'; +import {shadowHostOf} from '../utils/flatTree'; +import {usePresentation} from '../utils/presentation'; +import { + ContextMenuTouchLayer, + type ContextMenuTouchHandle, +} from './ContextMenuTouchLayer'; import {layerAnimations} from '../Layer/layerAnimations.stylex'; import {layerViewportInset} from '../Layer/layerViewportInset.stylex'; import { @@ -102,10 +110,7 @@ import type { } from '../DropdownMenu/DropdownMenu'; import {useMergedRefs} from '../hooks/useMergedRefs'; -import { - useAdaptivePresentation, - type AdaptivePresentation, -} from '../hooks/useAdaptivePresentation'; +import type {AdaptivePresentation} from '../hooks/useAdaptivePresentation'; import { registerEnglishFallback, useComponentTranslator, @@ -120,11 +125,12 @@ const LazyMenuBottomSheet = lazy(async () => })), ); const styles = stylex.create({ - // Trigger wrapper: suppress the iOS long-press callout/selection so the - // long-press opens our context menu instead of the native text/callout UI. - // `position: relative` establishes the containing block for the absolutely - // positioned cursor anchor below, so the anchor point tracks the trigger - // (and scrolls with it) instead of the page. + // Trigger wrapper. `position: relative` makes the containing block for the + // absolutely positioned cursor anchor below. So the anchor point follows + // the trigger, and scrolls with it, instead of the page. The iOS callout and + // text selection stay off. A long press or a right-click then opens this + // menu instead of selecting the trigger's text. The + // gesture host adds its own lock for a held press. trigger: { position: 'relative', WebkitTouchCallout: 'none', @@ -167,6 +173,11 @@ const styles = stylex.create({ transitionTimingFunction: easeVars['--ease-standard'], userSelect: 'none', WebkitTouchCallout: 'none', + // A touch open focuses the menu itself, so no row is lit until the finger + // moves onto one, as with DropdownMenu's held finger. The menu is an + // internal focus target, not a control, so it shows no browser ring. + // Keyboard focus moves to an item instead. + outline: 'none', }, // Scroll ownership by the browser's own signal; see DropdownMenu. touchPinchZoom: { @@ -284,11 +295,13 @@ interface ContextMenuBaseProps extends BaseProps { onOpenChange?: (isOpen: boolean) => void; /** * Presentation policy for the menu. - * - `popover`: open beside the pointer position. + * - omitted: the menu at the pointer. Under a finger, a held finger opens it + * on iOS's timeline (the touch model). + * - `popover`: the menu at the pointer on every device, as before. A held + * finger opens it at 500 ms through today's long press. * - `bottom-sheet`: open as an action sheet. * - `adaptive`: use a BottomSheet on compact coarse-pointer viewports and * the cursor-positioned popover elsewhere. - * @default 'popover' */ presentation?: AdaptivePresentation; 'data-testid'?: string; @@ -342,7 +355,7 @@ export function ContextMenu({ label: labelFromProps, isDisabled = false, onOpenChange, - presentation = 'popover', + presentation, triggerAs = 'div', ref, className, @@ -360,8 +373,12 @@ export function ContextMenu({ const t = useComponentTranslator(); const label = labelFromProps ?? t('@astryx.contextMenu.label'); const backLabel = t('@astryx.dropdownMenu.back'); - const resolvedPresentation = useAdaptivePresentation(presentation); - const usesBottomSheet = resolvedPresentation === 'bottom-sheet'; + // The surface, and whether the touch model applies: under a finger, unless + // the caller pins `presentation="popover"`. + const {surface, touchModes} = usePresentation(presentation, { + whenOmitted: 'touch', + }); + const usesBottomSheet = surface === 'bottom-sheet'; // Separate content props (union discriminant) from DOM pass-through attrs. // The union means exactly one of items/menuContent exists in rest; destructure // both so triggerProps contains only DOM-safe attributes. @@ -395,15 +412,20 @@ export function ContextMenu({ const [isOpen, setIsOpen] = useState(false); // An open from the trigger moves focus into the menu once it shows: a frame - // after the show, which for a first open comes as its layer attaches. - const focusOnShowRef = useRef(false); + // after the show, which for a first open comes as its layer attaches. A + // touch open focuses the menu itself; other opens focus its first row. + const focusOnShowRef = useRef<'first' | 'menu' | null>(null); const focusFirstRef = useRef<() => void>(() => {}); + const focusMenuRef = useRef<() => void>(() => {}); const [submenuPath, setSubmenuPath] = useState([]); const currentSubmenu = submenuPath.at(-1); const currentItems = currentSubmenu?.items ?? items; const currentTitle = currentSubmenu?.label ?? label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label; + // The mounted touch layer, which says whether it times a finger. + const touchHandleRef = useRef(null); + const updateOpenState = useCallback( (nextIsOpen: boolean) => { if (!nextIsOpen) { @@ -415,12 +437,17 @@ export function ContextMenu({ [onOpenChange], ); + const outsidePress = useOutsidePress(); + // Starts the outside-press watch as the menu shows. It is set below, where + // the menu's list and its close exist. + const watchOutsideRef = useRef<() => void>(() => {}); const layer = useLayerInternal({ mode: 'context', // Mounted at the first open and kept (see the file header). lazyMount: true, keepsMountAfterShow: true, onHide: useCallback(() => { + outsidePress.stop(); setIsOpen(false); onOpenChange?.(false); // Restore focus to the element that was focused before opening. @@ -429,13 +456,19 @@ export function ContextMenu({ if (toRestore && document.contains(toRestore)) { toRestore.focus(); } - }, [onOpenChange]), + }, [onOpenChange, outsidePress]), onShow: useCallback(() => { setIsOpen(true); onOpenChange?.(true); - if (focusOnShowRef.current) { - focusOnShowRef.current = false; - requestAnimationFrame(() => focusFirstRef.current()); + watchOutsideRef.current(); + const focusTarget = focusOnShowRef.current; + if (focusTarget != null) { + focusOnShowRef.current = null; + requestAnimationFrame(() => + focusTarget === 'menu' + ? focusMenuRef.current() + : focusFirstRef.current(), + ); } }, [onOpenChange]), lightDismiss: false, @@ -489,6 +522,7 @@ export function ContextMenu({ focusFollowsLastInput: true, }); focusFirstRef.current = focusFirst; + focusMenuRef.current = () => listRef.current?.focus({preventScroll: true}); // Join the shared stack so an Escape press dismisses exactly one top-most // surface. BottomSheet owns its own dismissal path. @@ -511,44 +545,36 @@ export function ContextMenu({ ), }); - // Dismiss on any click outside the menu. We use popover="manual" (not - // "auto") because the native light-dismiss treats the mouseup from the - // opening right-click as a dismiss event. Handling it ourselves via - // mousedown avoids that race. A touch's compatibility mousedown for the - // press that opened the menu (sent mid-hold by iOS, or as a long press - // lifts by Chromium) belongs to that press's gesture, so it is not read as - // a press outside; any later press is a new gesture and closes as before. - useEffect(() => { - if (!isOpen || usesBottomSheet) { + // The menu is a manual popover: native light dismiss would read the mouseup + // of the opening right-click as a press outside. So one watch closes it. A + // mouse or a pen closes it at the press. Under the touch model, a touch + // closes it as it begins to pan, or at its lift, and the page stays free to + // scroll. Otherwise a touch closes it at its compatibility mousedown. The + // press that opened the menu keeps it open. + watchOutsideRef.current = () => { + const trigger = triggerRef.current; + if (trigger == null) { return; } - // Inside a shadow root, even a closed or nested one, the document sees a - // press on the menu as a press on an outer shadow host, so the menu's own - // tree judges its presses first and the document skips those. - const tree = listRef.current?.getRootNode(); - const shadow = tree instanceof ShadowRoot ? tree : null; - const judged = new WeakSet(); - const handleClickOutside = (e: Event) => { - const menu = listRef.current; - if (judged.has(e)) { - return; - } - judged.add(e); - if (currentGesture() === openingGestureRef.current) { - return; - } - if (menu && !menu.contains(e.target as Node)) { - noteOutsideDismissal(menu.ownerDocument); - closeMenu(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - shadow?.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - shadow?.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen, closeMenu, listRef, usesBottomSheet]); + // The menu mounts after its first show, so each press reads it then. It + // renders in the trigger's own root, which may be another window's. + const root = trigger.getRootNode(); + outsidePress.start( + trigger, + path => listRef.current != null && path.includes(listRef.current), + () => { + if (currentGesture() !== openingGestureRef.current) { + closeMenu(); + } + }, + { + pointerTypes: touchModes ? ['touch'] : [], + closesOnPan: touchModes, + pressTypes: touchModes ? ['mouse', 'pen'] : ['mouse', 'pen', 'touch'], + root: shadowHostOf(root) != null ? root : null, + }, + ); + }; const listKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -603,7 +629,15 @@ export function ContextMenu({ // what makes the menu context-relative: it scrolls with the content and the // browser auto-flips it against the viewport edges via CSS anchor positioning. const openAtLocalPoint = useCallback( - (localX: number, localY: number, focusEl: HTMLElement | null) => { + ( + localX: number, + localY: number, + focusEl: HTMLElement | null, + // A touch open focuses the menu itself: UIKit lights no row until the + // finger moves onto one. Pointer and keyboard opens focus the first + // item (APG menu pattern); arrow keys reach the items from the menu. + focusTarget: 'first' | 'menu' = 'first', + ) => { const rect = triggerRef.current?.getBoundingClientRect(); const x = rect ? keepAnchorInside(localX, rect.width) : localX; const y = rect ? keepAnchorInside(localY, rect.height) : localY; @@ -625,7 +659,7 @@ export function ContextMenu({ // A first open mounts the layer, and the show runs from the popover // element's callback ref as it attaches, in the same commit, before // the browser paints; a later one shows at once. - focusOnShowRef.current = true; + focusOnShowRef.current = focusTarget; layer.show(); } }, @@ -639,6 +673,22 @@ export function ContextMenu({ [], ); + // A client point in the trigger's own coordinates, where the cursor anchor + // lives. It stays inside the trigger's box. + const toTriggerLocal = useCallback( + (point: {x: number; y: number}): {x: number; y: number} => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (rect == null) { + return point; + } + return { + x: Math.min(Math.max(point.x - rect.left, 0), rect.width), + y: Math.min(Math.max(point.y - rect.top, 0), rect.height), + }; + }, + [], + ); + const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (isDisabled) { @@ -670,19 +720,33 @@ export function ContextMenu({ [isDisabled, openAtLocalPoint, getAnchorBaseRect], ); - // Touch long-press invocation (menus-8). iOS Safari never synthesizes a - // `contextmenu` event on long-press, so a context menu is otherwise - // unreachable on touch. Open the menu at the touch point once the press is - // held long enough (see useLongPress for timer/move-cancel/cleanup logic). - // The hook also returns the imperative start/moveTo/cancel API the menu press - // model drives; only its four touch handlers belong on the trigger element. + // Touch invocation. Under the touch model, the touch layer runs UIKit's long + // press and asks for the menu here, at the client point where the finger + // landed. + const openFromFinger = useCallback( + (point: {x: number; y: number}) => { + const local = toTriggerLocal(point); + // The finger is still down: its lift belongs to the press that opened + // the menu, so it does not close it. + openingGestureRef.current = currentGesture(); + openAtLocalPoint(local.x, local.y, triggerRef.current, 'menu'); + }, + [openAtLocalPoint, toTriggerLocal], + ); + + // Today's long press for a finger outside the touch model: under a fine + // primary pointer, or with `presentation="popover"`, the way back. Safari on + // iOS never sends a `contextmenu` event for a long press. So the menu opens + // at the touch point once the finger is held for 500 ms. The useLongPress + // hook owns the timer, the move cancel and the cleanup. Under the touch + // model, the touch layer's recognizer owns the finger instead. const { onTouchStart: handleLongPressTouchStart, onTouchMove: handleLongPressTouchMove, onTouchEnd: handleLongPressTouchEnd, onTouchCancel: handleLongPressTouchCancel, } = useLongPress({ - disabled: isDisabled, + disabled: isDisabled || touchModes, onLongPress: useCallback( (point: {x: number; y: number}) => { const rect = getAnchorBaseRect(); @@ -698,17 +762,6 @@ export function ContextMenu({ [openAtLocalPoint, getAnchorBaseRect], ), }); - - // ContextMenu owns invocation and long-press cleanup. Consumer handlers are - // notified too, but preventing their default does not cancel component-owned - // behavior that the public API does not promise as cancelable. - const handleTriggerContextMenu = useCallback( - (event: React.MouseEvent) => { - handleContextMenu(event); - onContextMenuProp?.(event); - }, - [handleContextMenu, onContextMenuProp], - ); const handleTriggerTouchStart = useCallback( (event: React.TouchEvent) => { handleLongPressTouchStart(event); @@ -738,6 +791,23 @@ export function ContextMenu({ [handleLongPressTouchCancel, onTouchCancelProp], ); + // ContextMenu owns invocation. Consumer handlers are notified too, but + // preventing their default does not cancel component-owned behavior that + // the public API does not promise as cancelable. + const handleTriggerContextMenu = useCallback( + (event: React.MouseEvent) => { + if (touchHandleRef.current?.isTracking() === true) { + // The browser's own long-press menu (Android Chrome) for a finger the + // recognizer is timing: the recognizer decides, not this event. + event.preventDefault(); + } else { + handleContextMenu(event); + } + onContextMenuProp?.(event); + }, + [handleContextMenu, onContextMenuProp], + ); + const popoverXstyle = menuWidth ? styles.popoverCustomWidth(menuWidth) : styles.popover; @@ -903,6 +973,13 @@ export function ContextMenu({ })} /> + {touchModes && !isDisabled ? ( + + ) : null} {usesBottomSheet ? ( diff --git a/packages/core/src/ContextMenu/ContextMenuTouch.ts b/packages/core/src/ContextMenu/ContextMenuTouch.ts new file mode 100644 index 0000000000000..f8f22f0b96c95 --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouch.ts @@ -0,0 +1,17 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ContextMenuTouch.ts + * @input ContextMenu's touch modules + * @output ContextMenu's touch layer, its one import site: the press + * recognizer (contextMenuPress.ts) + * @position ContextMenuTouchLayer.tsx, which ContextMenu renders under the + * touch model, imports its touch behaviour from here only; the mouse and + * keyboard paths stay in ContextMenu.tsx + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx + */ + +export {ContextMenuPressRecognizer} from './contextMenuPress'; diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx new file mode 100644 index 0000000000000..9650067be3297 --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file ContextMenuTouchLayer.tsx + * @input ContextMenu's touch layer (ContextMenuTouch.ts), the gesture + * foundation and the UIKit context-menu constants + * @output ContextMenuTouchLayer: renders nothing; runs the trigger's press + * recognizer while ContextMenu is under the touch model + * @position Rendered by ContextMenu while the touch model applies + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + */ + +import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; +import {playHaptic} from '../gestures/haptics'; +import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {ContextMenuPressRecognizer} from './ContextMenuTouch'; + +/** What ContextMenu reaches of a mounted touch layer. */ +export interface ContextMenuTouchHandle { + /** Whether the recognizer is timing a finger on the trigger. */ + isTracking(): boolean; +} + +export interface ContextMenuTouchLayerProps { + triggerRef: RefObject; + /** Receives the layer's handle as it mounts, and null as it unmounts. */ + ref?: Ref; + /** Open the menu at a client point (where the finger landed). */ + onOpen: (point: {x: number; y: number}) => void; +} + +/** + * UIContextMenuInteraction's long press. iOS Safari never fires `contextmenu` + * for a held finger. So the trigger runs its own recognizer on UIKit's + * timeline, `contextMenu.press`. At the commit, the haptic plays and the + * recognizer claims the finger. The page stops scrolling under it, and its + * lift sends no click and no compatibility mouse events. The menu opens where + * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. + * A lift before the commit, a second finger or a scroll gets no menu. So does + * a drift past the tolerance before the menu shows. + */ +export function ContextMenuTouchLayer({ + triggerRef, + ref, + onOpen, +}: ContextMenuTouchLayerProps): null { + const onOpenRef = useRef(onOpen); + onOpenRef.current = onOpen; + const hasPressOpenedRef = useRef(false); + + // The gesture hook follows the element behind the ref, so a `triggerAs` + // swap moves the recognizer to the new node. + const recognizer = useGestureRecognizer( + triggerRef, + () => new ContextMenuPressRecognizer(), + press => { + const open = () => { + hasPressOpenedRef.current = true; + onOpenRef.current(press.press ?? press.location()); + }; + switch (press.state) { + case 'began': + hasPressOpenedRef.current = false; + // The click haptic, where a vibration motor is the only haptic. + // iOS Safari can play none here: no tap lands at the commit. + playHaptic(contextMenu.commitHaptic); + break; + case 'changed': + if (press.isMenuDue && !hasPressOpenedRef.current) { + open(); + } + break; + case 'ended': + if (!hasPressOpenedRef.current) { + open(); + } + break; + case 'possible': + case 'failed': + case 'cancelled': + break; + } + }, + ); + + useImperativeHandle( + ref, + () => ({ + isTracking: () => recognizer.numberOfTouches > 0, + }), + [recognizer], + ); + + return null; +} diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts index d914ca03605f0..e3ceb579c81a6 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.a11y.chromium.spec.ts @@ -2,10 +2,12 @@ /** * @file ContextMenuLongPress.a11y.chromium.spec.ts - * @input The `Core/ContextMenu` Default story in a built Storybook and CDP - * touch input. + * @input The `Core/ContextMenu` Default and popover-presentation touch stories + * in a built Storybook, and CDP touch input. * @output Real-engine proof that the long press that opens a context menu * does not close it as the finger lifts, and that a tap outside still does. + * Under the touch model the lift sends no click. Under today's long press + * (`presentation="popover"`) its click arrives and acts on nothing. * @position Run with `pnpm test:a11y-contract`; jsdom cannot produce the * compatibility mouse events a real browser sends after a touch. */ @@ -35,6 +37,8 @@ test.afterAll(async () => { }); const STORY = 'core-contextmenu--default'; +// A finger keeps today's long press under `presentation="popover"`. +const POPOVER_STORY = 'core-contextmenu--touch-long-press-popover'; async function touchCdp(page: Page): Promise { const cdp = await page.context().newCDPSession(page); @@ -45,61 +49,88 @@ async function touchCdp(page: Page): Promise { return cdp; } -async function triggerCenter(page: Page): Promise<{x: number; y: number}> { - const box = await page.getByText('Right-click this area').boundingBox(); +async function triggerCenter( + page: Page, + label = 'Right-click this area', +): Promise<{x: number; y: number}> { + const box = await page.getByText(label).boundingBox(); if (box == null) { throw new Error('the trigger area has no rendered bounds'); } return {x: box.x + box.width / 2, y: box.y + box.height / 2}; } -/** - * Resolves once the browser has delivered the `click` that follows a touch - * (the last of its compatibility mouse events) and two frames have run, so - * anything that event closes has closed. - */ -async function armCompatClick(page: Page): Promise { +interface InputEntry { + type: 'click' | 'touchstart'; + /** For a `touchstart`: whether a menu was open as it arrived. */ + isMenuOpen?: boolean; +} + +/** Records every `click` and `touchstart` the page gets from now on, in order. */ +async function recordInput(page: Page): Promise { await page.evaluate(() => { - const w = window as unknown as {__compatClick?: Promise}; - w.__compatClick = new Promise(resolve => { - document.addEventListener( - 'click', - () => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - {once: true, capture: true}, - ); - }); + const entries: {type: string; isMenuOpen?: boolean}[] = []; + (window as unknown as {__input?: typeof entries}).__input = entries; + document.addEventListener( + 'click', + () => { + entries.push({type: 'click'}); + }, + {capture: true}, + ); + document.addEventListener( + 'touchstart', + () => { + entries.push({ + type: 'touchstart', + // A closed menu can stay mounted, hidden. + isMenuOpen: + document.querySelector('[role="menu"]')?.checkVisibility() ?? false, + }); + }, + {capture: true}, + ); }); } -async function compatClickDelivered(page: Page): Promise { - await page.evaluate( - async () => - (window as unknown as {__compatClick?: Promise}).__compatClick, +async function recordedInput(page: Page): Promise { + return page.evaluate( + () => (window as unknown as {__input?: InputEntry[]}).__input ?? [], ); } -test('the long press that opens the menu leaves it open as the finger lifts', async ({ - page, -}) => { - await page.goto(`${storybook.origin}/iframe.html?id=${STORY}&viewMode=story`); +const nextFrame = async (page: Page) => + page.evaluate( + async () => + new Promise(resolve => requestAnimationFrame(() => resolve())), + ); + +/** + * Holds a finger on the trigger until the menu opens, lifts it, waits one + * frame, then taps outside. Returns the input recorded from the lift on: the + * outside tap's `touchstart` is the later event that bounds the lift's click. + */ +async function holdLiftThenTapOutside( + page: Page, + story: string, + label?: string, +): Promise { + await page.goto(`${storybook.origin}/iframe.html?id=${story}&viewMode=story`); + // Touch emulation makes the primary pointer coarse. const cdp = await touchCdp(page); - const center = await triggerCenter(page); + const center = await triggerCenter(page, label); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{x: center.x, y: center.y, id: 1}], }); const menu = page.getByRole('menu'); await expect(menu).toBeVisible({timeout: 2000}); - await armCompatClick(page); + await recordInput(page); await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [], }); - await compatClickDelivered(page); - await expect(menu).toBeVisible(); - - // A tap outside still closes it. + await nextFrame(page); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{x: 5, y: 5, id: 2}], @@ -108,7 +139,40 @@ test('the long press that opens the menu leaves it open as the finger lifts', as type: 'touchEnd', touchPoints: [], }); + // A tap outside still closes it. await expect(menu).toBeHidden(); + return recordedInput(page); +} + +/** The clicks before the outside tap, and whether the menu was open at it. */ +function beforeOutsideTap(entries: InputEntry[]) { + const tap = entries.findIndex(entry => entry.type === 'touchstart'); + expect(tap).toBeGreaterThanOrEqual(0); + return { + clicks: entries.slice(0, tap).filter(entry => entry.type === 'click') + .length, + isMenuOpen: entries[tap].isMenuOpen, + }; +} + +test('under the touch model, the long press that opens the menu leaves it open as the finger lifts, and the lift sends no click', async ({ + page, +}) => { + const entries = await holdLiftThenTapOutside(page, STORY); + expect(beforeOutsideTap(entries)).toEqual({clicks: 0, isMenuOpen: true}); +}); + +// The positive control: the same steps see the click that follows today's +// long press, so the check above can fail. +test('with presentation="popover", the click that follows the long press arrives before the outside tap and leaves the menu open', async ({ + page, +}) => { + const entries = await holdLiftThenTapOutside( + page, + POPOVER_STORY, + 'Hold for actions', + ); + expect(beforeOutsideTap(entries)).toEqual({clicks: 1, isMenuOpen: true}); }); test('a mouse press outside closes a right-click menu', async ({page}) => { diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts new file mode 100644 index 0000000000000..a8aa2d26a29a3 --- /dev/null +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -0,0 +1,182 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ContextMenuLongPress.interaction.browser.spec.ts + * @input The built "Touch / long press" ContextMenu story, real touch input, + * the interaction harness, the context-menu constants and UIKit's frames + * @output Interaction and animation proofs for a context menu that a held + * finger opens on UIKit's timeline. They cover the menu's moments, the + * press feedback against UIKit's frames and a blocked main thread. They + * also cover the page under the finger, a pan outside and a storyboard of + * the press + * @position Interaction specs of packages/core/src/ContextMenu; Chromium's + * finger stands for Android's engine, and WebKit's for iOS + */ + +import { + expect, + test, + type Locator, + type Page, +} from '@astryxdesign/a11y-spec/playwright'; +import { + drag, + installInputLog, + openStory, + readInputLog, + tap, + touchDriver, + withStorybook, + type TouchDriver, +} from '@astryxdesign/interaction-harness'; +import {contextMenu} from '../../motion/uikit/contextMenu'; + +const storybook = withStorybook(test); +const {menuDisplayMs} = contextMenu.press; +// Core's press machine believes a press 150 ms after it lands +// (`utils/pressGesture.ts`). That module loads a StyleX stylesheet, which +// this Node-side spec cannot, so the onset is restated here. + +interface TouchStory { + driver: TouchDriver; + trigger: Locator; + center: {x: number; y: number}; +} + +/** The touch story under the engine's own finger (a coarse pointer). */ +async function openTouchStory( + page: Page, + story = 'core-contextmenu--touch-long-press', +): Promise { + await installInputLog(page); + // Note when the menu is asked to show, on the page clock (no per-frame + // work: rendering proofs share this page). + await page.addInitScript(() => { + const w = window as unknown as {__menuShownAt?: number | null}; + w.__menuShownAt = null; + const show = HTMLElement.prototype.showPopover; + HTMLElement.prototype.showPopover = function ( + this: HTMLElement, + ...args: Parameters + ) { + // Only a popover that holds the menu (Storybook shows its own). + if ( + this.matches('[role="menu"]') || + this.querySelector('[role="menu"]') != null + ) { + w.__menuShownAt ??= performance.now(); + } + return show.apply(this, args); + }; + }); + await openStory(page, storybook(), story); + const driver = await touchDriver(page); + const trigger = page.getByTestId('touch-context-menu'); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + return { + driver, + trigger, + center: {x: box.x + box.width / 2, y: box.y + box.height / 2}, + }; +} + +const menuShownAt = async (page: Page) => + page.evaluate( + () => + (window as unknown as {__menuShownAt?: number | null}).__menuShownAt ?? + null, + ); + +/** The page-clock timestamp of the last `type` event. */ +async function lastInput( + page: Page, + type: 'touchstart' | 'touchend', +): Promise { + const log = await readInputLog(page); + const record = log.filter(entry => entry.type === type).at(-1); + if (record == null) { + throw new Error(`no ${type} reached the page`); + } + return record.timeStamp; +} + +const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); + +/** A finger held still for `ms`, then lifted. */ +const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); + +test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, menuDisplayMs + 200)); + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // The timer runs at its deadline, give or take a task. + expect(after).toBeGreaterThanOrEqual(menuDisplayMs); + expect(after).toBeLessThan(menuDisplayMs + 20); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + +/** The story with somewhere to scroll below it, and its trigger's center. */ +async function openScrollableStory(page: Page) { + const story = await openTouchStory(page); + await page.evaluate(() => { + document.body.style.paddingBottom = '3000px'; + }); + const box = await story.trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Where the trigger's container sits: the trigger itself is scaled by the + // press feedback. + const top = async () => + story.trigger.evaluate( + el => (el.parentElement ?? el).getBoundingClientRect().y, + ); + return {...story, center, top}; +} + +test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + await driver.play(hold(center, menuDisplayMs + 50)); + const menu = page.getByRole('menu', {name: 'Photo actions'}); + await expect(menu).toBeVisible(); + const before = await top(); + // The menu opens at the finger. So the new finger lands beside the menu's + // box, at the first point below or above it with room for the pan. + const box = await menu.boundingBox(); + const viewport = page.viewportSize(); + if (box == null || viewport == null) { + throw new Error('the menu or the viewport has no box'); + } + const outside = [ + {x: center.x, y: Math.round(box.y + box.height + 40)}, + {x: center.x, y: Math.round(box.y - 40)}, + ].find(point => point.y - 130 > 0 && point.y + 10 < viewport.height); + if (outside == null) { + throw new Error('no point beside the menu has room for the pan'); + } + expect( + await page.evaluate( + ({x, y}) => document.elementFromPoint(x, y)?.closest('[role="menu"]'), + outside, + ), + ).toBeNull(); + await driver.play( + drag(outside, {x: outside.x, y: outside.y - 120}, {durationMs: 300}), + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); + // WebKit scrolls nothing from an injected touch (README: engine limits). + if (driver.drivesNativeGestures) { + expect(await top()).toBeLessThan(before); + } +}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts new file mode 100644 index 0000000000000..fdc7db94eb237 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPress.test.ts + * @input vitest, the gesture arena driven by synthetic touches at explicit + * timestamps (the gesture tests' harness), ContextMenuPressRecognizer + * @output The context menu's touch timeline as a state machine. The press + * commits at UIKit's moment and not a millisecond before, at that moment + * even when the timer runs late. It fails on travel past the long-press + * tolerance, a lift, a second finger or the browser taking the touch. The + * commit claims the finger and drops its click. A press that fails leaves + * the click alone + * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts + */ + +import {describe, expect, it} from 'vitest'; +import {createHarness} from '../gestures/__tests__/harness'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {ContextMenuPressRecognizer} from './contextMenuPress'; + +const COMMIT = contextMenu.press.commitMs; +const DISPLAY = contextMenu.press.menuDisplayMs; + +function setup( + options: ConstructorParameters[0] = {}, +) { + const harness = createHarness(); + const press = new ContextMenuPressRecognizer({name: 'press', ...options}); + const states: string[] = []; + press.addTransitionListener((recognizer, _from, to) => { + states.push(`${to}@${recognizer.clockTime}`); + }); + return {harness, press, states}; +} + +describe('ContextMenuPressRecognizer', () => { + it('commits at UIKit’s moment (iOS 26.2: 400 ms, first 60 Hz tick 417 ms), not a millisecond before', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(100, 200, 1000, {recognizers: [press]}); + harness.advance(1000 + COMMIT - 1); + expect(press.state).toBe('possible'); + harness.advance(1000 + COMMIT); + expect(press.state).toBe('began'); + expect(states).toEqual([`began@${1000 + COMMIT}`]); + expect(COMMIT).toBe(400); + }); + + it('commits at the deadline even when the main thread runs the timer late', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + // Nothing ran until 900 ms (a blocked main thread): the press still + // began at 400, and the menu was due at 735. + harness.advance(900); + expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + expect(press.beganAt).toBe(COMMIT); + }); + + it('claims the finger at the commit and drops the lift’s click', () => { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + expect(harness.claims).toEqual([]); + harness.advance(COMMIT); + expect(harness.claims).toEqual([1]); + expect(finger.up(COMMIT + 300)).toBe('suppress'); + expect(press.state).toBe('possible'); + expect(press.press).toBeNull(); + }); + + it('makes the menu due at 735 ms while the finger stays (changed, isMenuDue)', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(DISPLAY - 1); + expect(press.isMenuDue).toBe(false); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + expect(DISPLAY).toBe(735); + }); + + it('ends at a lift after the commit, before the menu is due (UIKit shows the menu at the lift)', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + expect(finger.up(529)).toBe('suppress'); + harness.advance(DISPLAY + 100); + expect(states).toEqual([`began@${COMMIT}`, 'ended@529', 'possible@529']); + }); + + it('is cancelled by a drift past the tolerance after the commit, before the menu', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(0, 5, 480); // within: still committed + finger.move(0, 30, 500); // a 30 px drag at 0.5 s + expect(states).toEqual([`began@${COMMIT}`, 'changed@480', 'cancelled@500']); + harness.advance(DISPLAY + 100); + expect(press.isMenuDue).toBe(false); + }); + + it('lets the finger move freely once the menu is due', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(DISPLAY); + finger.move(0, 80, DISPLAY + 50); + finger.up(DISPLAY + 100); + expect(states).toEqual([ + `began@${COMMIT}`, + `changed@${DISPLAY}`, + `changed@${DISPLAY + 50}`, + `ended@${DISPLAY + 100}`, + `possible@${DISPLAY + 100}`, + ]); + }); + + it.each([ + [7, 7], + [8, 8], + [10, 0], + [11, 11], + [0, -11.5], + ])('still commits after a drift of (%d, %d) pt, as UIKit does', (dx, dy) => { + const {harness, press} = setup(); + harness + .finger(1) + .down(100, 100, 0, {recognizers: [press]}) + .move(100 + dx, 100 + dy, 100); + harness.advance(COMMIT); + expect(press.state).toBe('began'); + }); + + it.each([ + [12, 0], + [0, 12], + [-11.6, 0], + [0, -11.6], + ])( + 'fails on a drift of (%d, %d) pt, past the tolerance on one axis', + (dx, dy) => { + const {harness, press, states} = setup(); + harness + .finger(1) + .down(100, 100, 0, {recognizers: [press]}) + .move(100 + dx, 100 + dy, 250); + expect(press.state).toBe('failed'); + harness.advance(COMMIT); + expect(states).toEqual(['failed@250']); + }, + ); + + it('fails when the finger lifts before the commit, and lets that tap’s click through', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + expect(finger.up(250)).toBe('allow'); + harness.advance(COMMIT); + expect(states).toEqual(['failed@250', 'possible@250']); + }); + + it('fails on a second finger before the commit', () => { + const {harness, press} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.finger(2).down(50, 0, 120, {recognizers: [press]}); + expect(press.state).toBe('failed'); + }); + + it('ignores a second finger after the commit', () => { + const {harness, press} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + harness.finger(2).down(50, 0, COMMIT + 20, {recognizers: [press]}); + expect(press.state).toBe('began'); + }); + + it('fails when the browser takes the finger to scroll', () => { + const {harness, press, states} = setup(); + harness + .finger(1) + .down(0, 0, 0, {recognizers: [press]}) + .takenByBrowser(90); + // The browser owns the finger now: the recognizer lets go of it at once. + expect(states).toEqual(['failed@90', 'possible@90']); + }); + + it('keeps the press while it fails, so the feedback knows which one ended', () => { + const {harness, press} = setup(); + let pressAtFailure: unknown = 'unset'; + press.addTransitionListener((recognizer, _from, to) => { + if (to === 'failed') { + pressAtFailure = recognizer.press; + } + }); + harness + .finger(1) + .down(10, 20, 5, {recognizers: [press]}) + .up(200); + expect(pressAtFailure).toEqual({time: 5, x: 10, y: 20}); + expect(press.press).toBeNull(); + }); + + it('takes a finger or a pen, not a mouse (a mouse opens the menu with its secondary button)', () => { + const mouse = setup(); + mouse.harness + .finger(1) + .down(0, 0, 0, {recognizers: [mouse.press], pointerType: 'mouse'}); + mouse.harness.advance(COMMIT); + expect(mouse.press.state).toBe('possible'); + + const pen = setup(); + pen.harness + .finger(1) + .down(0, 0, 0, {recognizers: [pen.press], pointerType: 'pen'}); + pen.harness.advance(COMMIT); + expect(pen.press.state).toBe('began'); + }); +}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts new file mode 100644 index 0000000000000..dfc25022eeb40 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -0,0 +1,198 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPress.ts + * @input Touches from the gesture arena and the context-menu constants + * @output ContextMenuPressRecognizer: the long press that opens a context menu + * on UIContextMenuInteraction's timeline under a finger, and the press it + * reports + * @position ContextMenu's touch invocation, on the gesture foundation; + * ContextMenu.tsx attaches it to the trigger + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + * and /packages/core/src/ContextMenu/contextMenuPress.test.ts + */ + +import {isActive} from '../gestures/GestureRecognizer'; +import { + LongPressGestureRecognizer, + type LongPressGestureRecognizerOptions, +} from '../gestures/LongPressGestureRecognizer'; +import type {GestureEventContext, GestureTouch} from '../gestures/types'; +import {contextMenu} from '../motion/uikit/contextMenu'; + +const {commitMs, menuDisplayMs, allowableMovement} = contextMenu.press; + +/** + * The foundation's own press timer, which this recognizer never lets fire: + * the hold below commits instead. It is longer than any hold, and under the + * window timer's 2^31 - 1 ms limit. + */ +const NEVER_MS = 2 ** 30; + +/** A finger that may become a context menu: when and where it landed. */ +export interface ContextMenuPress { + /** On the event clock (`performance.now()`, an event's `timeStamp`), ms. */ + readonly time: number; + /** Client CSS px. */ + readonly x: number; + readonly y: number; +} + +export type ContextMenuPressRecognizerOptions = Omit< + LongPressGestureRecognizerOptions, + | 'allowableMovement' + | 'minimumPressDuration' + | 'numberOfTapsRequired' + | 'numberOfTouchesRequired' +>; + +/** + * A context menu is not UIKit's plain long press (500 ms, 10 pt of travel). + * Its timeline is `contextMenu.press`, and these are the recognizer's states: + * - `possible`: the finger landed. A lift, a second finger, or a browser + * scroll fails it. A drift past `allowableMovement` on either + * axis fails it too. + * - `began` at `commitMs`: the click haptic. The recognizer claims the touch. + * The page stops scrolling under it, and the lift sends no click and no + * compatibility mouse events. The menu does not show yet. A drift past + * `allowableMovement` still ends it with no menu (`cancelled`). A lift shows + * the menu at once (`ended` without `isMenuDue`). + * - `changed` with `isMenuDue` at `menuDisplayMs`: the finger is still down, + * and the menu shows. From then on, the finger moves freely. + * + * The hold runs on the arena's clock and timers, so the commit lands at its + * deadline even when the main thread is busy. The foundation's long press + * gives the claim and failure rules and the trigger's style. It never begins + * on its own timer, only at the hold's commit. A press that fails or is + * cancelled reports it through `addTransitionListener`, while `press` still + * names it. + */ +export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { + /** The finger in play, from its landing until the gesture resets. */ + press: ContextMenuPress | null = null; + /** + * The finger stayed until `menuDisplayMs`: the menu is due, and the finger + * moves freely from here. + */ + isMenuDue = false; + /** The finger the hold follows. */ + private pressTouchId: number | null = null; + /** Where the finger landed, while its hold runs. The drift counts from here. */ + private holdFrom: {x: number; y: number} | null = null; + /** When the finger landed, on the arena's clock. The deadlines count from it. */ + private holdStart = 0; + + constructor(options: ContextMenuPressRecognizerOptions = {}) { + super({ + ...options, + // A finger or a pen; a mouse opens the menu with its secondary button. + allowedPointerTypes: options.allowedPointerTypes ?? ['touch', 'pen'], + minimumPressDuration: NEVER_MS, + // The hold's own tolerance is the drift rule (touchesMoved below). + allowableMovement: Infinity, + }); + } + + /** + * The event clock at this moment: the timestamp of the input (or the + * deadline of the timer) being processed. Read it from a listener to + * place what the transition starts at the time it happened. + */ + get clockTime(): number { + return this.now(); + } + + protected override touchesBegan( + touches: ReadonlyArray, + event: GestureEventContext, + ): void { + super.touchesBegan(touches, event); + if ( + this.press == null && + this.state === 'possible' && + this.numberOfTouches === this.numberOfTouchesRequired + ) { + const [touch] = touches; + this.press = {time: event.time, x: touch.x, y: touch.y}; + this.pressTouchId = touch.id; + // The hold's deadlines count from the touch itself. This replaces the + // foundation's own press timer, which never fires. + this.holdFrom = {x: touch.x, y: touch.y}; + this.holdStart = event.time; + this.setTimer(event.time + commitMs, () => this.commit()); + } + } + + protected override touchesMoved(touches: ReadonlyArray): void { + const touch = touches.find(each => each.id === this.pressTouchId); + const hasDrifted = touch != null && !this.followHold(touch); + if (hasDrifted && this.state === 'possible') { + this.setState('failed'); + return; + } + if (hasDrifted && isActive(this.state) && !this.isMenuDue) { + this.setState('cancelled'); + return; + } + super.touchesMoved(touches); + } + + /** + * Follows the finger during its hold. A drift past `allowableMovement` on + * either axis ends the hold. Returns whether the hold still runs. + */ + private followHold(touch: GestureTouch): boolean { + const from = this.holdFrom; + if (from == null) { + return false; + } + if ( + Math.abs(touch.x - from.x) > allowableMovement || + Math.abs(touch.y - from.y) > allowableMovement + ) { + this.endHold(); + return false; + } + return true; + } + + private endHold(): void { + this.clearTimer(); + this.holdFrom = null; + } + + /** The hold's commit claims the finger, then waits for the menu's moment. */ + private commit(): void { + if (this.holdFrom == null) { + return; + } + if ( + this.state === 'possible' && + this.numberOfTouches === this.numberOfTouchesRequired + ) { + this.beganAt = this.now(); + this.setState('began'); + } + // A transition listener can end the hold. + if (this.holdFrom != null) { + this.setTimer(this.holdStart + menuDisplayMs, () => this.showMenu()); + } + } + + /** The finger is still down at the menu's moment: the menu is due. */ + private showMenu(): void { + if (this.holdFrom != null && isActive(this.state)) { + this.isMenuDue = true; + this.setState('changed'); + } + } + + protected override reset(): void { + super.reset(); + this.endHold(); + this.pressTouchId = null; + this.press = null; + this.isMenuDue = false; + } +} diff --git a/packages/core/src/Indicator/boxHold.test.tsx b/packages/core/src/Indicator/boxHold.test.tsx index 05bd93d001570..538b601bd7c8a 100644 --- a/packages/core/src/Indicator/boxHold.test.tsx +++ b/packages/core/src/Indicator/boxHold.test.tsx @@ -203,7 +203,10 @@ describe('a finger held on a control’s box', () => { const onChange = vi.fn(); const onOpenChange = vi.fn(); render( - + diff --git a/packages/core/src/Layer/outsidePress.test.ts b/packages/core/src/Layer/outsidePress.test.ts index ca08558aa9853..95beabf96d5a9 100644 --- a/packages/core/src/Layer/outsidePress.test.ts +++ b/packages/core/src/Layer/outsidePress.test.ts @@ -4,8 +4,9 @@ * @file outsidePress.test.ts * @input isFocusInsideOwnedLayer, isPressInsideOwnedLayer and * watchOutsidePress - * @output The shared focus-leave test, and the outside-press watch that - * closes as a finger outside begins to pan, at a cancel or at a lift + * @output The shared focus-leave test, and the outside-press watch. The + * watch closes as a finger outside begins to pan, at a cancel or at a lift. + * For a mouse or a pen it closes at the press */ import {fireEvent} from '@testing-library/react'; @@ -336,3 +337,110 @@ describe('watchOutsidePress, closing on a pan', () => { stop(); }); }); + +describe('watchOutsidePress, closing at the press', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + const press = (pointerType: string, pointerId = 1) => ({ + pointerType, + pointerId, + composed: true, + }); + + function setup(root?: Node, pressTypes = ['mouse', 'pen']) { + const owner = document.createElement('div'); + const layer = document.createElement('div'); + const item = document.createElement('button'); + layer.append(item); + const outside = document.createElement('button'); + document.body.append(owner, outside); + (root ?? document.body).appendChild(layer); + const onOutside = vi.fn(); + const stop = watchOutsidePress( + owner, + path => path.includes(layer), + onOutside, + {closesOnPan: true, pointerTypes: ['touch'], pressTypes, root}, + ); + return {item, outside, onOutside, stop}; + } + + it('closes at the mousedown of a mouse or a pen press outside, and not again at its lift', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.pointerUp(outside, press('mouse')); + fireEvent.mouseUp(outside); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.pointerDown(outside, press('pen', 2)); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledTimes(2); + stop(); + }); + + it('keeps the layer open for a mouse press inside it, in a closed shadow root too', () => { + const host = document.createElement('div'); + document.body.append(host); + const shadow = host.attachShadow({mode: 'closed'}); + const {item, onOutside, stop} = setup(shadow); + fireEvent.pointerDown(item, press('mouse')); + fireEvent.mouseDown(item, {composed: true}); + expect(onOutside).not.toHaveBeenCalled(); + fireEvent.pointerDown(host, press('mouse')); + fireEvent.mouseDown(host, {composed: true}); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('judges a mousedown that no pointerdown came before as a mouse press', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('leaves a press whose handler stops its mousedown before the document unjudged', () => { + const {outside, onOutside, stop} = setup(); + outside.addEventListener('mousedown', event => event.stopPropagation()); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(onOutside).not.toHaveBeenCalled(); + stop(); + }); + + it('leaves a touch to its own judgment, and its compatibility mousedown alone', () => { + const {outside, onOutside, stop} = setup(); + fireEvent.pointerDown(outside, press('touch')); + fireEvent.pointerUp(outside, press('touch')); + expect(onOutside).toHaveBeenCalledOnce(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('judges a touch at its compatibility mousedown when touch is a press type', () => { + const {outside, onOutside, stop} = setup(undefined, [ + 'mouse', + 'pen', + 'touch', + ]); + fireEvent.pointerDown(outside, press('touch')); + fireEvent.pointerUp(outside, press('touch')); + expect(onOutside).not.toHaveBeenCalled(); + fireEvent.mouseDown(outside); + expect(onOutside).toHaveBeenCalledOnce(); + stop(); + }); + + it('counts a layer it closes at the press as an outside dismissal', () => { + const {outside, stop} = setup(); + const before = outsideDismissalCount(document); + fireEvent.pointerDown(outside, press('mouse')); + fireEvent.mouseDown(outside); + expect(outsideDismissalCount(document)).toBe(before + 1); + stop(); + }); +}); diff --git a/packages/core/src/Layer/outsidePress.ts b/packages/core/src/Layer/outsidePress.ts index 4c34767d91411..55229c34cdd20 100644 --- a/packages/core/src/Layer/outsidePress.ts +++ b/packages/core/src/Layer/outsidePress.ts @@ -5,7 +5,8 @@ /** * @file outsidePress.ts * @input Pointer presses on a document and on the shadow roots around a - * given root; with closesOnPan, pan moves from core's input stream + * given root. With closesOnPan, pan moves from core's input stream. With + * pressTypes, the presses' bubbling mousedown * @output watchOutsidePress, and useOutsidePress, one watch per component. * noteOutsideDismissal and outsideDismissalCount keep a per-document count * of layers that an outside press or focus move closed. The inside tests @@ -35,6 +36,14 @@ export interface OutsidePressOptions { * document judges presses too, and an inner root's judgment stands. */ readonly root?: Node | null; + /** + * The pointer types judged at the press rather than at the lift. A press + * outside by one of them closes the layer at its bubbling `mousedown`, as a + * manual popover's own dismissal does. A press's type is that of the last + * `pointerdown` before its `mousedown`. With none, or one that names no + * type, it is a mouse. The watch does not follow these types at the lift. + */ + readonly pressTypes?: ReadonlyArray; } // A modal host's backdrop press reads this. A press that closed a layer above @@ -60,21 +69,29 @@ export function outsideDismissalCount(doc: Document): number { * @param isInside - Whether a press with this composed path is inside. * @param onOutside - Called once for each press that starts and ends outside, * as native light dismiss does. With `closesOnPan`, also when that press - * pans or is cancelled. + * pans or is cancelled. For a press type, at the press. * @returns Stops watching. */ export function watchOutsidePress( owner: Element, isInside: (path: ReadonlyArray) => boolean, onOutside: () => void, - {pointerTypes, closesOnPan = false, root = null}: OutsidePressOptions = {}, + { + pointerTypes, + closesOnPan = false, + root = null, + pressTypes = [], + }: OutsidePressOptions = {}, ): () => void { const doc = owner.ownerDocument; const shadows = root == null ? [] : shadowRootsOutward(root); let startedOutside = false; let panStart: {id: number; x: number; y: number} | null = null; + // The type of the pointer whose press the next mousedown belongs to. + let pressType: string | null = null; const follows = (event: PointerEvent) => - pointerTypes == null || pointerTypes.includes(event.pointerType); + !pressTypes.includes(event.pointerType) && + (pointerTypes == null || pointerTypes.includes(event.pointerType)); const close = () => { startedOutside = false; panStart = null; @@ -86,6 +103,7 @@ export function watchOutsidePress( // An inner root's listener runs after the outer ones', so its judgment // stands. const handleDown = (event: PointerEvent) => { + pressType = event.pointerType; if (!follows(event)) { return; } @@ -164,10 +182,30 @@ export function watchOutsidePress( } }; + // A press type's press is judged at its bubbling mousedown, so a handler + // that stops it keeps the layer open. An inner root sees it first, with the + // real target, and the outer ones skip it. + let judgedPress: Event | null = null; + const handlePress = (event: Event) => { + if (event === judgedPress) { + return; + } + judgedPress = event; + const type = pressType || 'mouse'; + pressType = null; + if (pressTypes.includes(type) && !isInside(event.composedPath())) { + close(); + } + }; + const judges: EventTarget[] = [ doc, ...shadows.map(({root: shadow}) => shadow), ]; + const pressJudges = pressTypes.length > 0 ? judges : []; + for (const target of pressJudges) { + target.addEventListener('mousedown', handlePress); + } for (const target of judges) { target.addEventListener('pointerdown', handleDown as EventListener, true); } @@ -190,6 +228,9 @@ export function watchOutsidePress( true, ); } + for (const target of pressJudges) { + target.removeEventListener('mousedown', handlePress); + } stopMoves?.(); doc.removeEventListener('pointerup', handleUp, true); for (const {shadow, host, handleRootUp, handleHostUp} of liftJudges) { diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index 5d7cec1464883..c739deaa6c701 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -18,6 +18,7 @@ roles through `resolveMotion`. | `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. | | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | +| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, commit haptic. | | `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/alert.ts` | UIAlertController: how a finger is tracked across the actions (highlight onset and bounds). | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts new file mode 100644 index 0000000000000..a6c168b433270 --- /dev/null +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -0,0 +1,94 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenu.ts + * @input Measurements of UIContextMenuInteraction on iOS 26.2 + * @output `contextMenu`: UIContextMenuInteraction's touch timeline, drift + * tolerance and commit haptic + * @position The context-menu domain of the UIKit constants table. Only + * ContextMenu's own modules import it. The table's barrel does not + * re-export it. + * + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md and + * /packages/core/src/ContextMenu/contextMenuPress.ts + */ + +/** + * UIContextMenuInteraction's touch timeline on iOS 26.2. A context menu is not + * a plain long press. A finger that rests on the view starts a press feedback + * after 150 ms. A copy of the view grows uniformly, and its size follows a + * linear ramp through a spring. At 400 ms the press commits. The click haptic + * plays, and the touch can no longer become a tap. The menu displays at 735 ms + * if the finger is still down, or at the lift after the commit. A finger that + * lifts before the commit gets no menu, and the copy springs back. A finger + * that drifts too far before the menu displays gets the same. + * + * UIKit runs this on display-link ticks. So a 60 Hz screen shows each moment + * on the first frame after it: the feedback at 151 ms, the commit at 417 ms. + * The times here are the moments themselves, from the `touchstart` timestamp. + * UIKit counts from the main thread's receipt of the touch, about a frame + * after the hardware timestamp that a browser stamps. A browser's compositor + * shows a value about a frame after its time, so the two offsets cancel. + * Scales are fractions of the view's own size. The lift and dismissal springs + * are `springs.contextMenuLift` and `springs.contextMenuDismiss`. + * + * Every leaf has one or more `@source`. A GUESS value also has the `@measure` + * task that confirms or replaces it. uikit.test.ts enforces both. + */ +export const contextMenu = { + press: { + /** + * When the long press commits. The click haptic plays, and from then on + * the touch belongs to the menu, not to a tap. The menu itself waits for + * `menuDisplayMs` or the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, recognizer state log, Oct 23 2026: the + * clickPresentationExclusion and clickPresentationFailure recognizers + * ended on the first 60 Hz tick after 400 ms (at 416.7 ms; the tick + * before, at 399.5 ms, had not), the haptic played at 420 ms, and the + * platter's size ramp changes slope at 400 ms + * @measure M-CM6 + */ + commitMs: 400, + /** + * How far the finger may drift on either axis, from where it landed, + * until the menu displays; past it the press ends with no menu. Not the + * long press's `UIKIT_GESTURES.longPress.allowableMovement` (a distance): + * a diagonal drift of 15.6 pt still opens the menu. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, drifts at 100, 250 and 500 ms, Oct 23 2026: (7, 7), + * (8, 8), (10, 0) and (11, 11) pt open the menu; (12, 0) and (0, 12) + * cancel it, before and after the onset alike, and a 30 pt drag after + * the commit cancels it too + * @source GUESS — the value, midway in the measured bracket (11, 12) + * @measure M-CM3 + */ + allowableMovement: 11.5, + /** + * When the menu displays for a finger that is still down: the delegate's + * willDisplay, with the backdrop and the menu. Until then, only the press + * copy shows, and it grows on the second ramp. A finger lifted after the + * commit gets the menu at the lift. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, delegate log and per-frame view tree, Oct 23 2026: + * 735–738 ms after the touch over five warm presses at random display + * phase (734 ms on the first press of a session); a press lifted at + * 0.5 s displayed at the lift (529 ms) + */ + menuDisplayMs: 735, + }, + /** + * The click haptic at the commit, as a vibration where that is the only + * haptic (Android browsers). UIKit plays a private pattern + * (`_UIClickPresentationFeedbackGenerator`) at the commit; iOS Safari can + * play nothing at that moment, because no tap lands then. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, UIFeedbackGenerator hook, Oct 23 2026: the click + * presentation feedback plays 3 ms after the commit, nothing plays for a + * press lifted before it (the moment) + * @source GUESS — a medium impact stands in for the private pattern (the + * kind) + * @measure M-CM5 + */ + commitHaptic: 'impactMedium', +} as const; From 812d3a40fd4e0753ac3be53669aebf8bd8ecab21 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:13:36 -0800 Subject: [PATCH 02/50] test(ContextMenu): the hold's menu commits in its deciding task (red) The hold's timer only sets ContextMenu's state, so React mounts and shows the menu in its next scheduled task. A WebKit CI run showed it at 758 ms, past the 735 ms proof's 20 ms. The proof now also wants the menu to show inside the timer task that decides it, and that task at the deadline counted from the touch. The page's stamps record the first show only. --- ...tMenuLongPress.interaction.browser.spec.ts | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index a8aa2d26a29a3..973617c03cceb 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -52,8 +52,37 @@ async function openTouchStory( // Note when the menu is asked to show, on the page clock (no per-frame // work: rendering proofs share this page). await page.addInitScript(() => { - const w = window as unknown as {__menuShownAt?: number | null}; + const w = window as unknown as { + __menuShownAt?: number | null; + __menuShownTimerAt?: number | null; + }; w.__menuShownAt = null; + w.__menuShownTimerAt = null; + // When the timer task that runs now started. The hold's moments come from + // the gesture arena's timers. The menu commits in the task that decides + // it, so its show runs inside that timer task. + let timerAt: number | null = null; + const setTimer = window.setTimeout; + window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => + setTimer( + (...callArgs: unknown[]) => { + const outer = timerAt; + timerAt = performance.now(); + try { + if (typeof handler === 'function') { + handler(...callArgs); + } + } finally { + timerAt = outer; + } + }, + timeout, + ...args, + )) as typeof window.setTimeout; const show = HTMLElement.prototype.showPopover; HTMLElement.prototype.showPopover = function ( this: HTMLElement, @@ -64,7 +93,11 @@ async function openTouchStory( this.matches('[role="menu"]') || this.querySelector('[role="menu"]') != null ) { - w.__menuShownAt ??= performance.now(); + // Only the first show counts, so a later one cannot stand in for it. + if (w.__menuShownAt == null) { + w.__menuShownAt = performance.now(); + w.__menuShownTimerAt = timerAt; + } } return show.apply(this, args); }; @@ -83,6 +116,14 @@ async function openTouchStory( }; } +/** When the timer task that showed the menu started, or null outside one. */ +const menuShownTimerAt = async (page: Page) => + page.evaluate( + () => + (window as unknown as {__menuShownTimerAt?: number | null}) + .__menuShownTimerAt ?? null, + ); + const menuShownAt = async (page: Page) => page.evaluate( () => @@ -115,7 +156,15 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async await driver.play(hold(center, menuDisplayMs + 200)); const start = await touchStartedAt(page); const shown = await menuShownAt(page); + const decided = await menuShownTimerAt(page); expect(shown, 'the menu never showed').not.toBeNull(); + // The menu commits in the timer task that decides it. That task runs at + // the deadline, which counts from the touch's own timestamp. + expect( + decided, + 'the menu showed after the task that decided it', + ).not.toBeNull(); + expect((decided as number) - start).toBeGreaterThanOrEqual(menuDisplayMs - 1); const after = (shown as number) - start; // The timer runs at its deadline, give or take a task. expect(after).toBeGreaterThanOrEqual(menuDisplayMs); From 0776b4071b32c57040daa18b08f0097bf7789a17 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:13:37 -0800 Subject: [PATCH 03/50] fix(ContextMenu): the hold commits its menu in the task that decides it The hold's timer opened the menu with a plain state update, which waits for React's next scheduled task: the menu showed 9 ms after the deadline in Chromium and 17 ms in the WebKit CI container. UIKit shows the menu at its moment. The open now commits with flushSync, in the timer task, as a lift's open commits in the lift's dispatch. --- packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index 9650067be3297..834178947c100 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -14,6 +14,7 @@ */ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; +import {flushSync} from 'react-dom'; import {playHaptic} from '../gestures/haptics'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; @@ -60,7 +61,10 @@ export function ContextMenuTouchLayer({ press => { const open = () => { hasPressOpenedRef.current = true; - onOpenRef.current(press.press ?? press.location()); + // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the menu shows in the task that decides it, as UIKit shows it at its moment, not a scheduled task later + flushSync(() => { + onOpenRef.current(press.press ?? press.location()); + }); }; switch (press.state) { case 'began': From e8f5ca1898daf706c39b645a0895759ee8fadb8b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:14:22 -0800 Subject: [PATCH 04/50] test(ContextMenu): the menu mounts, closed, at the hold's commit (red) The menu's first show mounts its layer, a render of 6 ms in Chromium and 17 to 21 ms in the WebKit CI container inside the task that shows the menu at 735 ms. UIKit prepares a context menu as the interaction begins and only presents it at its moment. The proofs want the menu mounted, closed and out of the accessibility tree, at the commit; the same element shown at 735 ms or at the lift; and nothing left mounted by a move past the tolerance or a cancel. --- .../core/src/ContextMenu/ContextMenu.test.tsx | 69 +++++++++++++++++++ ...tMenuLongPress.interaction.browser.spec.ts | 19 +++++ 2 files changed, 88 insertions(+) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 3b3957152001d..2a2284db9cd6f 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -869,6 +869,75 @@ describe.each([ press.up(); }); + it('mounts the menu, closed, at the commit, and shows that element at 735 ms', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs - 1); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + advance(1); + const menu = screen.getByRole('menu', {hidden: true}); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + advance(contextMenu.press.menuDisplayMs - contextMenu.press.commitMs); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + press.up(); + }); + + it('shows the menu mounted at the commit at the lift that comes before 735 ms', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(529); + const menu = screen.getByRole('menu', {hidden: true}); + act(() => { + press.up(); + }); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(1); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + }); + + it('mounts no menu for a press lifted before the commit', () => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(250); + press.up(); + advance(1000); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + }); + + it.each([ + [ + 'a move past the tolerance', + (trigger: HTMLElement) => { + const moved = {id: 1, x: 20, y: 50}; + dispatchTouch('touchmove', trigger, [moved], [moved]); + }, + ], + [ + 'a touch the browser cancels', + (trigger: HTMLElement) => { + dispatchTouch('touchcancel', trigger, [{id: 1, x: 20, y: 20}], []); + }, + ], + ])( + 'unmounts the menu mounted at the commit after %s, before 735 ms', + (_, end) => { + const trigger = renderMenu(); + const press = finger(trigger); + press.down(); + advance(contextMenu.press.commitMs + 50); + expect(screen.getByRole('menu', {hidden: true})).toBeInTheDocument(); + act(() => { + end(trigger); + }); + advance(contextMenu.press.menuDisplayMs); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + expect(HTMLElement.prototype.showPopover).not.toHaveBeenCalled(); + }, + ); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { const trigger = renderMenu(); const press = finger(trigger); diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 973617c03cceb..efba3d49c46db 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -172,6 +172,25 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); }); +test('the menu mounts at the commit, closed and out of the accessibility tree, and shows at 735 ms', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 200)); + const prepared = page + .locator('[popover]') + .filter({has: page.locator('[role="menu"]')}); + // Mounted at the commit (400 ms), well before the menu's moment (735 ms). + await expect(prepared).toHaveCount(1, {timeout: 700}); + expect(await menuShownAt(page), 'the menu showed before 735 ms').toBeNull(); + expect(await prepared.evaluate(el => el.matches(':popover-open'))).toBe( + false, + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toHaveCount(0); + await playing; + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + /** The story with somewhere to scroll below it, and its trigger's center. */ async function openScrollableStory(page: Page) { const story = await openTouchStory(page); From 0fbd309fdf2f8f974c10697f040b46784461fc27 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:14:49 -0800 Subject: [PATCH 05/50] fix(ContextMenu): the hold mounts the menu at its commit, closed A lazy context layer gains prepare(), which mounts its content without showing it, and releasePrepared(), which unmounts that content unless the layer has shown since. Both are internal to core's layer hook. At the hold's commit, ContextMenu prepares its layer, so the show at 735 ms or at the lift only reveals it. A press that ends with no menu releases it. component:ContextMenu PR1 names the commit-time mount. --- .../ContextMenu/ContextMenu.spec.md | 4 +- packages/core/src/ContextMenu/ContextMenu.tsx | 14 ++++- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 28 ++++++++- packages/core/src/Layer/useLayer.test.tsx | 60 +++++++++++++++++++ packages/core/src/Layer/useLayer.tsx | 60 ++++++++++++++++++- 5 files changed, 161 insertions(+), 5 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index dac8d53543eaf..29691feaa9afc 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -152,7 +152,9 @@ Consumer props, content modes, and presentation policy remain documented in mounted. A menu never opened mounts no pointer menu surface, only its layer's inert marker: the surface mounts at the first open and stays mounted after it, so a page with many context menus (a table with one per cell) carries no - closed menu surfaces. + closed menu surfaces. A finger's hold mounts the surface, closed, at its + commit, so the menu at 735 ms only shows it; a hold that ends with no menu + unmounts it again. ## Accessibility contract diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index d59a02ab30a90..dafc716443ee3 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -28,7 +28,9 @@ * context menu that was never opened adds no popover element to the page (a * table with a menu on every cell would otherwise carry one per cell). Once * mounted it stays, so every later open and close, its exit animation - * included, runs as before. + * included, runs as before. A finger's hold mounts it, closed, at the + * commit, so the show at 735 ms only reveals it. A hold that ends with no + * menu unmounts it again. * The trigger wrapper is a `div` by default; `triggerAs` makes it an inline * `span`, so a reference inside prose can own a context menu without * breaking the text flow. @@ -666,6 +668,14 @@ export function ContextMenu({ [layer, updateOpenState, usesBottomSheet], ); + // The hold's commit: the menu shows at its moment or at the lift, so its + // layer mounts now, closed, and the show only reveals it. + const prepareMenu = useCallback(() => { + if (!usesBottomSheet) { + layer.prepare(); + } + }, [layer, usesBottomSheet]); + // The box the cursor anchor's offsets are measured from: the trigger, which // is its containing block. const getAnchorBaseRect = useCallback( @@ -978,6 +988,8 @@ export function ContextMenu({ ref={touchHandleRef} triggerRef={triggerRef} onOpen={openFromFinger} + onCommit={prepareMenu} + onCancel={layer.releasePrepared} /> ) : null} diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index 834178947c100..a42432403acf2 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -32,6 +32,13 @@ export interface ContextMenuTouchLayerProps { ref?: Ref; /** Open the menu at a client point (where the finger landed). */ onOpen: (point: {x: number; y: number}) => void; + /** + * The hold committed: the menu shows at its moment or at the lift, so its + * content can mount now, hidden. + */ + onCommit?: () => void; + /** The press ended with no menu: what mounted at the commit can go. */ + onCancel?: () => void; } /** @@ -48,16 +55,32 @@ export function ContextMenuTouchLayer({ triggerRef, ref, onOpen, + onCommit, + onCancel, }: ContextMenuTouchLayerProps): null { const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + const onCancelRef = useRef(onCancel); + onCancelRef.current = onCancel; const hasPressOpenedRef = useRef(false); // The gesture hook follows the element behind the ref, so a `triggerAs` // swap moves the recognizer to the new node. const recognizer = useGestureRecognizer( triggerRef, - () => new ContextMenuPressRecognizer(), + () => { + const recognizer = new ContextMenuPressRecognizer(); + // Its target-action listeners never hear `failed`, so a press that ends + // with no menu releases what the commit mounted from here. + recognizer.addTransitionListener((_press, _from, to) => { + if (to === 'failed' || to === 'cancelled') { + onCancelRef.current?.(); + } + }); + return recognizer; + }, press => { const open = () => { hasPressOpenedRef.current = true; @@ -72,6 +95,9 @@ export function ContextMenuTouchLayer({ // The click haptic, where a vibration motor is the only haptic. // iOS Safari can play none here: no tap lands at the commit. playHaptic(contextMenu.commitHaptic); + // The menu shows at its moment or at the lift. Mounted now, its + // content costs that show nothing. + onCommitRef.current?.(); break; case 'changed': if (press.isMenuDue && !hasPressOpenedRef.current) { diff --git a/packages/core/src/Layer/useLayer.test.tsx b/packages/core/src/Layer/useLayer.test.tsx index f6c280516458a..37e0238403433 100644 --- a/packages/core/src/Layer/useLayer.test.tsx +++ b/packages/core/src/Layer/useLayer.test.tsx @@ -208,6 +208,12 @@ function KeptLazyContextHarness({ + + {unsafe ? (

{renderedLayer}

@@ -1024,6 +1030,60 @@ describe('useLayer', () => { expect(layer).toHaveAttribute('data-open', 'true'); }); + it('prepare() mounts it closed, and the next show opens that element at once', async () => { + const showSpy = mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + const layer = container.querySelector('[popover]'); + expect(layer).not.toHaveAttribute('data-open'); + expect(showSpy).not.toHaveBeenCalled(); + + fireEvent.click(getByRole('button', {name: 'Trigger'})); + // Mounted: shown within the call, not after a commit. + expect(showSpy).toHaveBeenCalledOnce(); + expect(container.querySelector('[popover]')).toBe(layer); + expect(layer).toHaveAttribute('data-open', 'true'); + }); + + it('releasePrepared() unmounts a prepared layer that never showed', async () => { + const showSpy = mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + fireEvent.click(getByRole('button', {name: 'Release'})); + + await waitFor(() => { + expect(container.querySelector('[popover]')).toBeNull(); + }); + expect(container.querySelector('template')).not.toBeNull(); + expect(showSpy).not.toHaveBeenCalled(); + }); + + it('releasePrepared() keeps a prepared layer that has shown since', async () => { + mockPopover(); + const {container, getByRole} = render(); + + fireEvent.click(getByRole('button', {name: 'Prepare'})); + await waitFor(() => { + expect(container.querySelector('[popover]')).not.toBeNull(); + }); + const layer = container.querySelector('[popover]'); + fireEvent.click(getByRole('button', {name: 'Trigger'})); + fireEvent.click(getByRole('button', {name: 'Close'})); + fireEvent.click(getByRole('button', {name: 'Release'})); + + await act(async () => {}); + expect(container.querySelector('[popover]')).toBe(layer); + expect(layer?.isConnected).toBe(true); + }); + it('without the option, a lazy layer still unmounts on hide', async () => { mockPopover(); const {container, getByRole} = render( diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 75b8e877b7cf6..1bfc7ca3d80f4 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -456,6 +456,23 @@ interface InternalLayerFields { wasJustDismissed: () => boolean; } +/** Context-mode members a core component may use through `useLayerInternal`. */ +interface InternalContextLayerFields { + /** + * With `lazyMount`, mounts the layer's content without showing it. The + * popover stays closed, so it shows no pixel and is out of the accessibility + * tree until `show()`, which then only shows it. A caller that knows a show + * is coming spares that show the first mount's render. Does nothing for a + * layer that is mounted already. + */ + prepare: () => void; + /** + * Unmounts what `prepare()` mounted, unless the layer has shown since. A + * layer that has shown keeps its mount as its options say. + */ + releasePrepared: () => void; +} + /** * Context-mode options a core component may pass through `useLayerInternal`; * not part of the public `useLayer` options. @@ -485,7 +502,9 @@ interface InternalContextLayerOptions extends ContextLayerOptions { onCloseStart?: () => void; } -type InternalContextLayerReturn = ContextLayerReturn & InternalLayerFields; +type InternalContextLayerReturn = ContextLayerReturn & + InternalLayerFields & + InternalContextLayerFields; type InternalFixedLayerReturn = FixedLayerReturn & InternalLayerFields; function toCssLength(value: number | string): string { @@ -1427,6 +1446,30 @@ function useLayerImplementation( [mountStore], ); + // The mount came from `prepare()`, and the layer has not shown since. + const isMountPreparedRef = useRef(false); + + const prepare = useCallback(() => { + if (mode !== 'context' || !lazyMount || contextMountRef.current != null) { + return; + } + isMountPreparedRef.current = true; + requestContextMount(); + }, [mode, lazyMount, requestContextMount]); + + const releasePrepared = useCallback(() => { + if ( + !isMountPreparedRef.current || + pendingShowRef.current || + isOpenRef.current + ) { + return; + } + isMountPreparedRef.current = false; + contextMountRef.current = null; + mountStore.set(null); + }, [mountStore]); + const clearContextMount = useCallback(() => { if (mode !== 'context' || !lazyMount || keepsMountAfterShow) { return; @@ -1441,6 +1484,7 @@ function useLayerImplementation( if (wasJustDismissed()) { return; } + isMountPreparedRef.current = false; // A context popover left over until React commits a previous hide must not // be reopened. The synchronous mount ref is the source of truth. const candidate = popoverRef.current; @@ -2111,6 +2155,8 @@ function useLayerImplementation( hide, isOpen, wasJustDismissed, + prepare, + releasePrepared, id, render: renderContext, }), @@ -2121,6 +2167,8 @@ function useLayerImplementation( hide, isOpen, wasJustDismissed, + prepare, + releasePrepared, id, renderContext, ], @@ -2148,7 +2196,15 @@ export function useLayer( ): ContextLayerReturn | FixedLayerReturn { const internalLayer = useLayerImplementation(options); return useMemo(() => { - const {wasJustDismissed: _, ...layer} = internalLayer; + const { + wasJustDismissed: _, + prepare: _prepare, + releasePrepared: _releasePrepared, + ...layer + } = internalLayer as ( + InternalContextLayerReturn | InternalFixedLayerReturn + ) & + Partial; return layer; }, [internalLayer]); } From 32e75c73cc6aa98b8b4db2a2187ac6bd56b5d708 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 05:20:00 -0800 Subject: [PATCH 06/50] test(ContextMenu): a mouse press closes the menu in its own document On main, ContextMenu's mouse and pen dismissal listens on the page's document. A menu rendered into a same-origin iframe or a Document Picture-in-Picture window then closes on a press in the page and stays open on one in its own window. The shared outside-press watch listens in the trigger's own document, as `family:overlay-dismissal` FR8 has every member work in the document it renders in. With main's ContextMenu.tsx, this case fails. --- .../core/src/ContextMenu/ContextMenu.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 2a2284db9cd6f..17213536c4441 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -27,6 +27,7 @@ import { waitFor, cleanup, createEvent, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {readFileSync} from 'node:fs'; @@ -2456,6 +2457,67 @@ describe('ContextMenu outside dismissal', () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); }); + + describe('in a second document', () => { + // An app can render a ContextMenu into a same-origin iframe or a Document + // Picture-in-Picture window through a portal. Every press for it then + // happens in that document. + let frame: HTMLIFrameElement; + let secondDocument: Document; + + beforeEach(() => { + frame = document.createElement('iframe'); + document.body.appendChild(frame); + secondDocument = frame.contentDocument!; + // The Popover API stand-in above, on the second window's own prototype. + const {prototype} = secondDocument.defaultView!.HTMLElement; + prototype.showPopover = HTMLElement.prototype.showPopover; + prototype.hidePopover = HTMLElement.prototype.hidePopover; + prototype.matches = HTMLElement.prototype.matches; + }); + + afterEach(() => { + // Unmount before the frame goes, or React removes nodes from a + // document that no longer exists. + cleanup(); + frame.remove(); + }); + + it('a mouse press outside the menu closes it in the document it renders in; a press in the page does not', () => { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + render( + createPortal( + <> + +
Right-click me
+
+ + , + secondDocument.body, + ), + ); + const view = within(secondDocument.body); + fireEvent.contextMenu(view.getByTestId('ctx'), { + clientX: 50, + clientY: 15, + detail: 1, + }); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + fireEvent.pointerDown(document.body, { + pointerType: 'mouse', + pointerId: 1, + }); + fireEvent.mouseDown(document.body); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + const outside = view.getByRole('button', {name: 'Outside'}); + fireEvent.pointerDown(outside, {pointerType: 'mouse', pointerId: 1}); + fireEvent.mouseDown(outside); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); }); describe('ContextMenu: Tab from a flyout leaves the whole menu (APG menu button)', () => { From df6fd04576e9fd141d4eb913f90bc4707249715d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:15:20 -0800 Subject: [PATCH 07/50] docs(ContextMenu): PR1 names the outside-press watch and the touch query PR1 listed one mousedown listener and the adaptive query. While the menu is open, the outside-press watch adds pointer listeners to the trigger's document and shadow roots, plus a mousedown listener where a mouse or a pen closes it at the press. With presentation omitted, ContextMenu subscribes to the coarse-pointer query while mounted. --- .../components/ContextMenu/ContextMenu.spec.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 29691feaa9afc..e1a79e4a0779e 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -144,12 +144,13 @@ Consumer props, content modes, and presentation policy remain documented in ### Performance and resources -- **PR1 — Bounded resources.** While open, ContextMenu adds a document (and - shadow-root) mousedown listener for outside dismissal and removes it on - close, and the - pointer menu observes its size and content to decide whether it scrolls. With - `presentation="adaptive"` it subscribes to the compact-touch query while - mounted. A menu never opened mounts no pointer menu surface, only its layer's +- **PR1 — Bounded resources.** While open, ContextMenu's outside-press watch + adds pointer listeners to the trigger's document (and shadow roots), plus a + mousedown listener where a mouse or a pen closes it at the press, and + removes them on close. The pointer menu observes its size and content to + decide whether it scrolls. With `presentation` omitted it subscribes to the + coarse-pointer query while mounted, and with `presentation="adaptive"` to + the compact-touch query. A menu never opened mounts no pointer menu surface, only its layer's inert marker: the surface mounts at the first open and stays mounted after it, so a page with many context menus (a table with one per cell) carries no closed menu surfaces. A finger's hold mounts the surface, closed, at its From 1cedda2d73d250a12bb71b35141eba86d5111143 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 08/50] feat(ContextMenu): a held finger grows the trigger on the lift spring Under a finger, a block trigger grows from the onset, 150 ms after the finger lands, toward 1.0735 times its size on springs.contextMenuLift, and returns on springs.contextMenuDismiss. It carries this pull request's own diff from 63c7a6c009, replayed on main after #9814's squash, and folds these 27 commits (the history of 63c7a6c009 has each one): - test(ContextMenu): a held finger grows the trigger on the theme's motion tokens, and the timeline's other proofs - feat(ContextMenu): a held finger grows the trigger on the theme's motion tokens - test(ContextMenu): a trigger gets its size back when its press feedback goes while the menu it opened is up - fix(ContextMenu): the press feedback attaches through useAttachment, and a departing feedback gives the trigger its size back - refactor(ContextMenu): the touch layer's recognizer carries its transition listener, and the press recognizer drops onPressStart - docs(ContextMenu): FR11's growth cites AST-094 FR45 - test(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - fix(ContextMenu): the trigger returns on the theme's exit duration and easing when the menu closes - docs(ContextMenu): FR11 keeps the measured basis for the hold's moments, and the tests that verify them - docs(ContextMenu): the block-trigger guidance says the trigger grows while the menu is up - test(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - fix(ContextMenu): the growth starts 150 ms after the finger lands, wherever the press machine marks the press - test(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - fix(ContextMenu): the trigger's growth plays springs.contextMenuLift, and its returns springs.contextMenuDismiss - docs(ContextMenu): FR11 names the block trigger, and the tests, story and comments say the trigger springs back - refactor(ContextMenu): the press feedback stops returning the pressable marker, and two comments say what the code does - test(ContextMenu): a right-click in a scaled container gives the anchor no scale of its own - fix(ContextMenu): the anchor cancels only the press feedback's own growth - fix(ContextMenu): the press feedback imports only its two springs - test(ContextMenu): a drift the recognizer still follows grows at onset - fix(ContextMenu): keep the growth's onset while the recognizer follows - test(ContextMenu): a finger grows at its own onset, marked or not - fix(ContextMenu): a finger arms the growth from its own landing - docs(ContextMenu): the growth starts 150 ms after the finger lands - refactor(ContextMenu): the recognizer times a finger's onset - refactor(ContextMenu): drop a comment that restates followsFingerRef - refactor(ContextMenu): the onset ref comment keeps only its constraint --- .changeset/contextmenu-touch-timeline.md | 2 +- .../storybook/stories/ContextMenu.stories.tsx | 2 +- .../ContextMenu/ContextMenu.spec.md | 66 +- .../core/src/ContextMenu/ContextMenu.doc.mjs | 21 +- .../core/src/ContextMenu/ContextMenu.test.tsx | 234 +++--- packages/core/src/ContextMenu/ContextMenu.tsx | 81 ++- .../core/src/ContextMenu/ContextMenuTouch.ts | 4 +- .../ContextMenuTouchGrowth.test.tsx | 262 +++++++ .../src/ContextMenu/ContextMenuTouchLayer.tsx | 61 +- .../ContextMenuTouchLayerProps.test.tsx | 62 ++ ...tMenuLongPress.interaction.browser.spec.ts | 688 +++++++++++++++++- .../src/ContextMenu/contextMenuPress.test.ts | 13 +- .../core/src/ContextMenu/contextMenuPress.ts | 6 + .../contextMenuPressFeedback.test.tsx | 332 +++++++++ .../ContextMenu/contextMenuPressFeedback.ts | 317 ++++++++ packages/core/src/motion/README.md | 2 +- packages/core/src/motion/uikit/contextMenu.ts | 27 +- .../core/src/utils/pressableCoverage.test.ts | 16 +- 18 files changed, 2033 insertions(+), 163 deletions(-) create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx create mode 100644 packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx create mode 100644 packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx create mode 100644 packages/core/src/ContextMenu/contextMenuPressFeedback.ts diff --git a/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md index b8c1a1e95a6c7..df1a62392af22 100644 --- a/.changeset/contextmenu-touch-timeline.md +++ b/.changeset/contextmenu-touch-timeline.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. The hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. +[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. A block trigger whose menu opens at the finger starts to grow 150 ms after the finger lands, toward its held size on UIKit's lift spring, played on the compositor, and the hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the trigger springs back to its size. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. The trigger returns to its size when the menu closes. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. @vjeux diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 7d8dff9642034..0c25ea1cea0b4 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -667,7 +667,7 @@ export const TouchLongPress: Story = { docs: { description: { story: - 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan.', + 'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, it starts to grow after 150 ms, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the card springs back to its size. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan. The card returns to its size when the menu closes.', }, }, }, diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index e1a79e4a0779e..5b9cc4172ffde 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -100,20 +100,20 @@ Consumer props, content modes, and presentation policy remain documented in ## Behavioral and layout contract -| ID | Invariant | Basis | -| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. 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 | -| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | -| 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 | -| 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 | -| 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. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | -| FR6 | With `triggerAs="span"` the Trigger area is 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. | DEC-1 | -| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 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. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | -| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | -| 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 | -| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | -| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | -| FR12 | Under a finger, the hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured: UIContextMenuInteraction on the iOS 26.2 simulator commits at 400 ms and displays its menu at 735 ms (`motion/uikit/contextMenu.ts`); `spec:AST-094` FR4, FR45 | +| ID | Invariant | Basis | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| FR1 | Every render contains a caller-provided Trigger area; with `isDisabled` no invocation opens a menu and the native context menu is left alone. 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 | +| FR2 | The `context-menu` target stays on the painted Pointer menu surface and alternative Touch menu surface, not on the Trigger area or cursor anchor. | `architecture:component-theming-surface`; current source | +| 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 | +| 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 | +| 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. The long press that opened the menu does not close it as it lifts; a tap outside closes it, and a mouse press outside closes it at the press. Under a finger the menu MUST close as a pan that starts outside it begins (`spec:AST-094` FR32), MUST hand the held finger that opened it to the menu as the opening press, and MUST treat a pen's press outside as FR32 does (FR33). The lift and the grow apply only to a menu opened by touch (a touch-initiated open on a coarse pointer); a menu opened by mouse or keyboard keeps its released appearance and motion, with no lift, no tint and no grow. A touch-opened menu MUST grow out of the finger's point and shrink back into it (`spec:AST-094` FR31) as its own surface, by transform and opacity only (`spec:AST-094` FR5), with no second element such as a glass surface growing behind it, and on a block trigger MUST lift the trigger above the page with the menu beside it (FR33; DD1, DEC-2). The long-press presentation is a gesture component, so the trigger's lift plays `springs.contextMenuLift`, the menu's grow `springs.contextMenuMorph`, and the trigger's return, part of the menu's exit, `springs.contextMenuDismiss` (`spec:AST-094` FR4, FR7, FR45). Implementation gap: it keeps the layer's own entrance and exit, the opening finger is not handed to the menu so it cannot slide to a row, no trigger lift or page tint exists, and a pen press outside closes it at the press. Right-click, Shift+F10, long-press invocation, and keyboard navigation are as FR1 and FR8 state. | `module:DropdownMenu/useMenuPress`; `spec:AST-094` FR26–FR33 | +| FR6 | With `triggerAs="span"` the Trigger area is 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. | DEC-1 | +| FR7 | Sub-menu rows follow `component:DropdownMenu` FR11 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. A sub-menu with a header or footer shows its rows alone here (`component:DropdownMenu` FR20). | `component:DropdownMenu/DEC-4` | +| FR8 | In the pointer menu ArrowDown on the last row wraps to the first and ArrowUp on the first to the last; PageDown and PageUp page as in `component:DropdownMenu` FR12; a held key's auto-repeat does not activate; typeahead matches the row's label element alone. A disabled row is reached by these keys and by typeahead and stays inert, as `component:DropdownMenu` FR17 states. Implementation gap: the arrow keys and typeahead skip disabled rows today (Current-state impact). | Current source and tests | +| 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 | +| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 | +| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests | +| FR12 | Under a finger, a block trigger whose menu opens at the finger grows during a hold toward 1.0735 times its size on `springs.contextMenuLift`, from the onset, 150 ms after the finger lands, and it returns after a lift before the commit on `springs.contextMenuDismiss`, as FR5 names them. The hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured on iOS 26.2 (`motion/uikit/contextMenu.ts`): UIKit commits at 400 ms, shows the menu at 735 ms, and grows the view on a linear ramp chased by a spring. `spec:AST-094` FR4, FR45: the two moments are measured input, and the long-press presentation is a gesture component (FR5), so the growth and its return play their spring roles | ### Allowed variation @@ -248,25 +248,25 @@ anchor is positioning infrastructure rather than consumer anatomy. ## Verification map -| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | -| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | -| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | -| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | -| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | -| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | -| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | -| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | -| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | -| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | -| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | -| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | -| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | -| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | -| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts` | A held finger: the commit and the menu's display | A commit or a display off UIKit's moments fails. | `audit:ContextMenu/behavior` | -| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | -| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | +| Contract | Verification | Representative states | Mutation or failure expectation | Audit section | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| FR1, FR4 | `ContextMenu.test.tsx` rendering, invocation, bottom-sheet, adaptive, compound, and disabled cases ("renders menu with role=\"menu\"", "opens menu on right-click", "opens from a keyboard-invoked contextmenu (Shift+F10 / Menu key)", "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before", "opens the real menu content in a BottomSheet when requested", "uses the BottomSheet for adaptive presentation on compact touch", "renders menuContent as menu items", "does not open when isDisabled is true") | Right-click, keyboard, long press; pointer and sheet; data and compound; disabled | A missing invocation path, menu role, sheet, or compound interior, or a disabled trigger that opens, fails. Evidence gap: no test asserts that a disabled trigger leaves the native context menu alone. | `audit:ContextMenu/anatomy` | +| FR2 | `themingTargets.test.ts` | Rendered targets | A rendered class absent from the documented targets fails. Evidence gap: no test asserts `context-menu` on either painted surface or its absence from the trigger. | `audit:ContextMenu/theming` | +| FR3 | `ContextMenu.test.tsx` destructive-variant, section, divider, selectable, and alias cases | Shared rows through ContextMenu | A row that loses DropdownMenu's semantics or variant fails. Evidence gap: no test asserts the BottomSheet and List delegation beyond rendering. | `audit:ContextMenu/theming` | +| FR5 | `ContextMenu.test.tsx` "acts on the row under a finger release, once, and swallows the stray click" and outside-dismissal cases; `outsidePress.test.ts` pan cases; `ContextMenuLongPress.interaction.browser.spec.ts` "a new finger that pans outside the open menu closes it, and the page scrolls"; `ContextMenuLongPress.a11y.chromium.spec.ts` | Finger release; long press lift; tap, mouse, pen, and pan outside | A row acting on the press row, a stray click that acts, a long press that closes its own menu, a tap or mouse press outside that leaves it open, or a pan outside that leaves it open fails. Evidence gap: no test asserts the FR31 grow, the opening-finger handoff, or the FR33 lift. | `audit:ContextMenu/behavior` | +| FR5, DD1 touch scope | Browser tests on the touch story, the menu opened by a finger under a coarse pointer and by a mouse and by Shift+F10 under a fine pointer | Touch-opened, mouse-opened and keyboard-opened menus on a block trigger | A mouse- or keyboard-opened menu that lifts its trigger, tints the page or grows, or a touch-opened menu that blurs the page, grows a second surface, or animates anything but transform and opacity, fails. Evidence gap: the lift and the grow are unbuilt, so no test asserts either. | `audit:ContextMenu/behavior` | +| FR5 Reduce Motion | Browser tests on the touch story with reduced motion | A touch-opened menu opening and closing under Reduce Motion | A lift, grow or shrink that moves rather than playing an opacity-only cross-fade of 200 ms or less (`spec:AST-094` FR8) fails. Evidence gap: no test exists. | `audit:ContextMenu/behavior` | +| FR5 motion roles | Role registry tests over `spec:AST-094` FR4 | `springs.contextMenuLift`, `springs.contextMenuDismiss` and `springs.contextMenuMorph` at tempo 1 and `'standard'` | A lift, return or grow that plays a curve or a literal instead of its spring role, or a role off its FR4 table value, fails. | `audit:ContextMenu/behavior` | +| DEC-2 | Element-count browser test on the touch story (`spec:AST-017` FR60) | At rest and lifted, fine and coarse pointer | A lift element at rest, a copy of the trigger, or an open count beyond the layer, the shadow, the anchor and the placeholder fails. Evidence gap: the lift is unbuilt, so no test exists. | `audit:ContextMenu/anatomy` | +| FR6, AR1, ORD1 | `ContextMenu.test.tsx` "inline trigger (triggerAs)" and cursor-anchor cases; "does not put aria-haspopup on the role-less trigger wrapper (menus-15)"; `ContextMenu.triggerProps.test.tsx`; `ContextMenuCursorAnchor.a11y.browser.spec.ts` | `span` in a paragraph; default `div`; pointer and keyboard anchors | A block trigger in prose, a trigger with a role or `aria-haspopup`, or an anchor outside the trigger fails. | `audit:ContextMenu/behavior` | +| FR7 | `ContextMenu.test.tsx` "drills into nested data items inside the BottomSheet", "renders a submenu from a nested items array and keyboard-reaches an item after it", and "shows a sub-menu with a header or footer as its rows alone, and says why in development" | Sheet drill-in; pointer sub-menu; regions under ContextMenu | A missing sub-menu, an unreachable row after it, or a sub-menu dialog under ContextMenu fails. Evidence gap: no test here asserts the pointer drill-in on a phone. | `audit:ContextMenu/behavior` | +| FR8 | `ContextMenu.test.tsx` "wraps arrow navigation at the ends", typeahead cases, and disabled-row keyboard cases (`component:DropdownMenu` FR17) | Wrap; typeahead; disabled rows | A clamped arrow, a missing typeahead match, a disabled row the keys or typeahead skip, or a disabled row that acts or closes the menu fails. Evidence gap: no test asserts Page keys or auto-repeat. | `audit:ContextMenu/behavior` | +| FR9 | `ContextMenu.test.tsx` "lets a nested layer consume Escape before the context menu", "closes on Escape even when opened without auto-focus", and "ignores Escape during IME composition" | Nested layer; Escape; composition | A context menu that closes with its nested child, or on a composition Escape, fails. | `audit:ContextMenu/behavior` | +| FR10, PR1 | `ContextMenu.test.tsx` invocation and handler-composition cases ("composes the consumer context-menu handler with menu opening", "keeps component-owned opening when a consumer prevents default", "passes touch events through to the consumer’s own handlers", and the cases where a consumer `onTouchStart` or `onTouchEnd` runs beside the hold) | Right click, long press, keyboard | A context request that does not open the menu, or a dropped consumer handler, fails. Evidence gap: no test asserts listener cleanup on unmount; nothing tests a headless factory, which does not ship (the FR10 gap). | `audit:ContextMenu/behavior` | +| FR11 | `ContextMenu.test.tsx` "Tab from a flyout leaves the whole menu" case | a sub-menu row | A Tab that leaves the menu or a flyout open, or a second `onOpenChange(false)`, fails. | `audit:ContextMenu/accessibility` | +| FR12 | `ContextMenu.test.tsx` "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before" and "opens at the lift for a finger lifted after the commit (400 ms), as UIKit does"; `contextMenuPress.test.ts`; `contextMenuPressFeedback.test.tsx` "grows toward the held scale on springs.contextMenuLift, from the onset" and "returns on springs.contextMenuDismiss, at the time the recognizer ended the press"; `ContextMenuTouchGrowth.test.tsx` onset cases; the touch story's browser curve and busy-thread cases in Chromium, and an iOS Safari recording for WebKit | A held finger: the commit, the display, the onset, the growth, the early-lift return, their springs | A commit or a display off UIKit's moments, a growth before the onset, or a growth or return off its spring role fails | `audit:ContextMenu/behavior` | +| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` | +| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` | ## Current-state impact diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs index 4a5b1745c7bbe..7390609678f8b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.doc.mjs +++ b/packages/core/src/ContextMenu/ContextMenu.doc.mjs @@ -159,7 +159,7 @@ export const docs = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions without cluttering the UI with visible buttons. Under a finger, where the primary pointer is coarse, a held finger opens it with iOS timing: a block trigger whose menu opens at the finger starts to grow at 150 ms, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.', bestPractices: [ { guidance: true, @@ -186,6 +186,11 @@ export const docs = { description: 'Keep a visible MoreMenu or equivalent trigger for important mobile actions; long-press must not be the only route.', }, + { + guidance: true, + description: + 'Leave a little room around a block trigger: a held finger grows it from its center, by about 7%, while the menu is up.', + }, { guidance: false, description: @@ -223,7 +228,7 @@ export const docs = { export const docsZh = { usage: { description: - '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', + '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:150 毫秒时块级触发区域(菜单在手指处打开时)开始放大,735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。', bestPractices: [ {guidance: true, description: '保持菜单项简洁和面向操作。'}, {guidance: true, description: '有很多项时使用分组和分隔线。'}, @@ -241,6 +246,11 @@ export const docsZh = { description: '重要的移动端操作还应提供可见的 MoreMenu 或同等入口,不要只依赖长按。', }, + { + guidance: true, + description: + '块级触发区域周围请留出少量空间:按住时它会从中心放大约 7%,直到菜单关闭。', + }, { guidance: false, description: '将上下文菜单作为访问重要操作的唯一方式。', @@ -259,7 +269,7 @@ export const docsDense = { usage: { anatomy, description: - 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms.', + 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms; a block trigger whose menu opens at the finger grows from 150 ms.', bestPractices: [ {guidance: true, description: 'Keep items concise and action-oriented.'}, { @@ -280,6 +290,11 @@ export const docsDense = { guidance: true, description: 'Keep a visible mobile entry point for important actions.', }, + { + guidance: true, + description: + 'Leave room around a block trigger: a held finger grows it about 7%.', + }, { guidance: false, description: 'Use as the only way to access important actions.', diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 17213536c4441..e65447bba669b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -603,113 +603,113 @@ describe('ContextMenu', () => { fireEvent.contextMenu(trigger, {clientX: 0, clientY: 0, detail: 0}); expect(HTMLElement.prototype.showPopover).toHaveBeenCalled(); }); +}); - describe('a menu that was never opened', () => { - it('adds no popover element and no menu until its first open, which mounts and shows it', () => { - render( - -
Right-click me
-
, - ); - expect(document.querySelector('[popover]')).toBeNull(); - expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); - openMenu(); - const popover = document.querySelector('[popover]'); - expect(popover).not.toBeNull(); - expect(popover?.hasAttribute('popover-open')).toBe(true); - expect( - screen.getByRole('menuitem', {name: 'Cut', hidden: true}), - ).toBeInTheDocument(); - }); +describe('a menu that was never opened', () => { + it('adds no popover element and no menu until its first open, which mounts and shows it', () => { + render( + +
Right-click me
+
, + ); + expect(document.querySelector('[popover]')).toBeNull(); + expect(screen.queryByRole('menu', {hidden: true})).toBeNull(); + openMenu(); + const popover = document.querySelector('[popover]'); + expect(popover).not.toBeNull(); + expect(popover?.hasAttribute('popover-open')).toBe(true); + expect( + screen.getByRole('menuitem', {name: 'Cut', hidden: true}), + ).toBeInTheDocument(); + }); - it('shows a first open as its layer attaches, and a later open within the event', () => { - render( - -
Right-click me
-
, + it('shows a first open as its layer attaches, and a later open within the event', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByText('Right-click me'); + const open = () => + trigger.dispatchEvent( + new MouseEvent('contextmenu', {bubbles: true, cancelable: true}), ); - const trigger = screen.getByText('Right-click me'); - const open = () => - trigger.dispatchEvent( - new MouseEvent('contextmenu', {bubbles: true, cancelable: true}), - ); - const showPopover = vi.mocked(HTMLElement.prototype.showPopover); - act(() => { - open(); - // No layer yet: the show waits for its popover element. - expect(showPopover).not.toHaveBeenCalled(); - }); - // The commit attached the layer, which showed it. - expect(showPopover).toHaveBeenCalledTimes(1); - fireEvent.keyDown(document, {key: 'Escape'}); - act(() => { - open(); - // Mounted: shown in the event itself. - expect(showPopover).toHaveBeenCalledTimes(2); - }); + const showPopover = vi.mocked(HTMLElement.prototype.showPopover); + act(() => { + open(); + // No layer yet: the show waits for its popover element. + expect(showPopover).not.toHaveBeenCalled(); }); - - it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => { - render( - - - , - ); - const button = screen.getByRole('button', {name: 'Right-click me'}); - button.focus(); - // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count. - fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0}); - await waitFor(() => { - expect( - screen.getByRole('menuitem', {name: 'Cut', hidden: true}), - ).toHaveFocus(); - }); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(button).toHaveFocus(); + // The commit attached the layer, which showed it. + expect(showPopover).toHaveBeenCalledTimes(1); + fireEvent.keyDown(document, {key: 'Escape'}); + act(() => { + open(); + // Mounted: shown in the event itself. + expect(showPopover).toHaveBeenCalledTimes(2); }); + }); - it('carries the same trigger and cursor anchor attributes as after it opened', () => { - render( - -
Right-click me
-
, - ); - const trigger = screen.getByTestId('ctx'); - const describe = () => ({ - trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`), - anchor: [...(trigger.lastElementChild?.attributes ?? [])].map( - a => a.name, - ), - anchorName: ( - (trigger.lastElementChild as HTMLElement | null)?.style as unknown as - Record | undefined - )?.anchorName, - }); - const before = describe(); - openMenu(); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(describe()).toEqual(before); - expect(trigger.hasAttribute('aria-controls')).toBe(false); - expect(trigger.hasAttribute('aria-haspopup')).toBe(false); + it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => { + render( + + + , + ); + const button = screen.getByRole('button', {name: 'Right-click me'}); + button.focus(); + // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count. + fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0}); + await waitFor(() => { + expect( + screen.getByRole('menuitem', {name: 'Cut', hidden: true}), + ).toHaveFocus(); }); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(button).toHaveFocus(); + }); - it('keeps its layer mounted after the first close, and a second open shows the same menu', () => { - render( - -
Right-click me
-
, - ); - openMenu(); - const menu = screen.getByRole('menu', {hidden: true}); - fireEvent.keyDown(document, {key: 'Escape'}); - expect(menu.isConnected).toBe(true); - expect( - document.querySelector('[popover]')?.hasAttribute('popover-open'), - ).toBe(false); - openMenu(); - expect(screen.getByRole('menu', {hidden: true})).toBe(menu); - expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); + it('carries the same trigger and cursor anchor attributes as after it opened', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + const describe = () => ({ + trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`), + anchor: [...(trigger.lastElementChild?.attributes ?? [])].map( + a => a.name, + ), + anchorName: ( + (trigger.lastElementChild as HTMLElement | null)?.style as unknown as + Record | undefined + )?.anchorName, }); + const before = describe(); + openMenu(); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(describe()).toEqual(before); + expect(trigger.hasAttribute('aria-controls')).toBe(false); + expect(trigger.hasAttribute('aria-haspopup')).toBe(false); + }); + + it('keeps its layer mounted after the first close, and a second open shows the same menu', () => { + render( + +
Right-click me
+
, + ); + openMenu(); + const menu = screen.getByRole('menu', {hidden: true}); + fireEvent.keyDown(document, {key: 'Escape'}); + expect(menu.isConnected).toBe(true); + expect( + document.querySelector('[popover]')?.hasAttribute('popover-open'), + ).toBe(false); + openMenu(); + expect(screen.getByRole('menu', {hidden: true})).toBe(menu); + expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2); }); }); @@ -939,6 +939,11 @@ describe.each([ }, ); + it('leaves a disabled trigger out of the press machine (spec:AST-094 FR24)', () => { + const trigger = renderMenu({isDisabled: true}); + expect(trigger).not.toHaveAttribute('data-astryx-pressable'); + }); + it('lights no row when a touch opens it: focus goes to the menu itself, not its first item', () => { const trigger = renderMenu(); const press = finger(trigger); @@ -1178,6 +1183,11 @@ describe.each([ expect(onTouchEnd).toHaveBeenCalledTimes(1); }); + it('makes the trigger a pressable surface of core’s one press machine', () => { + const trigger = renderMenu(); + expect(trigger).toHaveAttribute('data-astryx-pressable'); + }); + it('keeps today’s menu with presentation="popover": a held finger opens it at 500 ms through today’s long press, with no growth and no scroll lock', () => { const trigger = renderMenu({presentation: 'popover'}); const shows = () => @@ -1713,6 +1723,34 @@ describe('ContextMenu compound mode', () => { expect(anchor.style.top).toBe('40px'); }); + it("places a right-click's anchor at the pointer offset inside a scaled container, with no scale of its own", () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('ctx'); + // A 200x120 trigger inside an ancestor scaled to 0.5. + Object.defineProperty(trigger, 'offsetWidth', {value: 200}); + Object.defineProperty(trigger, 'offsetHeight', {value: 120}); + trigger.getBoundingClientRect = () => + ({ + left: 100, + top: 50, + right: 200, + bottom: 110, + width: 100, + height: 60, + }) as DOMRect; + fireEvent.contextMenu(trigger, {clientX: 150, clientY: 80, detail: 1}); + const anchor = getCursorAnchor(trigger); + expect(anchor.style.left).toBe('50px'); + expect(anchor.style.top).toBe('30px'); + // The ancestor's scale is not the press feedback's growth: nothing to cancel. + expect(anchor.style.scale).toBe(''); + expect(anchor.style.translate).toBe(''); + }); + it('anchors a keyboard-invoked menu to the trigger bottom-left', () => { render( diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index dafc716443ee3..83adbec81a3c2 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -40,6 +40,7 @@ * - /packages/core/src/ContextMenu/ContextMenu.test.tsx * - /packages/core/src/ContextMenu/ContextMenuTouch.ts (the touch layer's one import site) * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline) + * - /packages/core/src/ContextMenu/contextMenuPressFeedback.ts (press feedback) * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx * - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks) @@ -82,6 +83,7 @@ import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow'; import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn'; import {useInternalListFocus} from '../hooks/useListFocus'; import {useLongPress} from '../hooks/useLongPress'; +import {pressableProps} from '../utils/pressFeedback'; import {useMenuPress} from '../hooks/useMenuPress'; import {useTypeahead} from '../hooks/useTypeahead'; import {shadowHostOf} from '../utils/flatTree'; @@ -236,6 +238,20 @@ const styles = stylex.create({ }, }); +/** An element's own computed `scale` on each axis; 1 where it has none. */ +function ownScale(element: HTMLElement): {x: number; y: number} { + const value = + element.ownerDocument.defaultView?.getComputedStyle(element).scale ?? ''; + if (value === '' || value === 'none') { + return {x: 1, y: 1}; + } + const [x, y = x] = value.trim().split(/\s+/).map(Number.parseFloat); + return { + x: Number.isFinite(x) && x > 0 ? x : 1, + y: Number.isFinite(y) && y > 0 ? y : 1, + }; +} + /** * Keeps the 1px cursor anchor inside the trigger along one axis. A point in * the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by @@ -425,18 +441,25 @@ export function ContextMenu({ const currentTitle = currentSubmenu?.label ?? label; const sheetLabel = typeof currentTitle === 'string' ? currentTitle : label; - // The mounted touch layer, which says whether it times a finger. + // The mounted touch layer, for its press feedback and whether it times a + // finger. const touchHandleRef = useRef(null); + // The menu closed: a finger's menu is over, and the trigger returns with + // the close itself (run by every close handler below). + const endTouchOpen = useCallback(() => { + touchHandleRef.current?.release(); + }, []); const updateOpenState = useCallback( (nextIsOpen: boolean) => { if (!nextIsOpen) { setSubmenuPath([]); + endTouchOpen(); } setIsOpen(nextIsOpen); onOpenChange?.(nextIsOpen); }, - [onOpenChange], + [endTouchOpen, onOpenChange], ); const outsidePress = useOutsidePress(); @@ -450,6 +473,7 @@ export function ContextMenu({ keepsMountAfterShow: true, onHide: useCallback(() => { outsidePress.stop(); + endTouchOpen(); setIsOpen(false); onOpenChange?.(false); // Restore focus to the element that was focused before opening. @@ -458,7 +482,7 @@ export function ContextMenu({ if (toRestore && document.contains(toRestore)) { toRestore.focus(); } - }, [onOpenChange, outsidePress]), + }, [endTouchOpen, onOpenChange, outsidePress]), onShow: useCallback(() => { setIsOpen(true); onOpenChange?.(true); @@ -625,6 +649,16 @@ export function ContextMenu({ [listNavKeyDown, closeMenu, typeahead, ownsEvent], ); + // How much the press feedback has grown a block trigger from its center: + // the trigger's own `scale`, not an ancestor's transform. + const triggerScale = useCallback((): {x: number; y: number} => { + const trigger = triggerRef.current; + if (trigger == null || triggerAs === 'span') { + return {x: 1, y: 1}; + } + return ownScale(trigger); + }, [triggerAs]); + // Place the 1px cursor anchor at a point in the trigger's local // coordinate space and open the menu. Positioning the anchor inside the // trigger (rather than storing viewport coordinates on the menu itself) is @@ -641,13 +675,29 @@ export function ContextMenu({ focusTarget: 'first' | 'menu' = 'first', ) => { const rect = triggerRef.current?.getBoundingClientRect(); - const x = rect ? keepAnchorInside(localX, rect.width) : localX; - const y = rect ? keepAnchorInside(localY, rect.height) : localY; + const scale = rect ? triggerScale() : {x: 1, y: 1}; + const x = rect ? keepAnchorInside(localX, rect.width / scale.x) : localX; + const y = rect ? keepAnchorInside(localY, rect.height / scale.y) : localY; positionRef.current = {x, y}; const anchorEl = cursorAnchorRef.current; if (anchorEl) { anchorEl.style.left = `${x}px`; anchorEl.style.top = `${y}px`; + // A trigger the press feedback has grown (from its center) would + // carry the anchor with it, and engines differ on whether anchor + // positioning follows transforms. Cancel the scale at the anchor, so + // its box is the same either way: where the point is on screen. + if (rect != null && (scale.x !== 1 || scale.y !== 1)) { + const centerX = rect.width / scale.x / 2; + const centerY = rect.height / scale.y / 2; + anchorEl.style.transformOrigin = '0 0'; + anchorEl.style.scale = `${1 / scale.x} ${1 / scale.y}`; + anchorEl.style.translate = `${(x - centerX) * (1 / scale.x - 1)}px ${(y - centerY) * (1 / scale.y - 1)}px`; + } else { + anchorEl.style.removeProperty('transform-origin'); + anchorEl.style.removeProperty('scale'); + anchorEl.style.removeProperty('translate'); + } } // Remember the element focused before opening so we can restore it on // close (Escape or outside-click), instead of dropping focus to . @@ -665,7 +715,7 @@ export function ContextMenu({ layer.show(); } }, - [layer, updateOpenState, usesBottomSheet], + [layer, updateOpenState, usesBottomSheet, triggerScale], ); // The hold's commit: the menu shows at its moment or at the lift, so its @@ -684,19 +734,25 @@ export function ContextMenu({ ); // A client point in the trigger's own coordinates, where the cursor anchor - // lives. It stays inside the trigger's box. + // lives. It is the offset from the trigger's laid-out box, before the press + // feedback's scale, and it stays inside that box. const toTriggerLocal = useCallback( (point: {x: number; y: number}): {x: number; y: number} => { const rect = triggerRef.current?.getBoundingClientRect(); if (rect == null) { return point; } + const scale = triggerScale(); + const width = rect.width / scale.x; + const height = rect.height / scale.y; + const left = rect.left + (rect.width - width) / 2; + const top = rect.top + (rect.height - height) / 2; return { - x: Math.min(Math.max(point.x - rect.left, 0), rect.width), - y: Math.min(Math.max(point.y - rect.top, 0), rect.height), + x: Math.min(Math.max(point.x - left, 0), width), + y: Math.min(Math.max(point.y - top, 0), height), }; }, - [], + [triggerScale], ); const handleContextMenu = useCallback( @@ -957,6 +1013,10 @@ export function ContextMenu({ ({ + elements: [] as HTMLElement[], + play: vi.fn(), + animate: vi.fn(), +})); + +vi.mock('../motion/animator', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + createMotionEngine: (element: HTMLElement) => { + animator.elements.push(element); + const pending = () => ({finished: new Promise(() => {})}); + return { + element, + play: (...args: unknown[]) => { + animator.play(element, ...args); + return pending(); + }, + animate: (...args: unknown[]) => { + animator.animate(element, ...args); + return pending(); + }, + stop: () => {}, + dispose: () => {}, + get: () => 1, + getVelocity: () => 0, + reducedMotion: () => 'off', + }; + }, + }; +}); + +interface Contact { + id: number; + x: number; + y: number; +} + +function dispatchTouch( + type: 'touchstart' | 'touchmove' | 'touchend', + target: EventTarget, + changed: Contact[], + all: Contact[], +) { + const event = new Event(type, {bubbles: true, cancelable: true}); + const toTouch = (contact: Contact) => ({ + identifier: contact.id, + clientX: contact.x, + clientY: contact.y, + target, + }); + Object.defineProperty(event, 'changedTouches', {value: changed.map(toTouch)}); + Object.defineProperty(event, 'touches', {value: all.map(toTouch)}); + Object.defineProperty(event, 'timeStamp', {value: performance.now()}); + target.dispatchEvent(event); +} + +const advance = (ms: number) => + act(() => { + vi.advanceTimersByTime(ms); + }); + +/** The press machine's "on", as core's press machine writes it. */ +async function pressMachineOn(trigger: HTMLElement) { + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await act(async () => {}); +} + +/** The press machine lets the press go. */ +async function pressMachineOff(trigger: HTMLElement) { + trigger.removeAttribute(PRESSED_ATTRIBUTE); + await act(async () => {}); +} + +// The gesture host remembers the latest event time, and each install of fake +// timers restarts `performance.now()` near 0. So every case moves the clock on. +let clockEpoch = 0; + +describe('ContextMenu’s growth under a finger', () => { + beforeEach(() => { + 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(), + })), + ); + vi.useFakeTimers({ + toFake: [ + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + 'Date', + ], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + animator.elements.length = 0; + }); + + function renderMenu() { + render( + +
Hold me
+
, + ); + return screen.getByTestId('ctx'); + } + + const scaleBack = (trigger: HTMLElement) => + animator.animate.mock.calls.filter( + ([element, to]) => + element === trigger && (to as {scale?: number}).scale === 1, + ); + + it('springs a grown trigger back when the finger lifts before the commit', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(1); + advance(100); + dispatchTouch('touchend', trigger, [contact], []); + expect(scaleBack(trigger)).toHaveLength(1); + }); + + const growths = (trigger: HTMLElement) => + animator.animate.mock.calls.filter( + ([element, to]) => + element === trigger && ((to as {scale?: number}).scale ?? 1) > 1, + ); + const startOf = (call: unknown[]) => (call[3] as {at: number}).at; + + it('starts the growth at the onset, 150 ms after the finger lands, when the press machine marks the press as the finger lands', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + // Outside a scroll view the press machine marks a press as it lands. + await pressMachineOn(trigger); + advance(149); + expect(growths(trigger)).toHaveLength(0); + advance(1); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows the trigger at the onset when the press machine marks the press before the recognizer has the finger', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + // In a browser the press machine's mark comes first: its listener runs + // before the recognizer's, and the mark is read between the two. + await pressMachineOn(trigger); + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('still grows at the onset after a drift past the press machine’s 10 px that the recognizer’s 11.5 px tolerance still follows', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + await pressMachineOn(trigger); + advance(60); + // An 11 px drift: the press machine lets the press go at 10 px. + const drifted = {...contact, x: 31}; + dispatchTouch('touchmove', trigger, [drifted], [drifted]); + await pressMachineOff(trigger); + advance(90); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows at the onset after a drift the press machine never marks, as inside a scroll view, that the recognizer still follows', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(60); + // An (8, 8) px drift, 11.3 px: past the machine's 10 px, so it never + // marks the press, and inside the recognizer's 11.5 px. + const drifted = {...contact, x: 28, y: 28}; + dispatchTouch('touchmove', trigger, [drifted], [drifted]); + advance(90); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('grows at the onset, 150 ms after the finger lands, even when the press machine marks the press later', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const landedAt = performance.now(); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(150); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + // Inside a scroll view the machine's own timer marks it later. + advance(90); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(1); + }); + + it('grows a pen’s press at the onset from the press machine’s mark, which finds the recognizer following the pen', async () => { + const trigger = renderMenu(); + const landedAt = performance.now(); + const down = new Event('pointerdown', {bubbles: true, cancelable: true}); + Object.assign(down, { + pointerId: 7, + pointerType: 'pen', + isPrimary: true, + button: 0, + buttons: 1, + clientX: 20, + clientY: 20, + }); + Object.defineProperty(down, 'timeStamp', {value: landedAt}); + // The press machine marks the pen's press from the same pointerdown. + trigger.dispatchEvent(down); + await act(async () => {}); + expect(trigger.getAttribute(PRESSED_ATTRIBUTE)).toBe('on'); + advance(149); + expect(growths(trigger)).toHaveLength(0); + advance(1); + expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + }); + + it('does not grow the trigger for a press its recognizer does not follow, such as a mouse', async () => { + const trigger = renderMenu(); + await pressMachineOn(trigger); + expect(growths(trigger)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index a42432403acf2..136b9bc943635 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -7,7 +7,7 @@ * @input ContextMenu's touch layer (ContextMenuTouch.ts), the gesture * foundation and the UIKit context-menu constants * @output ContextMenuTouchLayer: renders nothing; runs the trigger's press - * recognizer while ContextMenu is under the touch model + * recognizer and press feedback while ContextMenu is under the touch model * @position Rendered by ContextMenu while the touch model applies * * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx @@ -15,19 +15,28 @@ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; import {flushSync} from 'react-dom'; +import {isTerminal} from '../gestures/GestureRecognizer'; import {playHaptic} from '../gestures/haptics'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; -import {ContextMenuPressRecognizer} from './ContextMenuTouch'; +import {isElement} from '../utils/nodeType'; +import { + ContextMenuPressRecognizer, + useContextMenuPressFeedback, +} from './ContextMenuTouch'; /** What ContextMenu reaches of a mounted touch layer. */ export interface ContextMenuTouchHandle { + /** The menu the press opened closed: the trigger returns. */ + release(): void; /** Whether the recognizer is timing a finger on the trigger. */ isTracking(): boolean; } export interface ContextMenuTouchLayerProps { triggerRef: RefObject; + /** False for an inline trigger, which does not grow. */ + isShown: boolean; /** Receives the layer's handle as it mounts, and null as it unmounts. */ ref?: Ref; /** Open the menu at a client point (where the finger landed). */ @@ -49,15 +58,34 @@ export interface ContextMenuTouchLayerProps { * lift sends no click and no compatibility mouse events. The menu opens where * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. * A lift before the commit, a second finger or a scroll gets no menu. So does - * a drift past the tolerance before the menu shows. + * a drift past the tolerance before the menu shows. The press feedback grows + * the trigger from the onset, 150 ms after the finger lands, while the + * recognizer follows the finger. It springs back when the recognizer ends the + * press with no menu, and it holds while that menu is up. */ export function ContextMenuTouchLayer({ triggerRef, + isShown, ref, onOpen, onCommit, onCancel, }: ContextMenuTouchLayerProps): null { + // Whether the recognizer follows a finger or a pen now. It is set below, + // where the recognizer exists. + const isFollowedRef = useRef<() => boolean>(() => false); + // When the finger the recognizer follows landed, for the growth's onset. + const landedAtRef = useRef<() => number | null>(() => null); + const followsFingerRef = useRef<() => boolean>(() => false); + const feedback = useContextMenuPressFeedback( + triggerRef, + isShown, + isFollowedRef, + landedAtRef, + followsFingerRef, + ); + const feedbackRef = useRef(feedback); + feedbackRef.current = feedback; const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; const onCommitRef = useRef(onCommit); @@ -73,16 +101,31 @@ export function ContextMenuTouchLayer({ () => { const recognizer = new ContextMenuPressRecognizer(); // Its target-action listeners never hear `failed`, so a press that ends - // with no menu releases what the commit mounted from here. - recognizer.addTransitionListener((_press, _from, to) => { + // with no menu springs back from here. + recognizer.addTransitionListener((press, _from, to) => { if (to === 'failed' || to === 'cancelled') { + feedbackRef.current.end(press.clockTime); onCancelRef.current?.(); } }); + // A finger's growth counts from the landing the recognizer records as + // it takes the touch. A pen's press waits for the press machine's mark. + recognizer.onPressBegan = (touch, time) => { + if (touch.pointerType === 'touch') { + feedbackRef.current.begin( + time, + isElement(touch.target) ? touch.target : null, + ); + } + }; return recognizer; }, press => { const open = () => { + // The press feedback grew the trigger from its center, and the cursor + // anchor inside it moves with that scale. So stop the growth where it + // is. ContextMenu places the anchor under the finger. + feedbackRef.current.hold(); hasPressOpenedRef.current = true; // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- the menu shows in the task that decides it, as UIKit shows it at its moment, not a scheduled task later flushSync(() => { @@ -117,9 +160,17 @@ export function ContextMenuTouchLayer({ }, ); + isFollowedRef.current = () => + recognizer.numberOfTouches > 0 && !isTerminal(recognizer.state); + landedAtRef.current = () => recognizer.press?.time ?? null; + followsFingerRef.current = () => + isFollowedRef.current() && + recognizer.trackedTouches[0]?.pointerType === 'touch'; + useImperativeHandle( ref, () => ({ + release: () => feedbackRef.current.release(), isTracking: () => recognizer.numberOfTouches > 0, }), [recognizer], diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx new file mode 100644 index 0000000000000..fd65b7b69febd --- /dev/null +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayerProps.test.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ContextMenuTouchLayerProps.test.tsx + * @input ContextMenu under a coarse pointer, with its touch layer replaced + * @output Checks what ContextMenu asks of its touch layer: a block trigger + * grows, and a trigger whose menu is a bottom sheet does not + * @position Unit tests of packages/core/src/ContextMenu/ContextMenu.tsx + */ + +import {render, waitFor} from '@testing-library/react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ContextMenu} from './ContextMenu'; +import {ContextMenuTouchLayer} from './ContextMenuTouchLayer'; + +vi.mock('./ContextMenuTouchLayer', () => ({ + ContextMenuTouchLayer: vi.fn(() => null), +})); + +function stubCoarsePointer() { + 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(), + })), + ); +} + +describe('ContextMenu and its touch layer', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.mocked(ContextMenuTouchLayer).mockClear(); + }); + + async function shownProp(presentation?: 'bottom-sheet') { + stubCoarsePointer(); + render( + +
Hold me
+
, + ); + await waitFor(() => { + expect(ContextMenuTouchLayer).toHaveBeenCalled(); + }); + return vi.mocked(ContextMenuTouchLayer).mock.lastCall?.[0].isShown; + } + + it('grows a block trigger whose menu is anchored to it', async () => { + expect(await shownProp()).toBe(true); + }); + + it('does not grow a trigger whose menu is a bottom sheet', async () => { + expect(await shownProp('bottom-sheet')).toBe(false); + }); +}); diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index deddefbbed4bf..b83c187617373 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -20,24 +20,56 @@ import { type Page, } from '@astryxdesign/a11y-spec/playwright'; import { + blockMainThread, + busyWindowProof, + castStoryboard, + countBetween, drag, FRAME_STARVATION_MS, FrameStarvationError, + freezeTimers, + holdAnimations, installInputLog, + installLongTaskLog, + installTimerGate, + markElement, + merge, openStory, readInputLog, + releaseAnimations, + startScreencast, tap, + thawTimers, + timeline, touchDriver, + trackMarker, + traceRendering, + waitForAnimation, + withFinger, withStorybook, + writeEvidence, + animationStoryboard, type TouchDriver, } from '@astryxdesign/interaction-harness'; +import {solveSpring, toSpring} from '../../motion/spring'; import {contextMenu} from '../../motion/uikit/contextMenu'; +import {springs} from '../../motion/uikit/springs'; const storybook = withStorybook(test); -const {menuDisplayMs} = contextMenu.press; -// Core's press machine believes a press 150 ms after it lands -// (`utils/pressGesture.ts`). That module loads a StyleX stylesheet, which -// this Node-side spec cannot, so the onset is restated here. +const {commitMs, menuDisplayMs, heldScale} = contextMenu.press; +// The growth starts at the onset, 150 ms after the finger lands +// (`PRESS_ONSET_MS` in `utils/pressGesture.ts`). That module loads a StyleX +// stylesheet, which this Node-side spec cannot, so the onset is restated here. +const feedbackOnsetMs = 150; + +/** The growth's expected curve: `springs.contextMenuLift` from 1 to the held scale. */ +function playedCurve() { + const curve = solveSpring(toSpring(springs.contextMenuLift), { + from: 1, + to: heldScale, + }); + return {at: (t: number) => curve.value(Math.max(0, t))}; +} interface TouchStory { driver: TouchDriver; @@ -51,14 +83,17 @@ async function openTouchStory( story = 'core-contextmenu--touch-long-press', ): Promise { await installInputLog(page); + await installLongTaskLog(page); // Note when the menu is asked to show, on the page clock (no per-frame // work: rendering proofs share this page). await page.addInitScript(() => { const w = window as unknown as { __menuShownAt?: number | null; + __liftHandledAt?: number | null; __menuShownTimerAt?: number | null; }; w.__menuShownAt = null; + w.__liftHandledAt = null; w.__menuShownTimerAt = null; // When the timer task that runs now started. The hold's moments come from // the gesture arena's timers. The menu commits in the task that decides @@ -85,6 +120,15 @@ async function openTouchStory( timeout, ...args, )) as typeof window.setTimeout; + // When the page gets to the lift (the event's own timestamp is the + // hardware's, or the script's): the menu shows in the same task. + addEventListener( + 'touchend', + () => { + w.__liftHandledAt = performance.now(); + }, + {capture: true, passive: true}, + ); const show = HTMLElement.prototype.showPopover; HTMLElement.prototype.showPopover = function ( this: HTMLElement, @@ -148,6 +192,12 @@ async function lastInput( const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); +const presentedScale = async (trigger: Locator) => + trigger.evaluate(element => { + const scale = getComputedStyle(element).scale; + return scale === 'none' ? 1 : Number.parseFloat(scale); + }); + /** A finger held still for `ms`, then lifted. */ const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); @@ -262,6 +312,554 @@ test('the menu mounts at the commit, closed and out of the accessibility tree, a await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); }); +test('with presentation="popover" a finger keeps today’s long press: the menu at 500 ms, with no growth and no iOS timeline', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory( + page, + 'core-contextmenu--touch-long-press-popover', + ); + // A coarse primary pointer: only the caller's choice keeps today's menu. + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(true); + expect(await trigger.getAttribute('data-astryx-pressable')).toBeNull(); + const playing = driver.play(hold(center, 1000)); + await page.waitForTimeout(350); + const scaleBeforeOpen = await presentedScale(trigger); + // Still held: the menu is up. At the lift, emulated Chromium sends + // compatibility mouse events that a device sends no click for. They dismiss + // today's menu. The touch model's recognizer claims the finger instead. + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible({ + timeout: 550, + }); + await playing; + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // Today's 500 ms, give or take a task and the render that shows the menu; + // far from the touch model's 735 ms. + expect(after).toBeGreaterThanOrEqual(500); + expect(after).toBeLessThan(550); + expect(scaleBeforeOpen).toBe(1); +}); + +test('a finger where the primary pointer is fine keeps today’s long press: the menu at 500 ms, with no growth', async ({ + browser, + page, +}) => { + // A touch screen whose primary pointer is fine (a laptop with a trackpad). + const context = await browser.newContext({ + viewport: page.viewportSize() ?? {width: 402, height: 874}, + hasTouch: true, + isMobile: false, + }); + const fine = await context.newPage(); + try { + await installInputLog(fine); + await openStory(fine, storybook(), 'core-contextmenu--touch-long-press'); + const coarse = await fine.evaluate( + () => matchMedia('(pointer: coarse)').matches, + ); + test.skip( + coarse, + 'this engine reports a coarse primary pointer whenever touch is emulated; the fine-primary finger is covered in jsdom', + ); + const trigger = fine.getByTestId('touch-context-menu'); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + expect(await trigger.getAttribute('data-astryx-pressable')).toBeNull(); + const driver = await touchDriver(fine); + const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + const playing = driver.play(hold(center, 1000)); + await fine.waitForTimeout(350); + const scaleBeforeOpen = await presentedScale(trigger); + await expect(fine.getByRole('menu', {name: 'Photo actions'})).toBeVisible({ + timeout: 550, + }); + await playing; + expect(scaleBeforeOpen).toBe(1); + } finally { + await context.close(); + } +}); + +test('under Reduce Motion the card does not grow, and the menu still opens at 735 ms', async ({ + page, +}) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver, trigger, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 200)); + // Sampled while the finger is down: before the commit, after it, and just + // before the menu. + const scales: number[] = []; + for (const wait of [300, 250, 100]) { + await page.waitForTimeout(wait); + scales.push(await presentedScale(trigger)); + } + await playing; + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(scales).toEqual([1, 1, 1]); + expect(await trigger.evaluate(el => el.getAnimations().length)).toBe(0); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + // The touch's stamp and the page clock are each coarsened (0.1 ms). + expect(after).toBeGreaterThanOrEqual(menuDisplayMs - 0.5); + expect(after).toBeLessThan(menuDisplayMs + 20); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + +test('the trigger’s selection and callout lock: its own style keeps it off at rest, and the gesture host refuses a selection for a held press', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory(page); + // WebKit spells it `-webkit-user-select` only. + const readLock = async () => + trigger.evaluate(el => { + const host = el as HTMLElement; + const computed = getComputedStyle(host); + return { + inline: + host.style.getPropertyValue('user-select') || + host.style.getPropertyValue('-webkit-user-select'), + computed: + computed.getPropertyValue('user-select') || + computed.getPropertyValue('-webkit-user-select'), + }; + }); + // Off iOS, the host's lock refuses a selection that starts on the element. + const refusesSelection = async () => + trigger.evaluate(el => { + const event = new Event('selectstart', {bubbles: true, cancelable: true}); + el.dispatchEvent(event); + return event.defaultPrevented; + }); + // At rest the trigger's own style keeps selection off (spec:AST-017 + // FR59), and nothing refuses a selection. + const rest = await readLock(); + expect(rest.computed).toBe('none'); + expect(await refusesSelection()).toBe(false); + // A held finger, short of the commit: the host's lock refuses a selection. + const playing = driver.play(hold(center, commitMs - 100)); + await page.waitForTimeout(commitMs / 2); + const heldRefuses = await refusesSelection(); + const held = await readLock(); + await playing; + await page.waitForTimeout(100); + // Lifted with no menu: back to the rest state. + const liftedRefuses = await refusesSelection(); + const lifted = await readLock(); + expect(heldRefuses).toBe(true); + expect(held.computed).toBe('none'); + expect(liftedRefuses).toBe(false); + expect(lifted).toEqual(rest); + expect( + await page.getByRole('menu', {name: 'Photo actions'}).isVisible(), + ).toBe(false); +}); + +test('a touch open lights no row: the menu itself takes the focus, and every row keeps its resting background', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + const playing = driver.play(hold(center, menuDisplayMs + 300)); + const menu = page.getByRole('menu', {name: 'Photo actions'}); + await expect(menu).toBeVisible(); + // The focus moves on the frame after the menu shows. + await page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }), + ); + await expect(menu).toBeFocused(); + // An internal focus target: no browser ring on it. + expect(await menu.evaluate(el => getComputedStyle(el).outlineStyle)).toBe( + 'none', + ); + const backgrounds = await menu + .getByRole('menuitem') + .evaluateAll(rows => + rows.map(row => getComputedStyle(row).backgroundColor), + ); + expect(new Set(backgrounds).size).toBe(1); + await playing; +}); + +test('a finger lifted after the commit gets the menu at the lift, and sends no click', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, 530)); + await page.waitForTimeout(300); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + const {lift, shown} = await page.evaluate(() => { + const w = window as unknown as { + __menuShownAt?: number | null; + __liftHandledAt?: number | null; + }; + return {lift: w.__liftHandledAt ?? null, shown: w.__menuShownAt ?? null}; + }); + expect(lift).not.toBeNull(); + expect(shown).not.toBeNull(); + // In the lift's own task: after it, and before the next frame could paint. + expect((shown as number) - (lift as number)).toBeGreaterThanOrEqual(0); + expect((shown as number) - (lift as number)).toBeLessThan(1000 / 60); + const log = await readInputLog(page); + const end = log.filter(record => record.type === 'touchend').at(-1); + expect(end?.defaultPrevented, JSON.stringify(log)).toBe(true); + expect(log.filter(record => record.type === 'click')).toEqual([]); +}); + +test('the lift after the menu shows keeps it open and sends no click', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play(hold(center, menuDisplayMs + 150)); + await page.waitForTimeout(300); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + const log = await readInputLog(page); + expect(log.filter(record => record.type === 'click')).toEqual([]); +}); + +test('the menu’s anchor sits under the finger, on screen and as laid out, while the trigger is grown, and the menu stays put', async ({ + page, +}) => { + const {driver, trigger} = await openTouchStory(page); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + const menu = page.getByRole('menu', {name: 'Photo actions'}); + // The cursor anchor (the trigger's last child), on screen and as laid out + // (engines differ on which one anchor positioning reads), and the menu. + const read = async () => + trigger.evaluate(el => { + const anchor = el.lastElementChild as HTMLElement | null; + const box = el.getBoundingClientRect(); + const host = el as HTMLElement; + const left = box.left + (box.width - host.offsetWidth) / 2; + const top = box.top + (box.height - host.offsetHeight) / 2; + const onScreen = anchor?.getBoundingClientRect(); + const list = document + .querySelector('[role="menu"]') + ?.getBoundingClientRect(); + return { + onScreen: onScreen == null ? null : {x: onScreen.x, y: onScreen.y}, + laidOut: + anchor == null + ? null + : { + x: left + host.clientLeft + anchor.offsetLeft, + y: top + host.clientTop + anchor.offsetTop, + }, + menu: list == null ? null : {x: list.x, y: list.y}, + }; + }); + // The middle of the card and near its far corner, where an anchor placed + // in the grown trigger's scaled coordinates would land furthest off. + for (const [fx, fy] of [ + [0.5, 0.5], + [0.9, 0.85], + ] as const) { + const p = { + x: Math.round(box.x + box.width * fx), + y: Math.round(box.y + box.height * fy), + }; + await driver.play(hold(p, menuDisplayMs + 100)); + await expect(menu).toBeVisible(); + const first = await read(); + await page.waitForTimeout(400); + const later = await read(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + // Let the trigger return to its size before the next press. + await page.waitForTimeout(1200); + const detail = JSON.stringify({p, first, later}); + expect(first.onScreen?.x, detail).toBeCloseTo(p.x, 0); + expect(first.onScreen?.y, detail).toBeCloseTo(p.y, 0); + expect(first.laidOut?.x, detail).toBeCloseTo(p.x, 0); + expect(first.laidOut?.y, detail).toBeCloseTo(p.y, 0); + expect(later.menu?.x, detail).toBeCloseTo(first.menu?.x ?? NaN, 0); + expect(later.menu?.y, detail).toBeCloseTo(first.menu?.y ?? NaN, 0); + } +}); + +test('a drag past the tolerance after the commit opens nothing', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + await driver.play( + drag( + center, + {x: center.x, y: center.y + 30}, + {durationMs: 50, holdMs: 500, restMs: 400}, + ), + ); + await page.waitForTimeout(200); + expect(await menuShownAt(page)).toBeNull(); +}); + +test('an early lift opens nothing, and the card springs back to its size', async ({ + page, +}) => { + const {driver, trigger, center} = await openTouchStory(page); + await driver.play(hold(center, 280)); + await page.waitForTimeout(commitMs + 200); + expect(await menuShownAt(page)).toBeNull(); + // The return spring settles within ~1.2 s; then the scale is gone. + await expect + .poll(async () => trigger.evaluate(el => (el as HTMLElement).style.scale), { + timeout: 3000, + }) + .toBe(''); + expect(await presentedScale(trigger)).toBe(1); +}); + +test('a second finger landing before the commit cancels: no menu, and the card springs back', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'two-finger input is Chromium-only in Playwright (WebKit drops the first finger)', + ); + const {driver, trigger, center} = await openTouchStory(page); + // The first finger holds; a second lands 200 ms in, before the commit. + await driver.play( + merge( + hold(center, menuDisplayMs + 300), + withFinger(hold({x: center.x + 50, y: center.y}, 400), 1, 200), + ), + ); + await page.waitForTimeout(300); + expect(await menuShownAt(page)).toBeNull(); + expect(await presentedScale(trigger)).toBeCloseTo(1, 3); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); +}); + +test('a drift within the tolerance still opens the menu; past it on one axis cancels', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + // (8, 8): 11.3 px away, within the tolerance on each axis (a long press's + // 10 px distance would cancel it). A drift past the browser's own pan slop + // goes to the page instead, as a scroll view's pan takes it on iOS. Lifted + // after the commit: the menu opens at the lift. + await driver.play( + drag( + center, + {x: center.x + 8, y: center.y + 8}, + {durationMs: 60, holdMs: 100, restMs: commitMs}, + ), + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + + const other = await openTouchStory(page); + await other.driver.play( + drag( + other.center, + {x: other.center.x, y: other.center.y + 12}, + {durationMs: 60, holdMs: 100, restMs: commitMs}, + ), + ); + await page.waitForTimeout(200); + expect(await menuShownAt(page)).toBeNull(); +}); + +test('the press feedback plays its computed curve on the compositor, frame by frame', async ({ + page, +}, testInfo) => { + const {driver, trigger, center} = await openTouchStory(page); + const played = playedCurve(); + const playing = driver.play(hold(center, commitMs + 300)); + await waitForAnimation(trigger, {minProgressMs: 16}); + await holdAnimations(page, trigger); + // Seek the held curve (its time 0 is the onset after the touch) to every + // 60 Hz frame and read the presented scale. + const samples = await trigger.evaluate((element, times) => { + const held = + (window as unknown as {__harnessHeld?: Animation[]}).__harnessHeld ?? []; + return times.map(t => { + for (const animation of held) { + animation.currentTime = t; + } + const scale = getComputedStyle(element).scale; + return {t, value: scale === 'none' ? 1 : Number.parseFloat(scale)}; + }); + }, timeline(560)); + await releaseAnimations(page); + await playing; + const at = (ms: number) => { + const t = ms - feedbackOnsetMs; + if (t <= 0) { + return 1; + } + // Linear between the seeked frames. + const i = samples.findIndex(s => s.t >= t); + if (i <= 0) { + return samples[Math.max(0, i)].value; + } + const a = samples[i - 1]; + const b = samples[i]; + return a.value + ((b.value - a.value) * (t - a.t)) / (b.t - a.t); + }; + // The card is 200 px wide, so a scale error times 200 is in pixels. + const errors = Array.from({length: 33}, (_, i) => feedbackOnsetMs + i * 17) + .filter(ms => ms <= commitMs + 300) + .map(ms => { + const expected = played.at((ms - feedbackOnsetMs) / 1000); + return { + ms, + expected, + web: at(ms), + errorPx: Math.abs(at(ms) - expected) * 200, + }; + }); + const rms = Math.sqrt( + errors.reduce((sum, e) => sum + e.errorPx ** 2, 0) / errors.length, + ); + await writeEvidence( + 'curves/contextmenu-press-feedback', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + {rmsPt: rms, errors, samples}, + null, + 1, + ), + }, + testInfo, + ); + testInfo.annotations.push({ + type: 'curve-parity', + description: `press feedback vs its computed curve (200 px card): rms ${rms.toFixed(3)} px over ${errors.length} frames`, + }); + expect(rms).toBeLessThan(0.1); + expect(Math.max(...errors.map(e => e.errorPx))).toBeLessThan(0.25); +}); + +test('the press feedback keeps advancing every frame while the main thread is blocked', async ({ + page, + browserName, +}, testInfo) => { + test.skip( + browserName === 'webkit', + "WebKit's Linux build runs the trigger's scale, as a transform too, on the main thread: beside a compositor control, its frames hold the trigger's width through the block (README: engine limits). The iOS simulator recording is the WebKit proof.", + ); + const {driver, trigger, center} = await openTouchStory(page); + // A wider card grows by about a CSS pixel a frame, which the screencast + // resolves; it stays inside the viewport at its largest. + await trigger.evaluate(el => { + const host = el as HTMLElement; + (host.firstElementChild as HTMLElement).style.width = '340px'; + }); + const played = playedCurve(); + await markElement(trigger, {mode: 'outline'}); + const cast = await startScreencast(page); + const playing = driver.play(hold(center, 1100)); + // The lift spring moves the card for about 200 ms, so the block covers + // its moving part. + const [feedback] = await waitForAnimation(trigger, {minProgressMs: 16}); + const block = await blockMainThread(page, 150); + await playing; + await page.waitForTimeout(100); + const castFrames = await cast.stop(); + const tracked = await trackMarker(page, castFrames); + const proof = busyWindowProof(tracked, block, {axis: 'width'}); + // What the curve grows by between the first and the last frame of the + // judged window, in the marker's pixels, against what those frames show. + const scaleAt = (epoch: number) => played.at((epoch - feedback.from) / 1000); + const judged = tracked.filter( + f => f.box != null && f.t >= block.from + 20 && f.t <= block.to - 20, + ); + const firstFrame = judged[0]; + const lastFrame = judged.at(-1); + if (firstFrame?.box == null || lastFrame?.box == null) { + throw new Error('the screencast shows no marker during the block'); + } + const restWidth = firstFrame.box.width / scaleAt(firstFrame.t); + const expectedGrowth = + restWidth * (scaleAt(lastFrame.t) - scaleAt(firstFrame.t)); + const shownGrowth = lastFrame.box.width - firstFrame.box.width; + // Widths arrive from the screencast in steps (2 px in Chromium here). One + // step is the instrument's resolution, and the only allowance below. + const widthStep = Math.min( + ...judged.slice(1).flatMap((f, i) => { + const change = Math.abs( + (f.box?.width ?? 0) - (judged[i].box?.width ?? 0), + ); + return change > 0 ? [change] : []; + }), + ); + await writeEvidence( + 'busy-main-thread/contextmenu-press-feedback', + { + [`${testInfo.project.name}.json`]: JSON.stringify( + { + block, + feedback, + proof, + frames: tracked.map(f => ({ + t: +(f.t - block.from).toFixed(1), + w: f.box?.width ?? null, + })), + }, + null, + 1, + ), + }, + testInfo, + ); + const film = await castStoryboard(page, castFrames, { + name: 'contextmenu-press-feedback-busy-main-thread', + zero: block.from, + from: block.from - 40, + to: block.to + 40, + columns: 8, + cellScale: 0.35, + data: t => ({w: tracked.find(f => f.t === t)?.box?.width ?? -1}), + }); + await film.write(testInfo, {block, feedback, expectedGrowth}); + // Frames kept coming at display rate and the card kept growing, as much + // as the curve says, within the screencast's resolution. + expect(proof.medianFrameMs).toBeLessThan(20); + expect(proof.frames).toBeGreaterThanOrEqual(proof.expectedFrames - 2); + expect(proof.monotonic).toBe(true); + expect(proof.longestStallMs).toBeLessThan(4 * (1000 / 60) + 4); + expect(Number.isFinite(widthStep)).toBe(true); + expect(shownGrowth).toBeGreaterThanOrEqual(expectedGrowth - widthStep); +}); + +test('the press feedback does no Layout or Paint per frame', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); + const {driver, center} = await openTouchStory(page); + const timeOrigin = await page.evaluate(() => performance.timeOrigin); + const trace = await traceRendering(page, async () => { + // Lifted just before the commit: the feedback alone, then its return. + await driver.play(hold(center, commitMs - 20)); + }); + const start = timeOrigin + (await touchStartedAt(page)); + const during = countBetween( + trace, + start + feedbackOnsetMs + 50, + start + commitMs - 30, + ); + expect(during.Layout ?? 0, JSON.stringify(during)).toBe(0); + expect(during.Paint ?? 0, JSON.stringify(during)).toBe(0); + expect(trace.longTasks).toEqual([]); +}); + /** The story with somewhere to scroll below it, and its trigger's center. */ async function openScrollableStory(page: Page) { const story = await openTouchStory(page); @@ -282,6 +880,24 @@ async function openScrollableStory(page: Page) { return {...story, center, top}; } +test('the page does not scroll under the finger that held the menu open, and it does not lock the page', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + const before = await top(); + // Held until the menu shows, then dragged up 200 px. + await driver.play( + drag( + center, + {x: center.x, y: center.y - 200}, + {durationMs: 300, holdMs: menuDisplayMs + 50, restMs: 100}, + ), + ); + expect(await top()).toBe(before); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); + expect(await page.evaluate(() => document.body.style.position)).toBe(''); +}); + test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ page, }) => { @@ -319,3 +935,67 @@ test('a new finger that pans outside the open menu closes it, and the page scrol expect(await top()).toBeLessThan(before); } }); + +test('a drag that cancels the press after the commit does not scroll the page either', async ({ + page, +}) => { + const {driver, center, top} = await openScrollableStory(page); + const before = await top(); + await driver.play( + drag( + center, + {x: center.x, y: center.y - 200}, + {durationMs: 300, holdMs: commitMs + 100, restMs: 100}, + ), + ); + expect(await menuShownAt(page)).toBeNull(); + expect(await top()).toBe(before); +}); + +test( + 'storyboard: the press feedback up to the menu', + {tag: '@storyboard'}, + async ({page}, testInfo) => { + const {driver, trigger, center} = await openTouchStory(page); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + // Gate the page's timers first: the commit and the menu wait while the + // held feedback is photographed, and the finger stays down meanwhile. + await installTimerGate(page); + const playing = driver.play(hold(center, 8000)); + await waitForAnimation(trigger, {minProgressMs: 16}); + await freezeTimers(page); + const {storyboard} = await animationStoryboard(page, { + name: 'contextmenu-press-feedback', + clip: { + x: box.x - 24, + y: box.y - 24, + width: box.width + 48, + height: box.height + 48, + }, + scope: trigger, + // t counts from the visible ramp's start, the onset after the touch. + times: timeline(menuDisplayMs - feedbackOnsetMs, 1000 / 30), + measure: async () => ({ + scale: (await presentedScale(trigger)).toFixed(4), + }), + columns: 6, + cellScale: 0.6, + }); + await releaseAnimations(page); + await thawTimers(page); + await playing; + await storyboard.write(testInfo); + const scales = storyboard.frames.map(f => Number(f.data.scale)); + // The lift spring rises to its peak, overshoots by under 3% of its + // travel, and settles at the held scale before the menu displays. + const peak = scales.indexOf(Math.max(...scales)); + expect( + scales.slice(0, peak + 1).every((s, i, a) => i === 0 || s >= a[i - 1]), + ).toBe(true); + expect(scales[peak]).toBeLessThan(heldScale + (heldScale - 1) * 0.03); + expect(scales.at(-1)).toBeCloseTo(heldScale, 2); + }, +); diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index fdc7db94eb237..e78f6f4fca17d 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -9,7 +9,7 @@ * even when the timer runs late. It fails on travel past the long-press * tolerance, a lift, a second finger or the browser taking the touch. The * commit claims the finger and drops its click. A press that fails leaves - * the click alone + * the click alone. Each press it takes is reported once, at its landing * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts */ @@ -180,6 +180,17 @@ describe('ContextMenuPressRecognizer', () => { expect(states).toEqual(['failed@90', 'possible@90']); }); + it('reports the press it takes once, with its touch and the time it landed', () => { + const {harness, press} = setup(); + const began: [string, number][] = []; + press.onPressBegan = (touch, time) => { + began.push([touch.pointerType, time]); + }; + harness.finger(1).down(10, 20, 5, {recognizers: [press]}); + harness.finger(2).down(50, 0, 120, {recognizers: [press]}); + expect(began).toEqual([['touch', 5]]); + }); + it('keeps the press while it fails, so the feedback knows which one ended', () => { const {harness, press} = setup(); let pressAtFailure: unknown = 'unset'; diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index dfc25022eeb40..0829874683a51 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -76,6 +76,11 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { * moves freely from here. */ isMenuDue = false; + /** + * Hears each press as the recognizer takes it: the touch, and the time it + * landed on the arena's clock. + */ + onPressBegan: ((touch: GestureTouch, time: number) => void) | null = null; /** The finger the hold follows. */ private pressTouchId: number | null = null; /** Where the finger landed, while its hold runs. The drift counts from here. */ @@ -121,6 +126,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { this.holdFrom = {x: touch.x, y: touch.y}; this.holdStart = event.time; this.setTimer(event.time + commitMs, () => this.commit()); + this.onPressBegan?.(touch, event.time); } } diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx new file mode 100644 index 0000000000000..2a1879ca09f9c --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx @@ -0,0 +1,332 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file contextMenuPressFeedback.test.tsx + * @input useContextMenuPressFeedback on a trigger, with the animator replaced + * @output Checks that the growth starts at the press machine's "on" and + * keeps going when the machine lets the press go. It returns when + * ContextMenu's recognizer ends the press with no menu. The growth plays + * `springs.contextMenuLift`, and each return, then or as the menu closes, + * `springs.contextMenuDismiss` + * @position Unit tests of packages/core/src/ContextMenu/contextMenuPressFeedback.ts + */ + +import {render} from '@testing-library/react'; +import {useRef} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import {springs} from '../motion/uikit/springs'; +import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; +import { + useContextMenuPressFeedback, + type ContextMenuPressFeedback, +} from './contextMenuPressFeedback'; + +const animator = vi.hoisted(() => ({ + element: null as HTMLElement | null, + /** The element each spring ran on, in order. */ + animated: [] as HTMLElement[], + play: vi.fn((..._args: unknown[]) => ({ + finished: new Promise(() => {}), + })), + animate: vi.fn((..._args: unknown[]) => ({ + finished: new Promise(() => {}), + })), + stop: vi.fn(), + dispose: vi.fn(), + get: vi.fn(() => 1), + getVelocity: vi.fn(() => 0), + reducedMotion: vi.fn(() => 'off'), +})); + +vi.mock('../motion/animator', () => ({ + createMotionEngine: (element: HTMLElement) => { + animator.element = element; + return { + ...animator, + animate: (...args: unknown[]) => { + animator.animated.push(element); + return animator.animate(...args); + }, + }; + }, +})); + +let feedback: ContextMenuPressFeedback | null = null; + +function Trigger() { + const ref = useRef(null); + feedback = useContextMenuPressFeedback(ref, true); + return
; +} + +/** What the recognizer follows now, as ContextMenu's refs report it. */ +const recognizer = {isFollowing: true, isFinger: false}; +function TriggerWithRecognizer() { + const ref = useRef(null); + const isFollowedRef = useRef(() => recognizer.isFollowing); + const landedAtRef = useRef((): number | null => null); + const followsFingerRef = useRef( + () => recognizer.isFollowing && recognizer.isFinger, + ); + feedback = useContextMenuPressFeedback( + ref, + true, + isFollowedRef, + landedAtRef, + followsFingerRef, + ); + return
; +} + +/** A trigger whose feedback can go while the trigger stays, as when it is disabled. */ +function FeedbackLayer({ + triggerRef, +}: { + triggerRef: React.RefObject; +}) { + feedback = useContextMenuPressFeedback(triggerRef, true); + return null; +} + +function TriggerWithLayer({hasLayer}: {hasLayer: boolean}) { + const ref = useRef(null); + return ( + <> +
+ {hasLayer ? : null} + + ); +} + +/** A trigger whose content is a pressable control, as a Button is. */ +function TriggerWithControl() { + const ref = useRef(null); + feedback = useContextMenuPressFeedback(ref, true); + return ( +
+ +
+ ); +} + +const nested: { + outer: ContextMenuPressFeedback | null; + inner: ContextMenuPressFeedback | null; +} = {outer: null, inner: null}; + +/** A trigger inside another trigger, beside a plain control of the outer one. */ +function NestedTriggers({isInnerShown = true}: {isInnerShown?: boolean}) { + const outerRef = useRef(null); + const innerRef = useRef(null); + nested.outer = useContextMenuPressFeedback(outerRef, true); + nested.inner = useContextMenuPressFeedback(innerRef, isInnerShown); + return ( +
+ +
+
+ ); +} + +const growths = () => + animator.animate.mock.calls.filter( + ([to]) => ((to as {scale?: number}).scale ?? 1) > 1, + ); + +/** Lets the shared mutation observer deliver its records. */ +const flush = async () => + new Promise(resolve => { + setTimeout(resolve, 0); + }); + +describe('useContextMenuPressFeedback', () => { + afterEach(() => { + vi.clearAllMocks(); + animator.animated.length = 0; + feedback = null; + }); + + it('keeps growing when the press machine lets the press go, and springs back when the recognizer ends it', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + // The press machine's slop is shorter than the recognizer's tolerance. + trigger.removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + feedback?.end(performance.now()); + expect(animator.animate).toHaveBeenCalledTimes(2); + }); + + it('grows when the press machine marks a pressable control inside the trigger, the innermost surface it marks', async () => { + const {getByTestId} = render(); + getByTestId('control').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + getByTestId('control').removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + }); + + it('grows only the innermost trigger when one trigger sits inside another', async () => { + const {getByTestId} = render(); + getByTestId('inner').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([getByTestId('inner')]); + getByTestId('inner').removeAttribute(PRESSED_ATTRIBUTE); + await flush(); + getByTestId('outer-control').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([ + getByTestId('inner'), + getByTestId('outer'), + ]); + }); + + it('grows no outer trigger for a press on a nested trigger that shows no growth', async () => { + const {getByTestId} = render(); + getByTestId('inner').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animated).toEqual([]); + }); + + it('gives the trigger back its size when its feedback goes while the menu it opened is up', async () => { + const {getByTestId, rerender} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + expect(animator.animate).toHaveBeenCalledOnce(); + // The menu opened: the animator froze the growth on the element. + feedback?.hold(); + trigger.style.scale = '1.0735'; + rerender(); + expect(trigger.style.scale).toBe(''); + expect(animator.dispose).toHaveBeenCalledOnce(); + }); + + it('grows toward the held scale on springs.contextMenuLift, from the onset', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + const [to, transition, options] = animator.animate.mock + .lastCall as unknown as [ + {scale: number}, + {spring: unknown}, + {at: number}, + ]; + expect(to).toEqual({scale: contextMenu.press.heldScale}); + expect(transition).toEqual({spring: springs.contextMenuLift}); + expect(typeof options.at).toBe('number'); + expect(animator.play).not.toHaveBeenCalled(); + }); + + it('returns on springs.contextMenuDismiss, at the time the recognizer ended the press', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + feedback?.end(1000); + expect(animator.animate.mock.lastCall).toEqual([ + {scale: 1}, + {spring: springs.contextMenuDismiss}, + {at: 1000}, + ]); + expect(animator.play).not.toHaveBeenCalled(); + }); + + it('grows a finger at its onset, 150 ms after the landing the recognizer reports', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + const {getByTestId} = render(); + vi.advanceTimersByTime(1000); + // The recognizer reports the landing 40 ms late, on a busy main thread. + const landedAt = performance.now() - 40; + feedback?.begin(landedAt, getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(109); + expect(growths()).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ + landedAt + 150, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps a finger’s onset when the press machine marks the press later', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + recognizer.isFinger = true; + const {getByTestId} = render(); + vi.advanceTimersByTime(1000); + const landedAt = performance.now(); + feedback?.begin(landedAt, getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(240); + getByTestId('trigger').setAttribute(PRESSED_ATTRIBUTE, 'on'); + await vi.advanceTimersByTimeAsync(200); + expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ + landedAt + 150, + ]); + } finally { + recognizer.isFinger = false; + vi.useRealTimers(); + } + }); + + it('starts a pen’s growth at its mark after a finger’s press ended', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + recognizer.isFinger = true; + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + feedback?.begin(performance.now(), trigger); + await vi.advanceTimersByTimeAsync(150); + expect(growths()).toHaveLength(1); + feedback?.end(performance.now()); + // A pen's press: the press machine's mark starts its growth. + recognizer.isFinger = false; + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await vi.advanceTimersByTimeAsync(0); + expect(growths()).toHaveLength(2); + } finally { + recognizer.isFinger = false; + vi.useRealTimers(); + } + }); + + it('grows only the innermost trigger for a finger the recognizer reports on a nested trigger', async () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + const {getByTestId} = render(); + const inner = getByTestId('inner'); + nested.outer?.begin(performance.now(), inner); + nested.inner?.begin(performance.now(), inner); + await vi.advanceTimersByTimeAsync(150); + expect(animator.animated).toEqual([inner]); + } finally { + vi.useRealTimers(); + } + }); + + it('returns on springs.contextMenuDismiss when the menu the press opened closes', async () => { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); + await flush(); + feedback?.hold(); + feedback?.release(); + expect(animator.animate.mock.lastCall).toEqual([ + {scale: 1}, + {spring: springs.contextMenuDismiss}, + ]); + expect(animator.play).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts new file mode 100644 index 0000000000000..f9e808c1a6217 --- /dev/null +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -0,0 +1,317 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file contextMenuPressFeedback.ts + * @input UIKit's context-menu springs, the context-menu constants, the + * compositor animator, the presses ContextMenu's recognizer takes, and the + * press state core's press machine writes + * @output useContextMenuPressFeedback: hold and release, from ContextMenu's + * menu; the growth follows the recognizer's finger, or a pen's press mark + * @position The press feedback of ContextMenu's touch invocation + * + * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx + */ + +// The growth starts at the onset, 150 ms after a finger lands, while the +// recognizer follows that finger. The recognizer reports the landing; a +// pointer-only pen's press waits for the press machine's "on". Only the +// recognizer's failure or cancel returns it. A long press is a gesture, so the +// growth plays UIKit's lift spring and each return its dismissal spring. The +// compositor plays them, so a busy main thread does not stall them. Once the +// menu opens, the trigger holds its size until the menu closes. Then it returns +// on the dismissal spring. An inline trigger and Reduce Motion show no growth. + +import {useCallback, useRef, type RefObject} from 'react'; +import {useAttachment} from '../hooks/useAttachment'; +import {usePressFeedback} from '../hooks/usePressFeedback'; +import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; +import {PRESS_ONSET_MS} from '../utils/pressGesture'; +import {observeMutations} from '../utils/sharedMutationObserver'; +import {createMotionEngine, type Animator} from '../motion/animator'; +import {contextMenu} from '../motion/uikit/contextMenu'; +import { + CONTEXT_MENU_DISMISS_SPRING, + CONTEXT_MENU_LIFT_SPRING, +} from '../motion/uikit/springs'; + +const {heldScale} = contextMenu.press; + +export interface ContextMenuPressFeedback { + /** + * The recognizer took a finger that landed at `landedAt` on `target`: grow + * at its onset, 150 ms later. + */ + begin(landedAt: number, target: Element | null): void; + /** The menu the press opened closed: return on the dismissal spring. */ + release(): void; + /** + * The recognizer ended the press with no menu, at `time`: return on the + * dismissal spring. + */ + end(time: number): void; + /** + * The menu showed: the feedback stops where it is. The menu's cursor anchor + * sits inside the trigger, and anchor positioning follows its transformed + * box. So a trigger that still grows would carry the open menu with it. + */ + hold(): void; +} + +/** A press feedback with no recognizer to ask grows at every "on". */ +const ALWAYS_FOLLOWED: RefObject<() => boolean> = {current: () => true}; + +/** With no recognizer to ask, the onset is the "on" itself. */ +const NO_LANDING: RefObject<() => number | null> = {current: () => null}; + +/** With no recognizer to ask, every "on" is a pen's. */ +const NO_FINGER: RefObject<() => boolean> = {current: () => false}; + +// The triggers a press mark belongs to: every mounted trigger, whether it +// shows growth or not. A mark inside a trigger belongs to the nearest of them +// at or above it. So a press on a nested trigger grows no outer trigger. +const pressMarkOwners = new WeakSet(); + +function pressMarkOwnerOf(marked: Element): Element | null { + for ( + let node: Element | null = marked; + node != null; + node = node.parentElement + ) { + if (pressMarkOwners.has(node)) { + return node; + } + } + return null; +} + +/** + * The press feedback of a ContextMenu trigger. `isShown` is read at each + * press: false for an inline trigger. It follows the element behind + * `triggerRef`, so a replaced trigger (`triggerAs`) is followed too. The + * `isFollowed` ref says whether ContextMenu's recognizer follows a finger or + * a pen now: the growth starts only then. The `landedAt` ref gives the time + * that finger landed, so the growth starts at its onset. The `followsFinger` + * ref says whether that contact is a finger, whose onset `begin` sets. + */ +export function useContextMenuPressFeedback( + triggerRef: RefObject, + isShown: boolean, + isFollowed: RefObject<() => boolean> = ALWAYS_FOLLOWED, + landedAt: RefObject<() => number | null> = NO_LANDING, + followsFinger: RefObject<() => boolean> = NO_FINGER, +): ContextMenuPressFeedback { + // The press machine's document listener, while this feedback is mounted. + usePressFeedback(); + const animatorRef = useRef(null); + const isShownRef = useRef(isShown); + isShownRef.current = isShown; + // Whether the trigger carries a scale of ours (to put back and clear). + const isGrownRef = useRef(false); + // The menu the press opened is up: the growth holds until it closes. + const isHeldRef = useRef(false); + // Null while no shown trigger is attached, so `begin` grows nothing. + const fingerOnsetRef = useRef< + ((landedAt: number, target: Element | null) => void) | null + >(null); + + // The compositor animator lives with the trigger it follows. A replaced + // trigger, or a feedback that goes while the trigger stays, takes the + // animation and the scale of ours with it. + useAttachment( + triggerRef, + element => () => { + animatorRef.current?.dispose(); + animatorRef.current = null; + if (isGrownRef.current) { + isGrownRef.current = false; + element.style.removeProperty('scale'); + } + }, + null, + ); + + const animatorFor = useCallback((): Animator | null => { + const element = triggerRef.current; + if (element == null) { + return null; + } + animatorRef.current ??= createMotionEngine(element); + return animatorRef.current; + }, [triggerRef]); + + // Back at 1: drop the inline scale, which would otherwise leave the trigger + // a stacking context and a containing block for fixed descendants. + const settleBack = useCallback((finished: Promise) => { + void finished.then(isDone => { + const element = animatorRef.current?.element; + if (isDone && element != null && isGrownRef.current) { + isGrownRef.current = false; + element.style.removeProperty('scale'); + } + }); + }, []); + + const start = useCallback( + (time: number) => { + if (!isShownRef.current || isHeldRef.current || !isFollowed.current()) { + return; + } + const animator = animatorFor(); + const element = animator?.element; + // Under Reduce Motion the view does not grow (UIKit's + // `reducedMotionFeedback`), and no fade stands in for it. + if ( + animator == null || + element == null || + (animator.reducedMotion() !== 'off' && + !contextMenu.reducedMotionFeedback) + ) { + return; + } + isGrownRef.current = true; + // `time` is the onset: 150 ms after the finger lands, or a later mark. + animator.animate( + {scale: heldScale}, + {spring: CONTEXT_MENU_LIFT_SPRING}, + {at: time}, + ); + }, + [animatorFor, isFollowed], + ); + + const end = useCallback( + (time: number) => { + const animator = animatorRef.current; + const element = animator?.element; + if ( + animator == null || + element == null || + !isGrownRef.current || + isHeldRef.current + ) { + return; + } + settleBack( + animator.animate( + {scale: 1}, + {spring: CONTEXT_MENU_DISMISS_SPRING}, + {at: time}, + ).finished, + ); + }, + [settleBack], + ); + + const release = useCallback(() => { + isHeldRef.current = false; + const animator = animatorRef.current; + const element = animator?.element; + if (animator == null || element == null || !isGrownRef.current) { + return; + } + settleBack( + animator.animate({scale: 1}, {spring: CONTEXT_MENU_DISMISS_SPRING}) + .finished, + ); + }, [settleBack]); + + const begin = useCallback((landed: number, target: Element | null) => { + fingerOnsetRef.current?.(landed, target); + }, []); + + const hold = useCallback(() => { + isHeldRef.current = true; + if (isGrownRef.current) { + animatorRef.current?.stop(['scale']); + } + }, []); + + // Grow at the onset, 150 ms after the finger lands. The recognizer reports + // the landing as it takes the finger, whether or not the press machine + // marks the press. Inside a scroll view the machine marks late, and it never + // marks a press that travels past 10 px first. A pen's onset waits for the + // mark. The machine marks the innermost pressable surface, so a control + // inside the trigger can carry the mark. Only the recognizer ends the growth + // (`end`). + useAttachment( + triggerRef, + element => { + pressMarkOwners.add(element); + if (!isShown) { + return () => { + pressMarkOwners.delete(element); + }; + } + const isMarkedOn = () => + element.getAttribute(PRESSED_ATTRIBUTE) === 'on' || + [...element.querySelectorAll(`[${PRESSED_ATTRIBUTE}="on"]`)].some( + marked => pressMarkOwnerOf(marked) === element, + ); + let isPressed = isMarkedOn(); + let markedAt = 0; + let onsetTimer: ReturnType | undefined; + const startAt = (onsetAt: number) => { + const wait = onsetAt - performance.now(); + if (wait > 0) { + onsetTimer = setTimeout(() => start(onsetAt), wait); + } else { + start(onsetAt); + } + }; + // A pen's mark: the gesture host took that pen from the same input + // before the mark's record comes, so the recognizer follows it already. + const growAtMark = () => { + // The recognizer times a finger's onset from its landing. + if (followsFinger.current() || !isFollowed.current()) { + return; + } + const landed = landedAt.current(); + startAt( + landed == null + ? markedAt + : Math.max(markedAt, landed + PRESS_ONSET_MS), + ); + }; + fingerOnsetRef.current = (landed, target) => { + // A finger on a nested trigger grows only that trigger. + if (target != null && pressMarkOwnerOf(target) !== element) { + return; + } + clearTimeout(onsetTimer); + startAt(landed + PRESS_ONSET_MS); + }; + const stopObserving = observeMutations( + element, + {attributes: true, attributeFilter: [PRESSED_ATTRIBUTE], subtree: true}, + () => { + const next = isMarkedOn(); + if (next !== isPressed) { + isPressed = next; + if (next) { + if (!followsFinger.current()) { + clearTimeout(onsetTimer); + markedAt = performance.now(); + growAtMark(); + } + } else if (!isFollowed.current()) { + // A finger the recognizer still follows keeps its onset, past + // the machine's shorter travel. + clearTimeout(onsetTimer); + } + } + }, + ); + return () => { + clearTimeout(onsetTimer); + stopObserving(); + fingerOnsetRef.current = null; + pressMarkOwners.delete(element); + }; + }, + isShown, + ); + + return {begin, release, end, hold}; +} diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index c739deaa6c701..a5c31c92649d6 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -18,7 +18,7 @@ roles through `resolveMotion`. | `uikit/springs.ts` | Interaction springs (navigation, sheets, alerts, menus, controls, keyboard, banners) and the SwiftUI / UIKit presets. | | `uikit/scroll.ts` | UIScrollView: deceleration rates, rubber band, bounce-back spring, `delaysContentTouches`, scroll to top. | | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | -| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, commit haptic. | +| `uikit/contextMenu.ts` | UIContextMenuInteraction: touch timeline (feedback, commit, menu), drift tolerance, the press feedback's held scale, commit haptic. | | `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/alert.ts` | UIAlertController: how a finger is tracked across the actions (highlight onset and bounds). | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts index a6c168b433270..7d7743e20b664 100644 --- a/packages/core/src/motion/uikit/contextMenu.ts +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -4,12 +4,13 @@ * @file contextMenu.ts * @input Measurements of UIContextMenuInteraction on iOS 26.2 * @output `contextMenu`: UIContextMenuInteraction's touch timeline, drift - * tolerance and commit haptic + * tolerance, held scale and commit haptic * @position The context-menu domain of the UIKit constants table. Only * ContextMenu's own modules import it. The table's barrel does not * re-export it. * - * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md and + * SYNC: When modified, update /packages/core/src/motion/MEASUREMENTS.md, + * /packages/core/src/ContextMenu/contextMenuPressFeedback.ts and * /packages/core/src/ContextMenu/contextMenuPress.ts */ @@ -76,6 +77,18 @@ export const contextMenu = { * 0.5 s displayed at the lift (529 ms) */ menuDisplayMs: 735, + /** + * The size the press copy reaches when the menu displays, as a fraction + * of the view. It is the second ramp at `menuDisplayMs`: 1 + 0.1 per + * second since the touch. The trigger grows toward it on + * `springs.contextMenuLift`, not on UIKit's ramp. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, per-frame model frames of the press platter over a + * 200 × 120 pt view, Oct 23 2026: 0.1001 per second, linear over 19 + * frames, zero at the touch (−1.4 ms), at 735 ms + * @measure M-CM2 + */ + heldScale: 1.0735, }, /** * The click haptic at the commit, as a vibration where that is the only @@ -91,4 +104,14 @@ export const contextMenu = { * @measure M-CM5 */ commitHaptic: 'impactMedium', + /** + * Reduce Motion: whether the press feedback still grows the view. It does + * not; only the copy's shadow fades in, and the haptic and the menu stay. + * @source measured: UIKit lab (injected host app), iOS 26.2 simulator, + * iPhone 17 Pro, per-frame platter frames with Reduce Motion on, Prefer + * Cross-Fade off and on, Oct 23 2026: the platter stays at scale 1 and + * opacity 1 through the hold while its shadow opacity rises 0 → 0.31 by + * 0.6 s; a press lifted at 0.25 s does not grow either + */ + reducedMotionFeedback: false, } as const; diff --git a/packages/core/src/utils/pressableCoverage.test.ts b/packages/core/src/utils/pressableCoverage.test.ts index c4b2c88b0c5ba..d610665016df5 100644 --- a/packages/core/src/utils/pressableCoverage.test.ts +++ b/packages/core/src/utils/pressableCoverage.test.ts @@ -15,7 +15,10 @@ * holds it equal to the marked set: * * - A file paints a press when its StyleX carries an `':active` key, a - * `[data-astryx-press` arm, or composes `interactionOverlayStyles`. + * `[data-astryx-press` arm, or composes `interactionOverlayStyles`. It + * also paints one when its script follows the pressed state that a + * controller writes, as ContextMenu's growth does. A script that also + * writes that state is a press controller, so it is part of the model. * - A file is marked when it calls `usePressFeedback(`. * - A file whose press arms are read off an ancestor scope marker * (`stylex.when.ancestor(':active', someScope)`, the way an indicator @@ -71,6 +74,13 @@ const source = new Map( // a bare attribute selector or inside `:is(…)`. const PRESS_ARM = /':active|'(?::is\()?\[data-astryx-press=|interactionOverlayStyles\./; +function paintsPress(text: string): boolean { + return ( + PRESS_ARM.test(text) || + (text.includes('getAttribute(PRESSED_ATTRIBUTE)') && + !text.includes('setAttribute(PRESSED_ATTRIBUTE')) + ); +} const ANCESTOR_ARM = /stylex\.when\.ancestor\(\s*'(?::active|\[data-astryx-press=[^)]*)',\s*(\w+)\s*,?\s*\)/g; @@ -143,7 +153,7 @@ function importers(file: string): string[] { } const pressArmFiles = [...source.entries()] - .filter(([file, text]) => !MODEL_FILES.has(file) && PRESS_ARM.test(text)) + .filter(([file, text]) => !MODEL_FILES.has(file) && paintsPress(text)) .map(([file]) => file) .sort(); @@ -227,7 +237,7 @@ describe('every surface that paints a press is reachable by the touch press cont ); const unexplained = marked.filter(file => { const text = source.get(file) ?? ''; - if (PRESS_ARM.test(text)) { + if (paintsPress(text)) { return false; } return ![...scopeNames].some(scope => From 7f9d428f31937808390de8659a7202f9d2d15ac6 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 09/50] test(ContextMenu): each animation hold names the motion it judges main's hold now rejects a hold that names no judged motion. The curve and storyboard proofs judge the trigger's scale, so they say so. --- .../ContextMenuLongPress.interaction.browser.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index b83c187617373..f237674bbeed4 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -682,7 +682,9 @@ test('the press feedback plays its computed curve on the compositor, frame by fr const played = playedCurve(); const playing = driver.play(hold(center, commitMs + 300)); await waitForAnimation(trigger, {minProgressMs: 16}); - await holdAnimations(page, trigger); + await holdAnimations(page, trigger, { + judges: {target: trigger, property: 'scale'}, + }); // Seek the held curve (its time 0 is the onset after the touch) to every // 60 Hz frame and read the presented scale. const samples = await trigger.evaluate((element, times) => { @@ -976,6 +978,7 @@ test( height: box.height + 48, }, scope: trigger, + judges: {target: trigger, property: 'scale'}, // t counts from the visible ramp's start, the onset after the touch. times: timeline(menuDisplayMs - feedbackOnsetMs, 1000 / 30), measure: async () => ({ From 791357ddfaaf6364f63e96da0b05a59e8dc88157 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 10/50] test(ContextMenu): a late touch grows from rest at its onset (red) A touch that reaches the page 90 ms after its timestamp waits for Chromium's timer hold, so the growth starts about 50 ms late, and its back-dated curve skips its first frames. The proof wants the frame before the growth at rest, the first grown frame no further along than the spring one frame in, and that frame within two frames of the onset. --- ...tMenuLongPress.interaction.browser.spec.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index f237674bbeed4..87bbbbb8c2c2f 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -675,6 +675,95 @@ test('a drift within the tolerance still opens the menu; past it on one axis can expect(await menuShownAt(page)).toBeNull(); }); +test('a touch that reaches the page late grows from the first frame at its onset, from rest', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + test.skip( + !driver.scriptedTimestamps, + 'only a Chromium touch takes its timestamp from the script', + ); + const played = playedCurve(); + await page.evaluate(() => { + const trigger = document.querySelector( + '[data-testid="touch-context-menu"]', + ); + const record = { + touchedAt: 0, + handledAt: 0, + frames: [] as {t: number; scale: number}[], + }; + (window as unknown as {__lateTouch: typeof record}).__lateTouch = record; + const sample = (t: number) => { + const scale = getComputedStyle(trigger as HTMLElement).scale; + record.frames.push({ + t, + scale: scale === 'none' ? 1 : Number.parseFloat(scale), + }); + if (t - record.touchedAt < 320) { + requestAnimationFrame(sample); + } + }; + addEventListener( + 'touchstart', + event => { + record.touchedAt = event.timeStamp; + record.handledAt = performance.now(); + // Added during the dispatch, it runs after the page's own touchstart + // listeners. So each frame, the sampler reads after the page's work. + addEventListener('touchstart', () => requestAnimationFrame(sample), { + once: true, + passive: true, + }); + }, + {capture: true, once: true, passive: true}, + ); + }); + const cdp = await page.context().newCDPSession(page); + // The touch reaches the page 90 ms after its own timestamp, as through a + // slow input path. Chromium then holds the page's timers until about + // 100 ms after it handles the touch. + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{...center, id: 1, radiusX: 1, radiusY: 1, force: 1}], + timestamp: (Date.now() - 90) / 1000, + }); + await page.waitForTimeout(250); + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + timestamp: Date.now() / 1000, + }); + const {touchedAt, handledAt, frames} = await page.evaluate( + () => + ( + window as unknown as { + __lateTouch: { + touchedAt: number; + handledAt: number; + frames: {t: number; scale: number}[]; + }; + } + ).__lateTouch, + ); + expect(handledAt - touchedAt).toBeGreaterThanOrEqual(80); + const grown = frames.findIndex(frame => frame.scale > 1); + expect(grown, 'the trigger never grew').toBeGreaterThan(0); + const before = frames[grown - 1]; + const first = frames[grown]; + const frameMs = first.t - before.t; + // From rest: the frame before is at rest, and the first grown frame is no + // further along than the spring one frame in. + expect(before.scale).toBe(1); + expect(first.scale).toBeLessThanOrEqual(played.at(frameMs / 1000) + 0.0005); + // On time: the curve starts in the first frame at or after the onset. So + // its first grown frame comes within two frames of the onset. + expect(before.t - touchedAt).toBeGreaterThanOrEqual(feedbackOnsetMs); + expect(first.t - touchedAt).toBeLessThanOrEqual( + feedbackOnsetMs + 2 * frameMs + 1, + ); +}); + test('the press feedback plays its computed curve on the compositor, frame by frame', async ({ page, }, testInfo) => { From 6fd83b267732257cde29f694ff54ee3ab8aec0fc Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 11/50] fix(ContextMenu): a frame, not a timer, starts the touch growth Chromium holds every timer for about 100 ms after it handles a touchstart, so a touch that reached the page late grew late, and its back-dated curve skipped its first frames. The first frame at or after the onset now starts the spring from rest. The motion engine is built in the first frame after the finger lands, not in its touchstart handler. A lift, a move past the tolerance, a cancel and the menu's display stop the frame watch. --- .../ContextMenuTouchGrowth.test.tsx | 108 +++++++++++++++--- .../contextMenuPressFeedback.test.tsx | 91 ++++++++++++--- .../ContextMenu/contextMenuPressFeedback.ts | 82 +++++++++---- 3 files changed, 231 insertions(+), 50 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx index e6aea9a540b25..307a5479f6ab3 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx @@ -55,7 +55,7 @@ interface Contact { } function dispatchTouch( - type: 'touchstart' | 'touchmove' | 'touchend', + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', target: EventTarget, changed: Contact[], all: Contact[], @@ -78,6 +78,10 @@ const advance = (ms: number) => vi.advanceTimersByTime(ms); }); +// Fake animation frames come every 16 ms. The growth starts in the first one +// at or after its onset. +const FRAME_MS = 16; + /** The press machine's "on", as core's press machine writes it. */ async function pressMachineOn(trigger: HTMLElement) { trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); @@ -148,7 +152,7 @@ describe('ContextMenu’s growth under a finger', () => { const trigger = renderMenu(); const contact = {id: 1, x: 20, y: 20}; dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); + advance(150 + FRAME_MS); await pressMachineOn(trigger); expect(growths(trigger)).toHaveLength(1); advance(100); @@ -162,6 +166,16 @@ describe('ContextMenu’s growth under a finger', () => { element === trigger && ((to as {scale?: number}).scale ?? 1) > 1, ); const startOf = (call: unknown[]) => (call[3] as {at: number}).at; + // One growth, started from rest in the first frame at or after the onset. + const expectGrowthFromOnsetFrame = ( + trigger: HTMLElement, + landedAt: number, + ) => { + const starts = growths(trigger).map(startOf); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(landedAt + 150); + expect(starts[0]).toBeLessThan(landedAt + 150 + FRAME_MS); + }; it('starts the growth at the onset, 150 ms after the finger lands, when the press machine marks the press as the finger lands', async () => { const trigger = renderMenu(); @@ -172,8 +186,8 @@ describe('ContextMenu’s growth under a finger', () => { await pressMachineOn(trigger); advance(149); expect(growths(trigger)).toHaveLength(0); - advance(1); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(1 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows the trigger at the onset when the press machine marks the press before the recognizer has the finger', async () => { @@ -184,8 +198,8 @@ describe('ContextMenu’s growth under a finger', () => { await pressMachineOn(trigger); const landedAt = performance.now(); dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(150 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('still grows at the onset after a drift past the press machine’s 10 px that the recognizer’s 11.5 px tolerance still follows', async () => { @@ -199,8 +213,8 @@ describe('ContextMenu’s growth under a finger', () => { const drifted = {...contact, x: 31}; dispatchTouch('touchmove', trigger, [drifted], [drifted]); await pressMachineOff(trigger); - advance(90); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(90 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows at the onset after a drift the press machine never marks, as inside a scroll view, that the recognizer still follows', async () => { @@ -213,8 +227,8 @@ describe('ContextMenu’s growth under a finger', () => { // marks the press, and inside the recognizer's 11.5 px. const drifted = {...contact, x: 28, y: 28}; dispatchTouch('touchmove', trigger, [drifted], [drifted]); - advance(90); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(90 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); }); it('grows at the onset, 150 ms after the finger lands, even when the press machine marks the press later', async () => { @@ -222,8 +236,8 @@ describe('ContextMenu’s growth under a finger', () => { const contact = {id: 1, x: 20, y: 20}; const landedAt = performance.now(); dispatchTouch('touchstart', trigger, [contact], [contact]); - advance(150); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(150 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); // Inside a scroll view the machine's own timer marks it later. advance(90); await pressMachineOn(trigger); @@ -250,10 +264,76 @@ describe('ContextMenu’s growth under a finger', () => { expect(trigger.getAttribute(PRESSED_ATTRIBUTE)).toBe('on'); advance(149); expect(growths(trigger)).toHaveLength(0); - advance(1); - expect(growths(trigger).map(startOf)).toEqual([landedAt + 150]); + advance(1 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); + }); + + it('builds the motion engine in the first frame after the finger lands, not in its touchstart handler', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + expect(animator.elements).not.toContain(trigger); + advance(FRAME_MS); + expect(animator.elements).toContain(trigger); + expect(growths(trigger)).toHaveLength(0); }); + it('shows nothing for a quick tap: no animation and no inline style', async () => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + const style = trigger.getAttribute('style'); + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(40); + dispatchTouch('touchend', trigger, [contact], []); + advance(400); + expect(animator.animate).not.toHaveBeenCalled(); + expect(animator.play).not.toHaveBeenCalled(); + expect(trigger.getAttribute('style')).toBe(style); + }); + + it.each([ + [ + 'the finger lifts', + (trigger: HTMLElement, contact: Contact) => + dispatchTouch('touchend', trigger, [contact], []), + ], + [ + 'the finger moves past the hold’s tolerance', + (trigger: HTMLElement, contact: Contact) => { + const moved = {...contact, x: contact.x + 20}; + dispatchTouch('touchmove', trigger, [moved], [moved]); + }, + ], + [ + 'the browser cancels the touch', + (trigger: HTMLElement, contact: Contact) => + dispatchTouch('touchcancel', trigger, [contact], [contact]), + ], + ])( + 'stops watching frames for the onset when %s before it', + async (_, stop) => { + const trigger = renderMenu(); + const contact = {id: 1, x: 20, y: 20}; + dispatchTouch('touchstart', trigger, [contact], [contact]); + advance(60); + const frames = vi.spyOn(window, 'requestAnimationFrame'); + const cancels = vi.spyOn(window, 'cancelAnimationFrame'); + try { + stop(trigger, contact); + expect(cancels).toHaveBeenCalled(); + advance(200); + // The onset's watch asks for no frame after it stops. + expect( + frames.mock.calls.filter(([callback]) => callback.name === 'look'), + ).toEqual([]); + expect(growths(trigger)).toHaveLength(0); + } finally { + frames.mockRestore(); + cancels.mockRestore(); + } + }, + ); + it('does not grow the trigger for a press its recognizer does not follow, such as a mouse', async () => { const trigger = renderMenu(); await pressMachineOn(trigger); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx index 2a1879ca09f9c..10f4830f570e2 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.test.tsx @@ -139,15 +139,39 @@ const growths = () => ); /** Lets the shared mutation observer deliver its records. */ +// The growth starts in the next animation frame. const flush = async () => new Promise(resolve => { - setTimeout(resolve, 0); + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); }); +// Fake animation frames come every 16 ms. +const FRAME_MS = 16; +const FAKE_TIMERS = { + toFake: [ + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'performance', + ] as const, +}; +const startsOf = () => growths().map(call => (call[2] as {at: number}).at); +// One growth, started from rest in the first frame at or after the onset. +const expectGrowthFromOnsetFrame = (onsetAt: number) => { + const starts = startsOf(); + expect(starts).toHaveLength(1); + expect(starts[0]).toBeGreaterThanOrEqual(onsetAt); + expect(starts[0]).toBeLessThan(onsetAt + FRAME_MS); +}; + describe('useContextMenuPressFeedback', () => { afterEach(() => { vi.clearAllMocks(); animator.animated.length = 0; + animator.element = null; feedback = null; }); @@ -243,7 +267,7 @@ describe('useContextMenuPressFeedback', () => { }); it('grows a finger at its onset, 150 ms after the landing the recognizer reports', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { const {getByTestId} = render(); vi.advanceTimersByTime(1000); @@ -252,17 +276,15 @@ describe('useContextMenuPressFeedback', () => { feedback?.begin(landedAt, getByTestId('trigger')); await vi.advanceTimersByTimeAsync(109); expect(growths()).toHaveLength(0); - await vi.advanceTimersByTimeAsync(1); - expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ - landedAt + 150, - ]); + await vi.advanceTimersByTimeAsync(1 + FRAME_MS); + expectGrowthFromOnsetFrame(landedAt + 150); } finally { vi.useRealTimers(); } }); it('keeps a finger’s onset when the press machine marks the press later', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { recognizer.isFinger = true; const {getByTestId} = render(); @@ -272,9 +294,7 @@ describe('useContextMenuPressFeedback', () => { await vi.advanceTimersByTimeAsync(240); getByTestId('trigger').setAttribute(PRESSED_ATTRIBUTE, 'on'); await vi.advanceTimersByTimeAsync(200); - expect(growths().map(call => (call[2] as {at: number}).at)).toEqual([ - landedAt + 150, - ]); + expectGrowthFromOnsetFrame(landedAt + 150); } finally { recognizer.isFinger = false; vi.useRealTimers(); @@ -282,19 +302,19 @@ describe('useContextMenuPressFeedback', () => { }); it('starts a pen’s growth at its mark after a finger’s press ended', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { recognizer.isFinger = true; const {getByTestId} = render(); const trigger = getByTestId('trigger'); feedback?.begin(performance.now(), trigger); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(150 + FRAME_MS); expect(growths()).toHaveLength(1); feedback?.end(performance.now()); // A pen's press: the press machine's mark starts its growth. recognizer.isFinger = false; trigger.setAttribute(PRESSED_ATTRIBUTE, 'on'); - await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(FRAME_MS); expect(growths()).toHaveLength(2); } finally { recognizer.isFinger = false; @@ -303,19 +323,60 @@ describe('useContextMenuPressFeedback', () => { }); it('grows only the innermost trigger for a finger the recognizer reports on a nested trigger', async () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); try { const {getByTestId} = render(); const inner = getByTestId('inner'); nested.outer?.begin(performance.now(), inner); nested.inner?.begin(performance.now(), inner); - await vi.advanceTimersByTimeAsync(150); + await vi.advanceTimersByTimeAsync(150 + FRAME_MS); expect(animator.animated).toEqual([inner]); } finally { vi.useRealTimers(); } }); + it('builds the motion engine in the first frame after the landing, before the onset', async () => { + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); + try { + const {getByTestId} = render(); + const trigger = getByTestId('trigger'); + feedback?.begin(performance.now(), trigger); + expect(animator.element).toBeNull(); + await vi.advanceTimersByTimeAsync(FRAME_MS); + expect(animator.element).toBe(trigger); + expect(growths()).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['the recognizer ends the press', () => feedback?.end(performance.now())], + ['the menu shows', () => feedback?.hold()], + ])( + 'stops watching frames for the onset when %s before it', + async (_, stop) => { + vi.useFakeTimers({toFake: [...FAKE_TIMERS.toFake]}); + try { + const {getByTestId} = render(); + feedback?.begin(performance.now(), getByTestId('trigger')); + await vi.advanceTimersByTimeAsync(60); + const frames = vi.spyOn(window, 'requestAnimationFrame'); + try { + stop(); + await vi.advanceTimersByTimeAsync(200); + expect(frames).not.toHaveBeenCalled(); + expect(growths()).toHaveLength(0); + } finally { + frames.mockRestore(); + } + } finally { + vi.useRealTimers(); + } + }, + ); + it('returns on springs.contextMenuDismiss when the menu the press opened closes', async () => { const {getByTestId} = render(); const trigger = getByTestId('trigger'); diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts index f9e808c1a6217..9e591e59b10a5 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -14,10 +14,12 @@ * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx */ -// The growth starts at the onset, 150 ms after a finger lands, while the -// recognizer follows that finger. The recognizer reports the landing; a -// pointer-only pen's press waits for the press machine's "on". Only the -// recognizer's failure or cancel returns it. A long press is a gesture, so the +// The growth starts in the first frame at or after the onset, 150 ms after a +// finger lands, while the recognizer follows that finger. It plays its whole +// spring from rest, from that frame, as Core Animation starts an animation at +// its commit. The recognizer reports the landing; a pointer-only pen's press +// waits for the press machine's "on". Only the recognizer's failure or cancel +// returns it. A long press is a gesture, so the // growth plays UIKit's lift spring and each return its dismissal spring. The // compositor plays them, so a busy main thread does not stall them. Once the // menu opens, the trigger holds its size until the menu closes. Then it returns @@ -41,14 +43,14 @@ const {heldScale} = contextMenu.press; export interface ContextMenuPressFeedback { /** * The recognizer took a finger that landed at `landedAt` on `target`: grow - * at its onset, 150 ms later. + * from the first frame at or after its onset, 150 ms later. */ begin(landedAt: number, target: Element | null): void; /** The menu the press opened closed: return on the dismissal spring. */ release(): void; /** * The recognizer ended the press with no menu, at `time`: return on the - * dismissal spring. + * dismissal spring. A growth still to come does not start. */ end(time: number): void; /** @@ -73,6 +75,32 @@ const NO_FINGER: RefObject<() => boolean> = {current: () => false}; // at or above it. So a press on a nested trigger grows no outer trigger. const pressMarkOwners = new WeakSet(); +/** + * Calls `onDue` in the first animation frame that runs at or after `dueAt`, on + * the event clock, with the time its callback runs. `onWait` runs in each frame + * before it. It reads only the clock. A frame comes on time where a timer does + * not: Chromium holds the page's timers for about 100 ms after it handles a + * touchstart. Returns the function that stops it. + */ +function watchFramesFrom( + dueAt: number, + onWait: () => void, + onDue: (time: number) => void, +): () => void { + let frame = requestAnimationFrame(function look() { + const time = performance.now(); + if (time >= dueAt) { + onDue(time); + return; + } + onWait(); + frame = requestAnimationFrame(look); + }); + return () => { + cancelAnimationFrame(frame); + }; +} + function pressMarkOwnerOf(marked: Element): Element | null { for ( let node: Element | null = marked; @@ -115,6 +143,12 @@ export function useContextMenuPressFeedback( const fingerOnsetRef = useRef< ((landedAt: number, target: Element | null) => void) | null >(null); + // Stops the frame watch of a growth still to come. + const stopOnsetRef = useRef<(() => void) | null>(null); + const stopOnset = useCallback(() => { + stopOnsetRef.current?.(); + stopOnsetRef.current = null; + }, []); // The compositor animator lives with the trigger it follows. A replaced // trigger, or a feedback that goes while the trigger stays, takes the @@ -171,7 +205,8 @@ export function useContextMenuPressFeedback( return; } isGrownRef.current = true; - // `time` is the onset: 150 ms after the finger lands, or a later mark. + // `time` is when the first frame at or after the onset runs. The curve + // starts there from rest, so no frame skips into it. animator.animate( {scale: heldScale}, {spring: CONTEXT_MENU_LIFT_SPRING}, @@ -183,6 +218,7 @@ export function useContextMenuPressFeedback( const end = useCallback( (time: number) => { + stopOnset(); const animator = animatorRef.current; const element = animator?.element; if ( @@ -201,7 +237,7 @@ export function useContextMenuPressFeedback( ).finished, ); }, - [settleBack], + [settleBack, stopOnset], ); const release = useCallback(() => { @@ -222,13 +258,14 @@ export function useContextMenuPressFeedback( }, []); const hold = useCallback(() => { + stopOnset(); isHeldRef.current = true; if (isGrownRef.current) { animatorRef.current?.stop(['scale']); } - }, []); + }, [stopOnset]); - // Grow at the onset, 150 ms after the finger lands. The recognizer reports + // Grow from the onset, 150 ms after the finger lands. The recognizer reports // the landing as it takes the finger, whether or not the press machine // marks the press. Inside a scroll view the machine marks late, and it never // marks a press that travels past 10 px first. A pen's onset waits for the @@ -251,14 +288,18 @@ export function useContextMenuPressFeedback( ); let isPressed = isMarkedOn(); let markedAt = 0; - let onsetTimer: ReturnType | undefined; const startAt = (onsetAt: number) => { - const wait = onsetAt - performance.now(); - if (wait > 0) { - onsetTimer = setTimeout(() => start(onsetAt), wait); - } else { - start(onsetAt); - } + stopOnset(); + stopOnsetRef.current = watchFramesFrom( + onsetAt, + // The engine is built in a frame, not in the handler of the touch, + // so the frame that starts the growth only creates its animation. + animatorFor, + time => { + stopOnsetRef.current = null; + start(time); + }, + ); }; // A pen's mark: the gesture host took that pen from the same input // before the mark's record comes, so the recognizer follows it already. @@ -279,7 +320,6 @@ export function useContextMenuPressFeedback( if (target != null && pressMarkOwnerOf(target) !== element) { return; } - clearTimeout(onsetTimer); startAt(landed + PRESS_ONSET_MS); }; const stopObserving = observeMutations( @@ -291,20 +331,20 @@ export function useContextMenuPressFeedback( isPressed = next; if (next) { if (!followsFinger.current()) { - clearTimeout(onsetTimer); + stopOnset(); markedAt = performance.now(); growAtMark(); } } else if (!isFollowed.current()) { // A finger the recognizer still follows keeps its onset, past // the machine's shorter travel. - clearTimeout(onsetTimer); + stopOnset(); } } }, ); return () => { - clearTimeout(onsetTimer); + stopOnset(); stopObserving(); fingerOnsetRef.current = null; pressMarkOwners.delete(element); From d44956d08bd3c696d39fe4e0ef720513e34d930a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 12/50] test(ContextMenu): the lift shows the menu inside its own dispatch The proof read "the menu shows in the lift's task" as "within one frame of the lift". Merged with main, the menu's render in that task takes 17 ms in the WebKit CI container, so the time proxy failed while the menu still showed before any frame. A page flag now records whether the menu shows while the lift's listeners run. --- ...tMenuLongPress.interaction.browser.spec.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 87bbbbb8c2c2f..f4ee001b28a2b 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -90,11 +90,14 @@ async function openTouchStory( const w = window as unknown as { __menuShownAt?: number | null; __liftHandledAt?: number | null; + __menuShownInLift?: boolean | null; __menuShownTimerAt?: number | null; }; w.__menuShownAt = null; w.__liftHandledAt = null; + w.__menuShownInLift = null; w.__menuShownTimerAt = null; + let isInLift = false; // When the timer task that runs now started. The hold's moments come from // the gesture arena's timers. The menu commits in the task that decides // it, so its show runs inside that timer task. @@ -120,12 +123,22 @@ async function openTouchStory( timeout, ...args, )) as typeof window.setTimeout; - // When the page gets to the lift (the event's own timestamp is the - // hardware's, or the script's): the menu shows in the same task. + // When the page gets to the lift. The event's own timestamp is the + // hardware's, or the script's. Also whether the menu shows while the + // lift's listeners run: then no frame paints between the two. addEventListener( 'touchend', () => { w.__liftHandledAt = performance.now(); + isInLift = true; + // Added during the dispatch, it runs after the page's own listeners. + addEventListener( + 'touchend', + () => { + isInLift = false; + }, + {once: true, passive: true}, + ); }, {capture: true, passive: true}, ); @@ -142,6 +155,7 @@ async function openTouchStory( // Only the first show counts, so a later one cannot stand in for it. if (w.__menuShownAt == null) { w.__menuShownAt = performance.now(); + w.__menuShownInLift = isInLift; w.__menuShownTimerAt = timerAt; } } @@ -501,18 +515,24 @@ test('a finger lifted after the commit gets the menu at the lift, and sends no c await driver.play(hold(center, 530)); await page.waitForTimeout(300); await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); - const {lift, shown} = await page.evaluate(() => { + const {lift, shown, isInLift} = await page.evaluate(() => { const w = window as unknown as { __menuShownAt?: number | null; __liftHandledAt?: number | null; + __menuShownInLift?: boolean | null; + }; + return { + lift: w.__liftHandledAt ?? null, + shown: w.__menuShownAt ?? null, + isInLift: w.__menuShownInLift ?? null, }; - return {lift: w.__liftHandledAt ?? null, shown: w.__menuShownAt ?? null}; }); expect(lift).not.toBeNull(); expect(shown).not.toBeNull(); - // In the lift's own task: after it, and before the next frame could paint. + // In the lift's own dispatch: after it, and before the next frame could + // paint, however long the menu's render takes in that task. expect((shown as number) - (lift as number)).toBeGreaterThanOrEqual(0); - expect((shown as number) - (lift as number)).toBeLessThan(1000 / 60); + expect(isInLift).toBe(true); const log = await readInputLog(page); const end = log.filter(record => record.type === 'touchend').at(-1); expect(end?.defaultPrevented, JSON.stringify(log)).toBe(true); From 409e581997fa24733af5e3bbe8a178992bd3f5ce Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 13/50] test(ContextMenu): the menu loads the animator at once for its growth Merged with main, the lazy-sheet bundle proof pins that a menu with an on-demand sheet keeps the animator on demand. ContextMenu's touch growth plays on the animator from the first touch, so the animator loads with the menu. The proof now expects that for ContextMenu, and `rideKeyboard` still loads only with the sheet. --- scripts/lazy-sheet-bundle.test.mjs | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/scripts/lazy-sheet-bundle.test.mjs b/scripts/lazy-sheet-bundle.test.mjs index d0f74abb83cb5..807fb86c36323 100644 --- a/scripts/lazy-sheet-bundle.test.mjs +++ b/scripts/lazy-sheet-bundle.test.mjs @@ -7,8 +7,9 @@ * @output Pins that a menu's static import keeps its on-demand bottom sheet's * keyboard code on demand. The sheet's keyboard ride (the animator and * `rideKeyboard`) stays in the chunks the import loads only when the sheet - * opens. This holds though the layer runtime's keyboard cap imports the - * keyboard module statically. + * opens. A menu that uses the animator at once loads it with the menu. This + * holds though the layer runtime's keyboard cap imports the keyboard module + * statically. * @position Node test over source, so it needs no build */ @@ -110,20 +111,28 @@ function rideIn(bytes) { describe('a menu with an on-demand bottom sheet', () => { it.each([ - ['DropdownMenu', 'packages/core/src/DropdownMenu/index.ts'], - ['ContextMenu', 'packages/core/src/ContextMenu/index.ts'], - ['Selector', 'packages/core/src/Selector/index.ts'], - ['MultiSelector', 'packages/core/src/MultiSelector/index.ts'], + ['DropdownMenu', 'packages/core/src/DropdownMenu/index.ts', []], + // ContextMenu's touch growth plays on the animator, so the animator loads + // with the menu. The sheet's own ride is then `rideKeyboard` alone. + [ + 'ContextMenu', + 'packages/core/src/ContextMenu/index.ts', + ['packages/core/src/motion/animator.ts'], + ], + ['Selector', 'packages/core/src/Selector/index.ts', []], + ['MultiSelector', 'packages/core/src/MultiSelector/index.ts', []], ])( "%s loads the sheet's keyboard ride only with the sheet", - (name, entry) => { + (name, entry, eagerRide) => { const {eager, onDemand} = importOf(entry, name); // The sheet still rides the keyboard, on demand. - expect(rideIn(onDemand)).toEqual([ - 'packages/core/src/keyboard/rideKeyboard.ts', - 'packages/core/src/motion/animator.ts', - ]); - expect(rideIn(eager)).toEqual([]); + expect(rideIn(onDemand)).toEqual( + [ + 'packages/core/src/keyboard/rideKeyboard.ts', + 'packages/core/src/motion/animator.ts', + ].filter(input => !eagerRide.includes(input)), + ); + expect(rideIn(eager)).toEqual(eagerRide); }, ); }); From abb70e167d42684a20ea59c03959758be7d7e72b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 14/50] test(ContextMenu): a starved page fails the 735 ms proof by name A WebKit CI run showed the menu at 890 ms, inside the timer task that decided it, so that task itself started late. The proof now logs the page's frames. A deciding task that starts within a frame of its due time keeps the 20 ms bound. A later start that a frame gap over 50 ms covers fails with FrameStarvationError, its numbers named; any other late start fails bare. The bound does not move. --- ...tMenuLongPress.interaction.browser.spec.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index efba3d49c46db..deddefbbed4bf 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -21,6 +21,8 @@ import { } from '@astryxdesign/a11y-spec/playwright'; import { drag, + FRAME_STARVATION_MS, + FrameStarvationError, installInputLog, openStory, readInputLog, @@ -149,11 +151,79 @@ const touchStartedAt = async (page: Page) => lastInput(page, 'touchstart'); /** A finger held still for `ms`, then lifted. */ const hold = (at: {x: number; y: number}, ms: number) => tap(at, {holdMs: ms}); +/** One frame at 60 Hz, ms. */ +const FRAME_MS = 1000 / 60; + +/** Logs when each frame's callbacks run, on the page clock, until read. */ +async function startFrameLog(page: Page) { + await page.evaluate(() => { + const w = window as unknown as {__frameLog?: number[]}; + const log: number[] = []; + w.__frameLog = log; + const tick = () => { + log.push(performance.now()); + if (w.__frameLog === log) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); +} + +/** Stops the frame log and reads it. */ +async function readFrameLog(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as {__frameLog?: number[]}; + const log = w.__frameLog ?? []; + w.__frameLog = undefined; + return log; + }); +} + +/** + * The timer task that decides the menu starts within a frame of its due + * time. A later start that a frame gap over `FRAME_STARVATION_MS` covers + * fails by name: the page was starved of frames. Any other late start fails + * bare, because something on the page held the timer. + */ +function expectDecidedOnTime( + frames: ReadonlyArray, + dueAt: number, + startedAt: number, +) { + const lateMs = startedAt - dueAt; + if (lateMs <= FRAME_MS) { + return; + } + let longestGapMs = 0; + for (let i = 1; i < frames.length; i++) { + const coversLateStart = + frames[i - 1] <= dueAt + FRAME_MS && frames[i] >= startedAt - FRAME_MS; + if (coversLateStart) { + longestGapMs = Math.max(longestGapMs, frames[i] - frames[i - 1]); + } + } + if (longestGapMs > FRAME_STARVATION_MS) { + throw new FrameStarvationError({ + during: `the menu's timer task was due, and it started ${lateMs.toFixed(1)} ms late`, + frames: frames.filter(t => t > dueAt && t < startedAt).length, + min: Math.max(1, Math.floor(lateMs / FRAME_MS)), + longestGapMs, + }); + } + expect( + lateMs, + 'the timer task that decides the menu started late while frames ran', + ).toBeLessThanOrEqual(FRAME_MS); +} + test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async ({ page, }) => { const {driver, center} = await openTouchStory(page); + await startFrameLog(page); await driver.play(hold(center, menuDisplayMs + 200)); + const frames = await readFrameLog(page); const start = await touchStartedAt(page); const shown = await menuShownAt(page); const decided = await menuShownTimerAt(page); @@ -165,6 +235,7 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async 'the menu showed after the task that decided it', ).not.toBeNull(); expect((decided as number) - start).toBeGreaterThanOrEqual(menuDisplayMs - 1); + expectDecidedOnTime(frames, start + menuDisplayMs, decided as number); const after = (shown as number) - start; // The timer runs at its deadline, give or take a task. expect(after).toBeGreaterThanOrEqual(menuDisplayMs); From 4edb9004e70dd063b97d9653e26e071b7c06f038 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 15/50] test(ContextMenu): Reduce Motion's menu shows in its deciding task too The 735 ms proof wants the menu to show inside the timer task that decides it. Under Reduce Motion the menu opens on the same timer, so its proof wants the same: the show inside that task, which starts at the deadline counted from the touch. --- .../ContextMenuLongPress.interaction.browser.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index f4ee001b28a2b..97ca0567f393a 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -417,9 +417,15 @@ test('under Reduce Motion the card does not grow, and the menu still opens at 73 await playing; const start = await touchStartedAt(page); const shown = await menuShownAt(page); + const decided = await menuShownTimerAt(page); expect(scales).toEqual([1, 1, 1]); expect(await trigger.evaluate(el => el.getAnimations().length)).toBe(0); expect(shown, 'the menu never showed').not.toBeNull(); + expect( + decided, + 'the menu showed after the task that decided it', + ).not.toBeNull(); + expect((decided as number) - start).toBeGreaterThanOrEqual(menuDisplayMs - 1); const after = (shown as number) - start; // The touch's stamp and the page clock are each coarsened (0.1 ms). expect(after).toBeGreaterThanOrEqual(menuDisplayMs - 0.5); From 24c03c1e10039a5eb52569848433ab8f7643419f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 18:05:19 -0800 Subject: [PATCH 16/50] test(ContextMenu): a starved page's late timer fails by name in WebKit After a long task, WebKit draws a frame and runs the delayed timer about 20 ms later. The 735 ms proof counted a frame gap only when it ended within a frame of the timer's start, so an 80 ms block failed bare there. A gap over 50 ms that holds the due time now explains a start within two frames of its end, and fails by name. A task held longer while frames ran, and a slow read inside an on-time task, still fail bare. The 20 ms bound does not move. --- ...ontextMenuLongPress.interaction.browser.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index deddefbbed4bf..3f0d941d4bf39 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -182,9 +182,10 @@ async function readFrameLog(page: Page): Promise { /** * The timer task that decides the menu starts within a frame of its due - * time. A later start that a frame gap over `FRAME_STARVATION_MS` covers - * fails by name: the page was starved of frames. Any other late start fails - * bare, because something on the page held the timer. + * time. A later start fails by name when the page was starved of frames. + * Then a gap over `FRAME_STARVATION_MS` held the due time, and the task ran + * within two frames of its end. Any other late start fails bare, because + * something on the page held the timer while frames ran. */ function expectDecidedOnTime( frames: ReadonlyArray, @@ -197,9 +198,10 @@ function expectDecidedOnTime( } let longestGapMs = 0; for (let i = 1; i < frames.length; i++) { - const coversLateStart = - frames[i - 1] <= dueAt + FRAME_MS && frames[i] >= startedAt - FRAME_MS; - if (coversLateStart) { + const holdsDueTime = frames[i - 1] <= dueAt + FRAME_MS && frames[i] > dueAt; + // After a long task the browser may draw a frame before the late timer. + const ranAfterIt = startedAt - frames[i] <= 2 * FRAME_MS; + if (holdsDueTime && ranAfterIt) { longestGapMs = Math.max(longestGapMs, frames[i] - frames[i - 1]); } } From 339bf7ec983966e8434b73b799469c6ad80cc281 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 18:56:40 -0800 Subject: [PATCH 17/50] docs(ContextMenu): PR1 names each presentation's query subscriptions Unless `presentation="popover"`, the menu subscribes to the coarse-pointer query while mounted, a bottom sheet included, and with `presentation="adaptive"` also to the compact query, as usePresentation reads them. PR1 named only the omitted and adaptive cases. --- docs/specs/components/ContextMenu/ContextMenu.spec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 7ef1f273a2d39..8f742d93b325c 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -148,9 +148,9 @@ Consumer props, content modes, and presentation policy remain documented in adds pointer listeners to the trigger's document (and shadow roots), plus a mousedown listener where a mouse or a pen closes it at the press, and removes them on close. The pointer menu observes its size and content to - decide whether it scrolls. With `presentation` omitted it subscribes to the - coarse-pointer query while mounted, and with `presentation="adaptive"` to - the compact-touch query. A menu never opened mounts no pointer menu surface, only its layer's + decide whether it scrolls. Unless `presentation="popover"`, it subscribes to + the coarse-pointer query while mounted, and with `presentation="adaptive"` + also to the compact query. A menu never opened mounts no pointer menu surface, only its layer's inert marker: the surface mounts at the first open and stays mounted after it, so a page with many context menus (a table with one per cell) carries no closed menu surfaces. A finger's hold mounts the surface, closed, at its From 3d78dae488ec298c7ddefea756032ee181008321 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 19:03:13 -0800 Subject: [PATCH 18/50] docs(ContextMenu): the record names the touch open's focus gap A touch open focuses the menu itself, and the menu draws no focus outline, forced colors included. The record's current-state impact now lists it: interaction-modality INV1 hides the indicator after pointer input, and no record names one for this menu under forced colors. --- docs/specs/components/ContextMenu/ContextMenu.spec.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 8f742d93b325c..c01fca1ee48c0 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -279,6 +279,11 @@ anchor is positioning infrastructure rather than consumer anatomy. lifted over a tinted page; a pen press outside closes it at the press. All are against `spec:AST-094` FR31, FR32, and FR33. A menu opened by mouse or keyboard already keeps its released appearance and motion. +- **Touch-open focus indicator.** A touch open focuses the menu itself, and + the menu draws no focus outline in any color mode, forced colors included. + `architecture:interaction-modality` INV1 hides the focus indicator after + pointer input, and no record names an indicator for this menu under forced + colors. ## Decision log From a5a0e12ac746a39378c665b528ccba9f8b2f8f8a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 09:32:16 -0800 Subject: [PATCH 19/50] test(ContextMenu): a finger that lifts before 400 ms is a plain tap A long press that fails before its commit at 400 ms ends as a plain tap: the touch model suppresses the click only once a gesture takes the touch. The browser case holds a finger 250 ms, past core's press onset, checks that the lift reached the page before 400 ms, and reads one click and no menu. The outside-press changeset files the change as a fix: a menu in an iframe or a Picture-in-Picture window no longer closes on a press in the page. --- .changeset/contextmenu-outside-press-watch.md | 2 +- ...tMenuLongPress.interaction.browser.spec.ts | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.changeset/contextmenu-outside-press-watch.md b/.changeset/contextmenu-outside-press-watch.md index c8ed790df9536..8540b95a11b32 100644 --- a/.changeset/contextmenu-outside-press-watch.md +++ b/.changeset/contextmenu-outside-press-watch.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[chore] Internal: ContextMenu's touch model uses the layer's outside-press watch. It closes the touch menu as a press outside begins to pan, and judges a mouse or pen press at its `mousedown` (`pressTypes`), in the menu's own document. Every other watch keeps today's behavior. Not exported from the package entry point. +[fix] ContextMenu closes on an outside press through the layer's outside-press watch. A mouse or pen press closes it at its `mousedown`, in the menu's own document, so a menu in an iframe or a Picture-in-Picture window no longer closes on a press in the page. @vjeux diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 3f0d941d4bf39..b75dddb06f821 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -284,6 +284,75 @@ async function openScrollableStory(page: Page) { return {...story, center, top}; } +/** WebKit makes a click only from its own tap gesture (harness README). */ +const CLICK_FROM_SCRIPT_SKIP = + 'WebKit makes a click from its own tap gesture only, not from a scripted touch'; + +/** Counts the clicks that reach the trigger from now on. */ +async function countTriggerClicks(page: Page) { + await page.evaluate(() => { + const w = window as unknown as {__triggerClicks?: number}; + w.__triggerClicks = 0; + const trigger = document.querySelector( + '[data-testid="touch-context-menu"]', + ); + document.addEventListener( + 'click', + event => { + if ( + trigger != null && + event.target instanceof Node && + trigger.contains(event.target) + ) { + w.__triggerClicks = (w.__triggerClicks ?? 0) + 1; + } + }, + true, + ); + }); + return async () => + page.evaluate( + () => + (window as unknown as {__triggerClicks?: number}).__triggerClicks ?? 0, + ); +} + +test('a finger that lifts before 400 ms makes a plain tap: one click and no menu', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + test.skip(!driver.drivesNativeGestures, CLICK_FROM_SCRIPT_SKIP); + const clicks = await countTriggerClicks(page); + // Past core's press onset at 150 ms: a long press that fails is still a + // tap. + await driver.play(hold(center, 250)); + const startedAt = await touchStartedAt(page); + const liftedAfterMs = (await lastInput(page, 'touchend')) - startedAt; + // The commit at 400 ms runs on the page clock. A lift that reached the page + // later makes a real long press, so the case cannot judge a tap. + if (liftedAfterMs >= contextMenu.press.commitMs) { + throw new Error( + `the lift reached the page ${liftedAfterMs.toFixed(1)} ms after the touch, past the ${contextMenu.press.commitMs} ms a tap allows`, + ); + } + await expect.poll(clicks).toBe(1); + // The menu's moment passes with no menu. + await page.waitForFunction( + dueAt => performance.now() > dueAt, + startedAt + menuDisplayMs + 100, + ); + // One task more, so a timer that is due runs before the read. + await page.evaluate( + async () => + new Promise(resolve => { + setTimeout(resolve, 0); + }), + ); + expect(await menuShownAt(page)).toBeNull(); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); + expect(await clicks()).toBe(1); +}); + test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ page, }) => { From 06c942cab25f837bf0e6c7bfaadb00263d8c066b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 11:02:02 -0800 Subject: [PATCH 20/50] test(ContextMenu): a press leaves the open menu as an unpressed open A finger or a pen holds the trigger until the menu shows. While the press grows the trigger, and after the lift, the menu's box and transform match a mouse open at the same point, no element above the cursor anchor carries a transform, and the anchor has no inline geometry. A new story puts a block, a row and a grid trigger beside a plain box with the same style, and a case checks that both lay out the same. --- .../storybook/stories/ContextMenu.stories.tsx | 87 ++++++++ ...tMenuLongPress.interaction.browser.spec.ts | 195 ++++++++++++++++++ 2 files changed, 282 insertions(+) diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx index 0c25ea1cea0b4..c19acf0dfbd64 100644 --- a/apps/storybook/stories/ContextMenu.stories.tsx +++ b/apps/storybook/stories/ContextMenu.stories.tsx @@ -692,6 +692,93 @@ export const TouchLongPress: Story = { ), }; +const layoutStyles = stylex.create({ + page: { + display: 'grid', + gap: '24px', + }, + pair: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '24px', + }, + block: { + padding: '12px', + borderRadius: '12px', + backgroundColor: '#f3f4f6', + }, + row: { + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '8px 16px', + backgroundColor: '#f3f4f6', + }, + grid: { + display: 'grid', + gridTemplateColumns: '40px 1fr', + gridTemplateRows: 'auto auto', + columnGap: '12px', + rowGap: '4px', + padding: '8px', + backgroundColor: '#f3f4f6', + }, + swatch: { + width: '40px', + height: '40px', + borderRadius: '8px', + backgroundColor: '#9ca3af', + gridRow: '1 / 3', + }, +}); + +const LAYOUTS = ['block', 'row', 'grid'] as const; + +function LayoutContent() { + return ( + <> +
+ Quarterly report + Edited today + + ); +} + +export const TouchTriggerLayouts: Story = { + name: 'Touch / trigger layouts', + parameters: { + docs: { + description: { + story: + 'The press grows only the trigger’s own visual, an inner box that lays the children out as the trigger would. Each trigger here sits beside a plain box with the same style, and both lay out the same.', + }, + }, + }, + render: () => ( +
+ {LAYOUTS.map(layout => ( +
+ {}}, + {label: 'Share', icon: ShareIcon, onClick: () => {}}, + ]}> + + +
+ +
+
+ ))} +
+ ), +}; + export const TouchLongPressPopover: Story = { name: 'Touch / long press, presentation="popover"', parameters: { diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 49c420c8ead96..15f771269695c 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -35,6 +35,7 @@ import { markElement, merge, openStory, + PEN_SKIP, readInputLog, releaseAnimations, startScreencast, @@ -45,6 +46,7 @@ import { trackMarker, traceRendering, waitForAnimation, + webInputDriver, withFinger, withStorybook, writeEvidence, @@ -558,6 +560,199 @@ test('the lift after the menu shows keeps it open and sends no click', async ({ expect(log.filter(record => record.type === 'click')).toEqual([]); }); +/** Polls until no animation runs on the menu's popover or inside it. */ +async function untilMenuSettled(page: Page) { + await expect + .poll( + async () => + page.evaluate(() => { + const surfaces = Array.from( + document.querySelectorAll('[popover]'), + ).filter(popover => popover.querySelector('[role="menu"]') != null); + return document.getAnimations().filter(animation => { + const target = + animation.effect instanceof KeyframeEffect + ? animation.effect.target + : null; + return ( + target != null && + animation.playState === 'running' && + surfaces.some( + surface => surface === target || surface.contains(target), + ) + ); + }).length; + }), + {timeout: 4000}, + ) + .toBe(0); +} + +/** + * The open menu's box and computed transform. Also every element from the + * cursor anchor up that carries a transform, and the anchor's inline + * geometry. + */ +async function readMenuPlacement(page: Page) { + return page.evaluate(() => { + const trigger = document.querySelector( + '[data-testid="touch-context-menu"]', + ); + const anchor = trigger?.lastElementChild as HTMLElement | null; + const surface = + document + .querySelector('[role="menu"]') + ?.closest('[popover]') ?? null; + const transformed: string[] = []; + for (let el: Element | null = anchor; el != null; el = el.parentElement) { + const style = getComputedStyle(el); + const scales = style.scale === 'none' ? [] : style.scale.split(' '); + if ( + style.transform !== 'none' || + scales.some(value => Number.parseFloat(value) !== 1) || + (style.translate !== 'none' && style.translate !== '0px') + ) { + const name = el === trigger ? 'trigger' : el.tagName.toLowerCase(); + transformed.push( + `${name}: scale ${style.scale}, translate ${style.translate}, transform ${style.transform}`, + ); + } + } + const box = surface?.getBoundingClientRect(); + return { + menu: + box == null + ? null + : {x: box.x, y: box.y, width: box.width, height: box.height}, + transform: surface == null ? null : getComputedStyle(surface).transform, + transformed, + anchorGeometry: + anchor == null + ? null + : ['scale', 'translate', 'transform', 'transform-origin'].filter( + property => anchor.style.getPropertyValue(property) !== '', + ), + }; + }); +} + +/** The largest scale on the trigger or inside it: the press's growth. */ +async function grownScale(trigger: Locator) { + return trigger.evaluate(root => + Math.max( + ...[root, ...root.querySelectorAll('*')].map(el => { + const scale = getComputedStyle(el).scale; + return scale === 'none' ? 1 : Number.parseFloat(scale); + }), + ), + ); +} + +for (const input of ['finger', 'pen'] as const) { + test(`a ${input}’s press grows only the trigger’s visual: the open menu matches an unpressed open, and nothing above its anchor is transformed (layer-runtime INV22)`, async ({ + page, + }) => { + const {driver, trigger} = await openTouchStory(page); + const pen = input === 'pen' ? await webInputDriver(page) : null; + test.skip(pen != null && !pen.pens, PEN_SKIP); + const box = await trigger.boundingBox(); + if (box == null) { + throw new Error('no trigger'); + } + // Near the far corner, where a scaled anchor would land furthest off. + const p = { + x: Math.round(box.x + box.width * 0.9), + y: Math.round(box.y + box.height * 0.85), + }; + const menu = page.getByRole('menu', {name: 'Photo actions'}); + // Held well past the menu's moment, so the first read is inside the + // press. + const holdMs = menuDisplayMs + 1500; + const playing = + pen == null + ? driver.play(hold(p, holdMs)) + : pen.pen([ + {...p, t: 0}, + {...p, t: holdMs}, + ]); + await expect(menu).toBeVisible(); + await untilMenuSettled(page); + const during = await readMenuPlacement(page); + const grown = await grownScale(trigger); + await playing; + await untilMenuSettled(page); + const after = await readMenuPlacement(page); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + await expect.poll(async () => grownScale(trigger), {timeout: 3000}).toBe(1); + // Unpressed: a mouse opens the menu at the same point. + await page.mouse.click(p.x, p.y, {button: 'right'}); + await expect(menu).toBeVisible(); + await untilMenuSettled(page); + const unpressed = await readMenuPlacement(page); + const detail = JSON.stringify({p, grown, during, after, unpressed}); + expect(grown, detail).toBeGreaterThan(1); + expect(unpressed.menu, detail).not.toBeNull(); + for (const read of [during, after]) { + expect(read.transformed, detail).toEqual([]); + expect(read.anchorGeometry, detail).toEqual([]); + expect(read.transform, detail).toBe(unpressed.transform); + expect(read.menu?.x, detail).toBeCloseTo(unpressed.menu?.x ?? NaN, 0); + expect(read.menu?.y, detail).toBeCloseTo(unpressed.menu?.y ?? NaN, 0); + expect(read.menu?.width, detail).toBeCloseTo( + unpressed.menu?.width ?? NaN, + 0, + ); + expect(read.menu?.height, detail).toBeCloseTo( + unpressed.menu?.height ?? NaN, + 0, + ); + } + }); +} + +test('the trigger’s visual lays out its children as the trigger would: a block, a row and a grid trigger each match a plain box with the same style', async ({ + page, +}) => { + await openStory(page, storybook(), 'core-contextmenu--touch-trigger-layouts'); + for (const layout of ['block', 'row', 'grid']) { + const read = async (testId: string) => + page.getByTestId(testId).evaluate(root => { + const origin = root.getBoundingClientRect(); + const leaves = [...root.querySelectorAll('*')].filter( + el => + el.childElementCount === 0 && + el.getAttribute('aria-hidden') == null, + ); + return { + size: {width: origin.width, height: origin.height}, + children: leaves.map(el => { + const box = el.getBoundingClientRect(); + return { + x: box.x - origin.x, + y: box.y - origin.y, + width: box.width, + height: box.height, + }; + }), + }; + }); + const trigger = await read(`layout-${layout}`); + const reference = await read(`layout-${layout}-reference`); + const detail = JSON.stringify({layout, trigger, reference}); + expect(trigger.children.length, detail).toBe(3); + expect(trigger.children.length, detail).toBe(reference.children.length); + expect(trigger.size.width, detail).toBeCloseTo(reference.size.width, 1); + expect(trigger.size.height, detail).toBeCloseTo(reference.size.height, 1); + for (const [i, child] of trigger.children.entries()) { + const twin = reference.children[i]; + for (const key of ['x', 'y', 'width', 'height'] as const) { + expect(child[key], detail).toBeCloseTo(twin[key], 1); + } + } + } +}); + test('the menu’s anchor sits under the finger, on screen and as laid out, while the trigger is grown, and the menu stays put', async ({ page, }) => { From 597e158d96b51a691c8d6496a7b28daa04c73492 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 11:02:02 -0800 Subject: [PATCH 21/50] fix(ContextMenu): the press grows the trigger's visual, not its anchor The press feedback scaled the trigger itself, and the menu's cursor anchor sits inside the trigger. So a script cancelled the scale at the anchor. Now the trigger lays its children out in an inner visual box, and only that box grows. The anchor stays outside it, on the trigger's untransformed box, and the cancelling code is gone. A layer never takes its trigger's press transform. --- .changeset/contextmenu-touch-timeline.md | 2 +- packages/core/src/ContextMenu/ContextMenu.tsx | 99 +++++++++---------- .../ContextMenuTouchGrowth.test.tsx | 16 +-- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 13 ++- ...tMenuLongPress.interaction.browser.spec.ts | 45 ++++++--- .../contextMenuPressFeedback.test.tsx | 11 ++- .../ContextMenu/contextMenuPressFeedback.ts | 26 ++--- 7 files changed, 114 insertions(+), 98 deletions(-) diff --git a/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md index df1a62392af22..50c8df16b8668 100644 --- a/.changeset/contextmenu-touch-timeline.md +++ b/.changeset/contextmenu-touch-timeline.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. A block trigger whose menu opens at the finger starts to grow 150 ms after the finger lands, toward its held size on UIKit's lift spring, played on the compositor, and the hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the trigger springs back to its size. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. The trigger returns to its size when the menu closes. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. +[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. A block trigger whose menu opens at the finger starts to grow 150 ms after the finger lands, toward its held size on UIKit's lift spring, played on the compositor, and the hold keeps UIKit's measured moments. The growth scales an inner box that holds the trigger's children, so the menu never takes the press's scale. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the trigger springs back to its size. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. The trigger returns to its size when the menu closes. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms. @vjeux diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index c0c120c45c28e..7ec64ec0ede47 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -142,6 +142,33 @@ const styles = stylex.create({ WebkitUserSelect: 'none', userSelect: 'none', }, + // The trigger's own visual, which the press feedback grows. It lays its + // children out as the trigger would, and fills the trigger in a flex or + // grid layout. The cursor anchor stays outside it, so a layer never takes + // the press's scale. + triggerVisual: { + display: 'inherit', + flexDirection: 'inherit', + flexWrap: 'inherit', + alignItems: 'inherit', + alignContent: 'inherit', + justifyContent: 'inherit', + justifyItems: 'inherit', + rowGap: 'inherit', + columnGap: 'inherit', + gridTemplateColumns: 'inherit', + gridTemplateRows: 'inherit', + gridTemplateAreas: 'inherit', + gridAutoFlow: 'inherit', + gridAutoColumns: 'inherit', + gridAutoRows: 'inherit', + flexGrow: 1, + alignSelf: 'stretch', + gridColumn: '1 / -1', + gridRow: '1 / -1', + minWidth: 0, + borderRadius: 'inherit', + }, // 1px anchor placed at the cursor point within the trigger. The menu is // anchored to this element, so it sits under the cursor yet is positioned // relative to the trigger's context — it follows the content on scroll and @@ -241,20 +268,6 @@ const styles = stylex.create({ }, }); -/** An element's own computed `scale` on each axis; 1 where it has none. */ -function ownScale(element: HTMLElement): {x: number; y: number} { - const value = - element.ownerDocument.defaultView?.getComputedStyle(element).scale ?? ''; - if (value === '' || value === 'none') { - return {x: 1, y: 1}; - } - const [x, y = x] = value.trim().split(/\s+/).map(Number.parseFloat); - return { - x: Number.isFinite(x) && x > 0 ? x : 1, - y: Number.isFinite(y) && y > 0 ? y : 1, - }; -} - /** * Keeps the 1px cursor anchor inside the trigger along one axis. A point in * the trigger's last pixel (Shift+F10 opens on its bottom edge) steps back by @@ -421,6 +434,8 @@ export function ContextMenu({ // — it scrolls with the content instead of sitting at a fixed viewport point. const positionRef = useRef({x: 0, y: 0}); const cursorAnchorRef = useRef(null); + // The trigger's own visual: what the press feedback grows. + const visualRef = useRef(null); const triggerRef = useRef(null); // Element focused before the menu opened, restored when it closes so focus // does not fall to after Escape or outside-click dismissal. @@ -654,16 +669,6 @@ export function ContextMenu({ [listNavKeyDown, closeMenu, typeahead, ownsEvent], ); - // How much the press feedback has grown a block trigger from its center: - // the trigger's own `scale`, not an ancestor's transform. - const triggerScale = useCallback((): {x: number; y: number} => { - const trigger = triggerRef.current; - if (trigger == null || triggerAs === 'span') { - return {x: 1, y: 1}; - } - return ownScale(trigger); - }, [triggerAs]); - // Place the 1px cursor anchor at a point in the trigger's local // coordinate space and open the menu. Positioning the anchor inside the // trigger (rather than storing viewport coordinates on the menu itself) is @@ -680,29 +685,13 @@ export function ContextMenu({ focusTarget: 'first' | 'menu' = 'first', ) => { const rect = triggerRef.current?.getBoundingClientRect(); - const scale = rect ? triggerScale() : {x: 1, y: 1}; - const x = rect ? keepAnchorInside(localX, rect.width / scale.x) : localX; - const y = rect ? keepAnchorInside(localY, rect.height / scale.y) : localY; + const x = rect ? keepAnchorInside(localX, rect.width) : localX; + const y = rect ? keepAnchorInside(localY, rect.height) : localY; positionRef.current = {x, y}; const anchorEl = cursorAnchorRef.current; if (anchorEl) { anchorEl.style.left = `${x}px`; anchorEl.style.top = `${y}px`; - // A trigger the press feedback has grown (from its center) would - // carry the anchor with it, and engines differ on whether anchor - // positioning follows transforms. Cancel the scale at the anchor, so - // its box is the same either way: where the point is on screen. - if (rect != null && (scale.x !== 1 || scale.y !== 1)) { - const centerX = rect.width / scale.x / 2; - const centerY = rect.height / scale.y / 2; - anchorEl.style.transformOrigin = '0 0'; - anchorEl.style.scale = `${1 / scale.x} ${1 / scale.y}`; - anchorEl.style.translate = `${(x - centerX) * (1 / scale.x - 1)}px ${(y - centerY) * (1 / scale.y - 1)}px`; - } else { - anchorEl.style.removeProperty('transform-origin'); - anchorEl.style.removeProperty('scale'); - anchorEl.style.removeProperty('translate'); - } } // Remember the element focused before opening so we can restore it on // close (Escape or outside-click), instead of dropping focus to . @@ -720,7 +709,7 @@ export function ContextMenu({ layer.show(); } }, - [layer, updateOpenState, usesBottomSheet, triggerScale], + [layer, updateOpenState, usesBottomSheet], ); // The hold's commit: the menu shows at its moment or at the lift, so its @@ -739,25 +728,20 @@ export function ContextMenu({ ); // A client point in the trigger's own coordinates, where the cursor anchor - // lives. It is the offset from the trigger's laid-out box, before the press - // feedback's scale, and it stays inside that box. + // lives. The press feedback grows only the trigger's visual, so the + // trigger's box is as laid out. The point stays inside that box. const toTriggerLocal = useCallback( (point: {x: number; y: number}): {x: number; y: number} => { const rect = triggerRef.current?.getBoundingClientRect(); if (rect == null) { return point; } - const scale = triggerScale(); - const width = rect.width / scale.x; - const height = rect.height / scale.y; - const left = rect.left + (rect.width - width) / 2; - const top = rect.top + (rect.height - height) / 2; return { - x: Math.min(Math.max(point.x - left, 0), width), - y: Math.min(Math.max(point.y - top, 0), height), + x: Math.min(Math.max(point.x - rect.left, 0), rect.width), + y: Math.min(Math.max(point.y - rect.top, 0), rect.height), }; }, - [triggerScale], + [], ); const handleContextMenu = useCallback( @@ -1036,7 +1020,13 @@ export function ContextMenu({ : [triggerXstyle] : []), )}> - {children} + {triggerAs === 'div' ? ( +
+ {children} +
+ ) : ( + children + )}
), }; diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index c5604ddce5ec4..bc841028e4a2b 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -758,11 +758,11 @@ for (const input of ['finger', 'pen'] as const) { }); } -test('the trigger’s visual lays out its children as the trigger would: a block, a row and a grid trigger each match a plain box with the same style', async ({ +test('the trigger’s visual lays out its children as the trigger would: a block, a row, a grid and a set-height trigger each match a plain box with the same style', async ({ page, }) => { await openStory(page, storybook(), 'core-contextmenu--touch-trigger-layouts'); - for (const layout of ['block', 'row', 'grid']) { + for (const layout of ['block', 'row', 'grid', 'tall']) { const read = async (testId: string) => page.getByTestId(testId).evaluate(root => { const origin = root.getBoundingClientRect(); @@ -787,7 +787,7 @@ test('the trigger’s visual lays out its children as the trigger would: a block const trigger = await read(`layout-${layout}`); const reference = await read(`layout-${layout}-reference`); const detail = JSON.stringify({layout, trigger, reference}); - expect(trigger.children.length, detail).toBe(3); + expect(trigger.children.length, detail).toBe(layout === 'tall' ? 1 : 3); expect(trigger.children.length, detail).toBe(reference.children.length); expect(trigger.size.width, detail).toBeCloseTo(reference.size.width, 1); expect(trigger.size.height, detail).toBeCloseTo(reference.size.height, 1); From 3c39a05a474458f986ffc0fdcd307a56c09de9ff Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 11:43:41 -0800 Subject: [PATCH 25/50] test(ContextMenu): a late drift past 11.5 px cancels in every engine The case for a finger that drifts past 11.5 px after the commit, before the menu's moment, now runs in Chromium too. --- .../ContextMenuLongPress.interaction.browser.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 321de791549e4..b9da7f55e6741 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -364,12 +364,7 @@ const afterTwoFrames = async (page: Page) => test('a slow drift past 11.5 px late in the hold cancels it: no menu and no click (AST-094 FR21)', async ({ page, - browserName, }) => { - test.fixme( - browserName === 'chromium', - 'Chromium opens the menu at about 736 ms after a drift past 11.5 px; WebKit cancels', - ); const {driver, center} = await openTouchStory(page); const clicks = await countTriggerClicks(page); // The finger rests past the commit at 400 ms, then drifts 14 px in 250 ms. From a4e040430c2700ae2ddb1b34865297ff5d655a8b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 12:55:23 -0800 Subject: [PATCH 26/50] fix(ContextMenu): the menu waits for the finger's late moves Under load, a held finger's moves can reach the page after the menu's moment, though they came before it, and the gesture arena's clock has then passed that moment. A finger that drifted past 11.5 px after the commit still got the menu. The touch layer now reports each pointermove of the held finger with its own time. At its moment the menu waits while such moves still come, so a drift in them ends the hold. --- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 63 +++++++++++++++++++ .../src/ContextMenu/contextMenuPress.test.ts | 28 +++++++++ .../core/src/ContextMenu/contextMenuPress.ts | 61 ++++++++++++++++-- 3 files changed, 148 insertions(+), 4 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index a42432403acf2..d3a8a7a1be0d0 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -16,6 +16,8 @@ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; import {flushSync} from 'react-dom'; import {playHaptic} from '../gestures/haptics'; +import {useAttachment} from '../hooks/useAttachment'; +import {subscribeInput} from '../utils/inputStream'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; import {ContextMenuPressRecognizer} from './ContextMenuTouch'; @@ -41,6 +43,23 @@ export interface ContextMenuTouchLayerProps { onCancel?: () => void; } +/** + * An event's time on the gesture arena's clock, as the gesture host reads it. + * That is its `timeStamp`, which some engines and DOM emulators give from the + * epoch instead of from the page's time origin. + */ +function clockTime(event: Event): number { + const time = event.timeStamp; + if ( + time > 1e12 && + typeof performance !== 'undefined' && + performance.timeOrigin > 0 + ) { + return time - performance.timeOrigin; + } + return time; +} + /** * UIContextMenuInteraction's long press. iOS Safari never fires `contextmenu` * for a held finger. So the trigger runs its own recognizer on UIKit's @@ -117,6 +136,50 @@ export function ContextMenuTouchLayer({ }, ); + // Under load, the held finger's moves can reach the page after the menu's + // moment, though they came before it. The recognizer reads each move's own + // time here, so the menu waits for them, and a drift in them still ends the + // hold. + useAttachment( + triggerRef, + element => { + const view = element.ownerDocument.defaultView; + if (view == null) { + return () => {}; + } + let pointerId: number | null = null; + const onDown = (event: PointerEvent) => { + if (event.pointerType === 'touch' && event.isPrimary) { + pointerId = event.pointerId; + } + }; + const onMove = (event: PointerEvent) => { + if (event.pointerId !== pointerId) { + return; + } + recognizer.followMove(clockTime(event)); + }; + const onEnd = (event: PointerEvent) => { + if (event.pointerId === pointerId) { + pointerId = null; + } + }; + element.addEventListener('pointerdown', onDown, {capture: true}); + const unsubscribes = [ + subscribeInput(view, 'pointermove', onMove), + subscribeInput(view, 'pointerup', onEnd), + subscribeInput(view, 'pointercancel', onEnd), + ]; + return () => { + element.removeEventListener('pointerdown', onDown, {capture: true}); + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + }; + }, + recognizer, + ); + useImperativeHandle( ref, () => ({ diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index fdc7db94eb237..493b40f0a515f 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -97,6 +97,34 @@ describe('ContextMenuPressRecognizer', () => { expect(press.isMenuDue).toBe(false); }); + it('waits at the menu’s moment for moves that came before it but reach the page late, and a drift in them cancels', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // The page has just handled a move from 450 ms: more may still come. + press.followMove(450); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // A late move from before the moment, 14 px over: the hold ends. + finger.move(14, 0, DISPLAY + 10); + expect(states).toEqual([`began@${COMMIT}`, `cancelled@${DISPLAY + 10}`]); + expect(press.isMenuDue).toBe(false); + finger.up(DISPLAY + 300); + }); + + it('shows the menu once a move from its moment on reaches the page', () => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + press.followMove(450); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + press.followMove(DISPLAY + 5); + expect(press.isMenuDue).toBe(true); + expect(states.at(-1)).toBe(`changed@${DISPLAY}`); + finger.up(DISPLAY + 300); + }); + it('lets the finger move freely once the menu is due', () => { const {harness, press, states} = setup(); const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index dfc25022eeb40..eec04013993e0 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -23,6 +23,12 @@ import {contextMenu} from '../motion/uikit/contextMenu'; const {commitMs, menuDisplayMs, allowableMovement} = contextMenu.press; +/** + * How long the finger's moves may pause before the menu stops waiting for + * late ones, in ms: three frames at 60 Hz. + */ +const LATE_MOVE_GAP_MS = 50; + /** * The foundation's own press timer, which this recognizer never lets fire: * the hold below commits instead. It is longer than any hold, and under the @@ -80,8 +86,16 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { private pressTouchId: number | null = null; /** Where the finger landed, while its hold runs. The drift counts from here. */ private holdFrom: {x: number; y: number} | null = null; + /** + * The held finger's latest pointermove: when it came, on the event clock, + * and when the page got it. + */ + private latestMove: {time: number; arrivedAt: number} | null = null; /** When the finger landed, on the arena's clock. The deadlines count from it. */ private holdStart = 0; + /** The menu's moment passed while the finger's moves still came late. */ + private isMenuWaiting = false; + private moveWait: ReturnType | null = null; constructor(options: ContextMenuPressRecognizerOptions = {}) { super({ @@ -138,6 +152,18 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { super.touchesMoved(touches); } + /** + * ContextMenu's touch layer reports each pointermove of the held finger: + * the time it came, on the event clock. A move from the menu's moment on + * means no late move can still come before it. + */ + followMove(time: number): void { + this.latestMove = {time, arrivedAt: performance.now()}; + if (this.isMenuWaiting && time >= this.holdStart + menuDisplayMs) { + this.showMenu(); + } + } + /** * Follows the finger during its hold. A drift past `allowableMovement` on * either axis ends the hold. Returns whether the hold still runs. @@ -159,9 +185,18 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { private endHold(): void { this.clearTimer(); + this.stopMoveWait(); this.holdFrom = null; } + private stopMoveWait(): void { + this.isMenuWaiting = false; + if (this.moveWait != null) { + clearTimeout(this.moveWait); + this.moveWait = null; + } + } + /** The hold's commit claims the finger, then waits for the menu's moment. */ private commit(): void { if (this.holdFrom == null) { @@ -180,12 +215,29 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { } } - /** The finger is still down at the menu's moment: the menu is due. */ + /** + * The finger is still down at the menu's moment: the menu is due. Under + * load, the finger's moves can reach the page after that moment, though + * they came before it. While they still come, the menu waits for them, so + * a drift in them still ends the hold. + */ private showMenu(): void { - if (this.holdFrom != null && isActive(this.state)) { - this.isMenuDue = true; - this.setState('changed'); + this.stopMoveWait(); + if (this.holdFrom == null || !isActive(this.state)) { + return; + } + const move = this.latestMove; + if ( + move != null && + move.time < this.holdStart + menuDisplayMs && + performance.now() - move.arrivedAt < LATE_MOVE_GAP_MS + ) { + this.isMenuWaiting = true; + this.moveWait = setTimeout(() => this.showMenu(), LATE_MOVE_GAP_MS); + return; } + this.isMenuDue = true; + this.setState('changed'); } protected override reset(): void { @@ -193,6 +245,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { this.endHold(); this.pressTouchId = null; this.press = null; + this.latestMove = null; this.isMenuDue = false; } } From 7d23141f22db7feec950d08efe70302a356def79 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 13:13:11 -0800 Subject: [PATCH 27/50] fix(ContextMenu): the trigger's visual takes the trigger's height In block layout the inner visual box took its content's height, so a 100% tall child of a set-height trigger collapsed to its text. The box now fills the trigger in block layout too, as it does in flex and grid. --- packages/core/src/ContextMenu/ContextMenu.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 7ec64ec0ede47..3f84a40375ef4 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -143,9 +143,10 @@ const styles = stylex.create({ userSelect: 'none', }, // The trigger's own visual, which the press feedback grows. It lays its - // children out as the trigger would, and fills the trigger in a flex or - // grid layout. The cursor anchor stays outside it, so a layer never takes - // the press's scale. + // children out as the trigger would, and fills the trigger in block, flex + // and grid layout. A full-height child then keeps a set-height trigger's + // height. The cursor anchor stays outside it, so a layer never takes the + // press's scale. triggerVisual: { display: 'inherit', flexDirection: 'inherit', @@ -167,6 +168,7 @@ const styles = stylex.create({ gridColumn: '1 / -1', gridRow: '1 / -1', minWidth: 0, + height: '100%', borderRadius: 'inherit', }, // 1px anchor placed at the cursor point within the trigger. The menu is From a75cf0914f6b23a0afe614b0cd78f24f6bdb18ee Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 13:13:11 -0800 Subject: [PATCH 28/50] test(ContextMenu): the inner visual box adds no attribute The trigger keeps its own attributes, the inner box carries only its class, and the cursor anchor keeps its attributes. --- .../ContextMenu.triggerProps.test.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx b/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx index e384ddc50099c..146b5d288debb 100644 --- a/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx @@ -4,7 +4,8 @@ * @file ContextMenu.triggerProps.test.tsx * @input vitest, @testing-library/react, ContextMenu * @output A guard that the trigger element receives only DOM props: React - * warns about nothing, and no hook-internal function lands on it. + * warns about nothing, and no hook-internal function lands on it. The + * inner visual box adds no attribute to the trigger. * @position Testing; validates ContextMenu.tsx. Its own file on purpose: React * warns about an unknown DOM prop once per module, so this case must be the * first ContextMenu render of its file to see the warning at all. @@ -51,4 +52,26 @@ describe('ContextMenu trigger element', () => { consoleError.mockRestore(); } }); + + it('keeps its own attributes: the inner visual box carries only its class', () => { + render( + +
Right-click me
+
, + ); + const trigger = screen.getByTestId('block'); + expect(trigger.getAttributeNames().sort()).toEqual([ + 'class', + 'data-testid', + ]); + const [visual, anchor] = Array.from(trigger.children); + expect(visual.tagName).toBe('DIV'); + expect(visual.getAttributeNames()).toEqual(['class']); + expect(visual.textContent).toBe('Right-click me'); + expect(anchor.getAttributeNames().sort()).toEqual([ + 'aria-hidden', + 'class', + 'style', + ]); + }); }); From 3b76a8c589d83265097166bdeec7de82c600e618 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 13:32:33 -0800 Subject: [PATCH 29/50] test(ContextMenu): a finger inside the tolerance at the menu's moment A finger still inside 11.5 px at 735 ms that crosses it just after keeps its menu: the tolerance holds until the menu shows. --- ...textMenuLongPress.interaction.browser.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index b9da7f55e6741..e47db738395de 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -388,6 +388,22 @@ test('a slow drift past 11.5 px late in the hold cancels it: no menu and no clic } }); +test('a finger still inside 11.5 px at the menu’s moment that crosses it just after still gets the menu (AST-094 FR12)', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + // 14 px over 650–760 ms: 10.6 px at the last move before 735 ms, 12.7 px + // at the first move after it. + await driver.play( + drag( + center, + {x: center.x + 14, y: center.y}, + {holdMs: 650, durationMs: 110, restMs: 300}, + ), + ); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); +}); + test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ page, }) => { From 88cc0d8742431d162b5457a19079a978f4a0f22c Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 13:52:17 -0800 Subject: [PATCH 30/50] fix(ContextMenu): the press reads each move's own time from the arena The gesture arena holds a late input's time at its clock, so a move that reached the page after the menu's moment looked as if it came at that moment. The arena now also keeps the time the input came with, as the touch's inputTime, and no other recognizer reads it. The press reads it: while the menu waits at its moment, a drift in a move from before the moment ends the hold, and a move from the moment on shows the menu. ContextMenu's own pointer listeners and clock copy are gone. --- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 63 ------------------- .../src/ContextMenu/contextMenuPress.test.ts | 56 ++++++++++++++--- .../core/src/ContextMenu/contextMenuPress.ts | 27 ++++---- packages/core/src/gestures/GestureArena.ts | 3 + .../core/src/gestures/__tests__/arena.test.ts | 21 ++++++- packages/core/src/gestures/types.ts | 6 ++ 6 files changed, 89 insertions(+), 87 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index d3a8a7a1be0d0..a42432403acf2 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -16,8 +16,6 @@ import {useImperativeHandle, useRef, type Ref, type RefObject} from 'react'; import {flushSync} from 'react-dom'; import {playHaptic} from '../gestures/haptics'; -import {useAttachment} from '../hooks/useAttachment'; -import {subscribeInput} from '../utils/inputStream'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; import {ContextMenuPressRecognizer} from './ContextMenuTouch'; @@ -43,23 +41,6 @@ export interface ContextMenuTouchLayerProps { onCancel?: () => void; } -/** - * An event's time on the gesture arena's clock, as the gesture host reads it. - * That is its `timeStamp`, which some engines and DOM emulators give from the - * epoch instead of from the page's time origin. - */ -function clockTime(event: Event): number { - const time = event.timeStamp; - if ( - time > 1e12 && - typeof performance !== 'undefined' && - performance.timeOrigin > 0 - ) { - return time - performance.timeOrigin; - } - return time; -} - /** * UIContextMenuInteraction's long press. iOS Safari never fires `contextmenu` * for a held finger. So the trigger runs its own recognizer on UIKit's @@ -136,50 +117,6 @@ export function ContextMenuTouchLayer({ }, ); - // Under load, the held finger's moves can reach the page after the menu's - // moment, though they came before it. The recognizer reads each move's own - // time here, so the menu waits for them, and a drift in them still ends the - // hold. - useAttachment( - triggerRef, - element => { - const view = element.ownerDocument.defaultView; - if (view == null) { - return () => {}; - } - let pointerId: number | null = null; - const onDown = (event: PointerEvent) => { - if (event.pointerType === 'touch' && event.isPrimary) { - pointerId = event.pointerId; - } - }; - const onMove = (event: PointerEvent) => { - if (event.pointerId !== pointerId) { - return; - } - recognizer.followMove(clockTime(event)); - }; - const onEnd = (event: PointerEvent) => { - if (event.pointerId === pointerId) { - pointerId = null; - } - }; - element.addEventListener('pointerdown', onDown, {capture: true}); - const unsubscribes = [ - subscribeInput(view, 'pointermove', onMove), - subscribeInput(view, 'pointerup', onEnd), - subscribeInput(view, 'pointercancel', onEnd), - ]; - return () => { - element.removeEventListener('pointerdown', onDown, {capture: true}); - for (const unsubscribe of unsubscribes) { - unsubscribe(); - } - }; - }, - recognizer, - ); - useImperativeHandle( ref, () => ({ diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index 493b40f0a515f..c0b9606c8ca0d 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -13,7 +13,7 @@ * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts */ -import {describe, expect, it} from 'vitest'; +import {describe, expect, it, vi} from 'vitest'; import {createHarness} from '../gestures/__tests__/harness'; import {contextMenu} from '../motion/uikit/contextMenu'; import {ContextMenuPressRecognizer} from './contextMenuPress'; @@ -102,29 +102,67 @@ describe('ContextMenuPressRecognizer', () => { const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); harness.advance(COMMIT); // The page has just handled a move from 450 ms: more may still come. - press.followMove(450); + finger.move(3, 0, 450); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); - // A late move from before the moment, 14 px over: the hold ends. - finger.move(14, 0, DISPLAY + 10); - expect(states).toEqual([`began@${COMMIT}`, `cancelled@${DISPLAY + 10}`]); + // A late move that came at 600 ms, 14 px over. The arena holds its time + // at the clock, and the recognizer reads the time it came with. + finger.move(14, 0, 600); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `cancelled@${DISPLAY}`, + ]); expect(press.isMenuDue).toBe(false); finger.up(DISPLAY + 300); }); - it('shows the menu once a move from its moment on reaches the page', () => { + it('shows the menu for a move stamped from its moment on, even past the tolerance, and lets the finger move freely after', () => { const {harness, press, states} = setup(); const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); harness.advance(COMMIT); - press.followMove(450); + finger.move(3, 0, 450); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); - press.followMove(DISPLAY + 5); + // Inside the tolerance at the moment, 14 px over 15 ms after it. + finger.move(14, 0, DISPLAY + 15); expect(press.isMenuDue).toBe(true); - expect(states.at(-1)).toBe(`changed@${DISPLAY}`); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `changed@${DISPLAY + 15}`, + `changed@${DISPLAY + 15}`, + ]); finger.up(DISPLAY + 300); }); + it('shows the menu once the late moves pause for 50 ms', () => { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 450); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + vi.advanceTimersByTime(49); + expect(press.isMenuDue).toBe(false); + vi.advanceTimersByTime(1); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); + } finally { + vi.useRealTimers(); + } + }); + + it('shows a still finger’s menu at its moment, with no wait', () => { + const {harness, press, states} = setup(); + harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + }); + it('lets the finger move freely once the menu is due', () => { const {harness, press, states} = setup(); const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index eec04013993e0..740d550e10e44 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -87,8 +87,8 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { /** Where the finger landed, while its hold runs. The drift counts from here. */ private holdFrom: {x: number; y: number} | null = null; /** - * The held finger's latest pointermove: when it came, on the event clock, - * and when the page got it. + * The held finger's latest move: the time it came with, and when the page + * got it. */ private latestMove: {time: number; arrivedAt: number} | null = null; /** When the finger landed, on the arena's clock. The deadlines count from it. */ @@ -140,6 +140,17 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { protected override touchesMoved(touches: ReadonlyArray): void { const touch = touches.find(each => each.id === this.pressTouchId); + if (touch != null) { + this.latestMove = {time: touch.inputTime, arrivedAt: performance.now()}; + // A move from the menu's moment on: the finger stayed inside the + // tolerance until then, so the menu shows, and it moves freely after. + if ( + this.isMenuWaiting && + touch.inputTime >= this.holdStart + menuDisplayMs + ) { + this.showMenu(); + } + } const hasDrifted = touch != null && !this.followHold(touch); if (hasDrifted && this.state === 'possible') { this.setState('failed'); @@ -152,18 +163,6 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { super.touchesMoved(touches); } - /** - * ContextMenu's touch layer reports each pointermove of the held finger: - * the time it came, on the event clock. A move from the menu's moment on - * means no late move can still come before it. - */ - followMove(time: number): void { - this.latestMove = {time, arrivedAt: performance.now()}; - if (this.isMenuWaiting && time >= this.holdStart + menuDisplayMs) { - this.showMenu(); - } - } - /** * Follows the finger during its hold. A drift past `allowableMovement` on * either axis ends the hold. Returns whether the hold still runs. diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index 62bf6d03374cd..edb5992a3b2d8 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -238,6 +238,7 @@ export class GestureArena { x: input.x, y: input.y, time, + inputTime: input.time, startX: input.x, startY: input.y, startTime: time, @@ -328,6 +329,7 @@ export class GestureArena { x: input.x, y: input.y, time, + inputTime: input.time, startX: input.x, startY: input.y, startTime: time, @@ -993,6 +995,7 @@ export class GestureArena { touch.x = input.x; touch.y = input.y; touch.time = time; + touch.inputTime = input.time; touch.phase = phase; changed.push(touch); } diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index d5a724e20862d..72bad1a344e67 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -10,7 +10,8 @@ * requirements, idle recognizers), shouldBegin and shouldReceiveTouch, * innermost resolution, independent touches, delivery to the view (claims, * cancelsTouchesInView, delaysTouchesBegan, deferred and replayed clicks, - * the browser taking a touch), cancellation, and the event clock + * the browser taking a touch), cancellation, and the event clock, which + * holds a late input at its time and keeps the time the input came with * @position Unit tests of packages/core/src/gestures/GestureArena.ts and the * state machine of GestureRecognizer.ts */ @@ -402,6 +403,24 @@ describe('arena: cancellation and time', () => { h.finger(1).move(5, 0, 90); expect(h.log).toEqual(['r:began@100']); }); + it('holds a late input at the clock, and keeps the time it came with', () => { + const h = createHarness(); + const seen: {time: number; inputTime: number}[] = []; + const r = new Scripted('r', { + moved: recognizer => { + const [touch] = recognizer.trackedTouches; + seen.push({time: touch.time, inputTime: touch.inputTime}); + }, + }); + h.finger(1).down(0, 0, 100, {recognizers: [r]}); + h.advance(500); + h.finger(1).move(5, 0, 300); + h.finger(1).move(6, 0, 520); + expect(seen).toEqual([ + {time: 500, inputTime: 300}, + {time: 520, inputTime: 520}, + ]); + }); }); /** A clock whose timers run one task at a time, as an event loop does. */ diff --git a/packages/core/src/gestures/types.ts b/packages/core/src/gestures/types.ts index ef646cecde9d2..38c510eca9e33 100644 --- a/packages/core/src/gestures/types.ts +++ b/packages/core/src/gestures/types.ts @@ -81,6 +81,12 @@ export interface GestureTouch { y: number; /** Timestamp of the latest position. */ time: number; + /** + * The latest position's own timestamp, as the host read it. The arena's + * clock never runs back, so a late input's `time` is held at the clock; + * this field keeps the time the input came with. + */ + inputTime: number; readonly startX: number; readonly startY: number; readonly startTime: number; From 46bc0da3ad1744aecb662d10f8f182544213a569 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 14:58:48 -0800 Subject: [PATCH 31/50] test(ContextMenu): coalesced late samples and an on-time move A late move whose coalesced samples cross 11.5 px before the menu's moment ends the hold, as the same samples as separate moves do. A finger that shifts slightly just before 735 ms, with its move on time, gets the menu at 735 ms. A late timer after a recent move still waits. The test harness's finger moves can carry coalesced samples. --- ...tMenuLongPress.interaction.browser.spec.ts | 20 ++ .../src/ContextMenu/contextMenuPress.test.ts | 175 ++++++++++++++---- .../core/src/gestures/__tests__/harness.ts | 17 +- 3 files changed, 170 insertions(+), 42 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index e47db738395de..f866dc632b4b0 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -245,6 +245,26 @@ test('a finger still down gets the menu at 735 ms (iOS 26.2), not before', async await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeVisible(); }); +test('a finger that shifts slightly just before 735 ms gets the menu at 735 ms when its move reaches the page on time (AST-094 FR12)', async ({ + page, +}) => { + const {driver, center} = await openTouchStory(page); + // One 3 px move at 700 ms, then still. An idle page gets the move at once. + await driver.play( + drag( + center, + {x: center.x + 3, y: center.y}, + {holdMs: 699, durationMs: 1, restMs: 300}, + ), + ); + const start = await touchStartedAt(page); + const shown = await menuShownAt(page); + expect(shown, 'the menu never showed').not.toBeNull(); + const after = (shown as number) - start; + expect(after).toBeGreaterThanOrEqual(menuDisplayMs); + expect(after).toBeLessThan(menuDisplayMs + 20); +}); + test('the menu mounts at the commit, closed and out of the accessibility tree, and shows at 735 ms', async ({ page, }) => { diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index c0b9606c8ca0d..577f1a8fe4d06 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -33,6 +33,20 @@ function setup( return {harness, press, states}; } +/** + * Runs `body` with the page's clock (`performance.now()`) and window timers + * faked. `at(time)` moves the page's clock to when the page gets the next + * input, which may be after the time the input came with. + */ +function withPageClock(body: (at: (time: number) => void) => void): void { + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + try { + body(time => vi.advanceTimersByTime(time - performance.now())); + } finally { + vi.useRealTimers(); + } +} + describe('ContextMenuPressRecognizer', () => { it('commits at UIKit’s moment (iOS 26.2: 400 ms, first 60 Hz tick 417 ms), not a millisecond before', () => { const {harness, press, states} = setup(); @@ -98,51 +112,101 @@ describe('ContextMenuPressRecognizer', () => { }); it('waits at the menu’s moment for moves that came before it but reach the page late, and a drift in them cancels', () => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - // The page has just handled a move from 450 ms: more may still come. - finger.move(3, 0, 450); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // A late move that came at 600 ms, 14 px over. The arena holds its time - // at the clock, and the recognizer reads the time it came with. - finger.move(14, 0, 600); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@450', - `cancelled@${DISPLAY}`, - ]); - expect(press.isMenuDue).toBe(false); - finger.up(DISPLAY + 300); + withPageClock(at => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // The page gets a move from 450 ms only at 700 ms: it is behind. + at(700); + finger.move(3, 0, 450); + at(DISPLAY); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // A late move that came at 600 ms, 14 px over. The arena holds its time + // at the clock, and the recognizer reads the time it came with. + at(DISPLAY + 10); + finger.move(14, 0, 600); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `cancelled@${DISPLAY}`, + ]); + expect(press.isMenuDue).toBe(false); + finger.up(DISPLAY + 300); + }); }); it('shows the menu for a move stamped from its moment on, even past the tolerance, and lets the finger move freely after', () => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - finger.move(3, 0, 450); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // Inside the tolerance at the moment, 14 px over 15 ms after it. - finger.move(14, 0, DISPLAY + 15); - expect(press.isMenuDue).toBe(true); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@450', - `changed@${DISPLAY + 15}`, - `changed@${DISPLAY + 15}`, - ]); - finger.up(DISPLAY + 300); + withPageClock(at => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + at(700); + finger.move(3, 0, 450); + at(DISPLAY); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // Inside the tolerance at the moment, 14 px over 15 ms after it. + at(DISPLAY + 20); + finger.move(14, 0, DISPLAY + 15); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `changed@${DISPLAY + 15}`, + `changed@${DISPLAY + 15}`, + ]); + finger.up(DISPLAY + 300); + }); + }); + + it('judges each coalesced sample of a late move at its own time while the menu waits', () => { + withPageClock(at => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + at(700); + finger.move(3, 0, 450); + at(DISPLAY); + harness.advance(DISPLAY); + // One late move with two samples: 13 px at 725 ms, before the moment, + // then 14 px at 740 ms. Two separate moves would end the hold at the + // first, so this one does too. + at(DISPLAY + 20); + finger.move(14, 0, DISPLAY + 5, [{x: 13, y: 0, time: DISPLAY - 10}]); + expect(press.isMenuDue).toBe(false); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `cancelled@${DISPLAY + 5}`, + ]); + }); + }); + + it('shows the waiting menu for a coalesced move whose samples before the moment stay inside the tolerance', () => { + withPageClock(at => { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + at(700); + finger.move(3, 0, 450); + at(DISPLAY); + harness.advance(DISPLAY); + at(DISPLAY + 20); + finger.move(14, 0, DISPLAY + 5, [{x: 10, y: 0, time: DISPLAY - 10}]); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); + }); }); it('shows the menu once the late moves pause for 50 ms', () => { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); - try { + withPageClock(at => { const {harness, press} = setup(); const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); harness.advance(COMMIT); + at(700); finger.move(3, 0, 450); + at(DISPLAY); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); vi.advanceTimersByTime(49); @@ -150,9 +214,44 @@ describe('ContextMenuPressRecognizer', () => { vi.advanceTimersByTime(1); expect(press.isMenuDue).toBe(true); finger.up(DISPLAY + 300); - } finally { - vi.useRealTimers(); - } + }); + }); + + it('shows the menu at its moment after a move that reached the page on time, with no wait', () => { + withPageClock(at => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // A 3 px shift at 720 ms that the page gets at once: it is not behind. + at(720); + finger.move(3, 0, 720); + at(DISPLAY); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@720', + `changed@${DISPLAY}`, + ]); + }); + }); + + it('waits at the menu’s moment when its own timer runs late after a recent move', () => { + withPageClock(at => { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + at(730); + finger.move(3, 0, 730); + // The page is busy: the moment's timer runs 40 ms late, so later moves + // may still be queued. + at(DISPLAY + 40); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + vi.advanceTimersByTime(50); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); + }); }); it('shows a still finger’s menu at its moment, with no wait', () => { diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index 37f8796de614d..5b9c46c2681ce 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -5,8 +5,8 @@ * @input Nothing * @output Exports the synthetic-input harness the gesture tests share: a * manual clock, an arena wired to a transition log, fingers that land, - * move and lift at explicit timestamps, and a coarse primary pointer for - * tests that go through the document's host + * move (with coalesced samples) and lift at explicit timestamps, and a + * coarse primary pointer for tests that go through the document's host * @position Test-only helper of packages/core/src/gestures (excluded from the * build with the rest of __tests__) */ @@ -18,6 +18,7 @@ import type { GestureTouch, NativeScrollProbe, TouchConsumer, + TouchSample, } from '../types'; /** @@ -189,12 +190,20 @@ export class Finger { return this; } - move(x: number, y: number, time: number): this { + /** `history`: the coalesced samples before this one, oldest first. */ + move( + x: number, + y: number, + time: number, + history?: ReadonlyArray, + ): this { this.x = x; this.y = y; this.time = time; this.harness.clock.time = time; - this.harness.arena.moved([{id: this.id, x, y, time}]); + this.harness.arena.moved([ + {id: this.id, x, y, time, ...(history == null ? {} : {history})}, + ]); return this; } From cad79ec4d6bc5ffee6d487bfd86ea5ed4d37d7ac Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 15:36:45 -0800 Subject: [PATCH 32/50] fix(ContextMenu): judge each sample at its time, wait only when late The gesture arena now keeps each coalesced sample of a move at the time it came with, as the touch's inputSamples, and no other recognizer reads them. The press follows the finger through each of them: a drift past 11.5 px before the menu's moment ends the hold, and a sample from the moment on shows a waiting menu. The menu waits at its moment only when the page is behind its input: a recent move from before the moment, or the moment's timer, ran more than two frames late. The wait runs on the arena's clock, like the hold's deadlines. An on-time finger gets the menu at 735 ms. --- .../src/ContextMenu/contextMenuPress.test.ts | 19 ++-- .../core/src/ContextMenu/contextMenuPress.ts | 100 +++++++++++------- packages/core/src/gestures/GestureArena.ts | 9 ++ .../core/src/gestures/__tests__/arena.test.ts | 26 ++++- packages/core/src/gestures/types.ts | 6 ++ 5 files changed, 114 insertions(+), 46 deletions(-) diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index 577f1a8fe4d06..290cd0cbc48eb 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -34,12 +34,12 @@ function setup( } /** - * Runs `body` with the page's clock (`performance.now()`) and window timers - * faked. `at(time)` moves the page's clock to when the page gets the next - * input, which may be after the time the input came with. + * Runs `body` with the page's clock (`performance.now()`) faked. `at(time)` + * moves it to when the page gets the next input, which may be after the time + * the input came with. */ function withPageClock(body: (at: (time: number) => void) => void): void { - vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'performance']}); + vi.useFakeTimers({toFake: ['performance']}); try { body(time => vi.advanceTimersByTime(time - performance.now())); } finally { @@ -209,9 +209,12 @@ describe('ContextMenuPressRecognizer', () => { at(DISPLAY); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); - vi.advanceTimersByTime(49); + // The wait runs on the arena's clock. + at(DISPLAY + 49); + harness.advance(DISPLAY + 49); expect(press.isMenuDue).toBe(false); - vi.advanceTimersByTime(1); + at(DISPLAY + 50); + harness.advance(DISPLAY + 50); expect(press.isMenuDue).toBe(true); finger.up(DISPLAY + 300); }); @@ -248,7 +251,9 @@ describe('ContextMenuPressRecognizer', () => { at(DISPLAY + 40); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); - vi.advanceTimersByTime(50); + // No more moves came: the wait ends 50 ms on, on the arena's clock. + at(DISPLAY + 90); + harness.advance(DISPLAY + 50); expect(press.isMenuDue).toBe(true); finger.up(DISPLAY + 300); }); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index 740d550e10e44..344c007e6cbf9 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -29,6 +29,12 @@ const {commitMs, menuDisplayMs, allowableMovement} = contextMenu.press; */ const LATE_MOVE_GAP_MS = 50; +/** + * The page counts as behind its input when an input or a timer runs this long + * after its own time. In ms: two frames at 60 Hz. + */ +const LATE_INPUT_MS = 33; + /** * The foundation's own press timer, which this recognizer never lets fire: * the hold below commits instead. It is longer than any hold, and under the @@ -95,7 +101,6 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { private holdStart = 0; /** The menu's moment passed while the finger's moves still came late. */ private isMenuWaiting = false; - private moveWait: ReturnType | null = null; constructor(options: ContextMenuPressRecognizerOptions = {}) { super({ @@ -142,14 +147,6 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { const touch = touches.find(each => each.id === this.pressTouchId); if (touch != null) { this.latestMove = {time: touch.inputTime, arrivedAt: performance.now()}; - // A move from the menu's moment on: the finger stayed inside the - // tolerance until then, so the menu shows, and it moves freely after. - if ( - this.isMenuWaiting && - touch.inputTime >= this.holdStart + menuDisplayMs - ) { - this.showMenu(); - } } const hasDrifted = touch != null && !this.followHold(touch); if (hasDrifted && this.state === 'possible') { @@ -164,22 +161,31 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { } /** - * Follows the finger during its hold. A drift past `allowableMovement` on - * either axis ends the hold. Returns whether the hold still runs. + * Follows the finger during its hold, through each sample of the move at + * the time it came with. A sample from the menu's moment on shows a + * waiting menu: the finger stayed inside the tolerance until then, and it + * moves freely after. A drift past `allowableMovement` on either axis ends + * the hold. Returns whether the hold still runs. */ private followHold(touch: GestureTouch): boolean { - const from = this.holdFrom; - if (from == null) { - return false; - } - if ( - Math.abs(touch.x - from.x) > allowableMovement || - Math.abs(touch.y - from.y) > allowableMovement - ) { - this.endHold(); - return false; + const moment = this.holdStart + menuDisplayMs; + for (const sample of touch.inputSamples) { + const from = this.holdFrom; + if (from == null) { + return false; + } + if (this.isMenuWaiting && sample.time >= moment) { + this.presentMenu(); + } + if ( + Math.abs(sample.x - from.x) > allowableMovement || + Math.abs(sample.y - from.y) > allowableMovement + ) { + this.endHold(); + return false; + } } - return true; + return this.holdFrom != null; } private endHold(): void { @@ -189,10 +195,9 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { } private stopMoveWait(): void { - this.isMenuWaiting = false; - if (this.moveWait != null) { - clearTimeout(this.moveWait); - this.moveWait = null; + if (this.isMenuWaiting) { + this.isMenuWaiting = false; + this.clearTimer(); } } @@ -215,30 +220,49 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { } /** - * The finger is still down at the menu's moment: the menu is due. Under - * load, the finger's moves can reach the page after that moment, though - * they came before it. While they still come, the menu waits for them, so - * a drift in them still ends the hold. + * The finger is still down at the menu's moment: the menu is due. When the + * page is behind its input, the finger's moves can reach it after that + * moment, though they came before it. While they still come, the menu + * waits for them, so a drift in them still ends the hold. */ private showMenu(): void { this.stopMoveWait(); - if (this.holdFrom == null || !isActive(this.state)) { + if (this.holdFrom == null || !isActive(this.state) || this.isMenuDue) { return; } - const move = this.latestMove; - if ( - move != null && - move.time < this.holdStart + menuDisplayMs && - performance.now() - move.arrivedAt < LATE_MOVE_GAP_MS - ) { + if (this.isBehindInput()) { this.isMenuWaiting = true; - this.moveWait = setTimeout(() => this.showMenu(), LATE_MOVE_GAP_MS); + // On the arena's clock, like the hold's own deadlines. + this.setTimer(this.now() + LATE_MOVE_GAP_MS, () => this.showMenu()); return; } + this.presentMenu(); + } + + private presentMenu(): void { + this.stopMoveWait(); this.isMenuDue = true; this.setState('changed'); } + /** + * Whether the page may still hold moves from before the menu's moment. A + * move from before it reached the page in the last `LATE_MOVE_GAP_MS`, and + * that move or the moment's timer ran more than `LATE_INPUT_MS` late. + */ + private isBehindInput(): boolean { + const move = this.latestMove; + const moment = this.holdStart + menuDisplayMs; + const now = performance.now(); + return ( + move != null && + move.time < moment && + now - move.arrivedAt < LATE_MOVE_GAP_MS && + (move.arrivedAt - move.time > LATE_INPUT_MS || + now - moment > LATE_INPUT_MS) + ); + } + protected override reset(): void { super.reset(); this.endHold(); diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index edb5992a3b2d8..d9a353fad7ab1 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -239,6 +239,10 @@ export class GestureArena { y: input.y, time, inputTime: input.time, + inputSamples: [ + ...(input.history ?? []), + {x: input.x, y: input.y, time: input.time}, + ], startX: input.x, startY: input.y, startTime: time, @@ -330,6 +334,7 @@ export class GestureArena { y: input.y, time, inputTime: input.time, + inputSamples: [{x: input.x, y: input.y, time: input.time}], startX: input.x, startY: input.y, startTime: time, @@ -996,6 +1001,10 @@ export class GestureArena { touch.y = input.y; touch.time = time; touch.inputTime = input.time; + touch.inputSamples = [ + ...(input.history ?? []), + {x: input.x, y: input.y, time: input.time}, + ]; touch.phase = phase; changed.push(touch); } diff --git a/packages/core/src/gestures/__tests__/arena.test.ts b/packages/core/src/gestures/__tests__/arena.test.ts index 72bad1a344e67..13f798c14339b 100644 --- a/packages/core/src/gestures/__tests__/arena.test.ts +++ b/packages/core/src/gestures/__tests__/arena.test.ts @@ -11,7 +11,8 @@ * innermost resolution, independent touches, delivery to the view (claims, * cancelsTouchesInView, delaysTouchesBegan, deferred and replayed clicks, * the browser taking a touch), cancellation, and the event clock, which - * holds a late input at its time and keeps the time the input came with + * holds a late input at its time and keeps the times the input and its + * coalesced samples came with * @position Unit tests of packages/core/src/gestures/GestureArena.ts and the * state machine of GestureRecognizer.ts */ @@ -421,6 +422,29 @@ describe('arena: cancellation and time', () => { {time: 520, inputTime: 520}, ]); }); + it('keeps a late move’s coalesced samples at the times they came with', () => { + const h = createHarness(); + const seen: {samples: number[]; inputSamples: number[]}[] = []; + const r = new Scripted('r', { + moved: recognizer => { + const [touch] = recognizer.trackedTouches; + seen.push({ + samples: touch.samples.map(sample => sample.time), + inputSamples: touch.inputSamples.map(sample => sample.time), + }); + }, + }); + h.finger(1).down(0, 0, 100, {recognizers: [r]}); + h.advance(500); + h.finger(1).move(5, 0, 300); + // Both late: the clock holds them at 500 ms, and `samples` drops the + // earlier one, which comes before the touch's held time. + h.finger(1).move(7, 0, 320, [{x: 6, y: 0, time: 310}]); + expect(seen).toEqual([ + {samples: [500], inputSamples: [300]}, + {samples: [500], inputSamples: [310, 320]}, + ]); + }); }); /** A clock whose timers run one task at a time, as an event loop does. */ diff --git a/packages/core/src/gestures/types.ts b/packages/core/src/gestures/types.ts index 38c510eca9e33..598501a78456c 100644 --- a/packages/core/src/gestures/types.ts +++ b/packages/core/src/gestures/types.ts @@ -87,6 +87,12 @@ export interface GestureTouch { * this field keeps the time the input came with. */ inputTime: number; + /** + * The latest event's positions at the times they came with, oldest first, + * ending with the current one at `inputTime`. The arena's clock filters + * `samples`; it does not filter these. + */ + inputSamples: TouchSample[]; readonly startX: number; readonly startY: number; readonly startTime: number; From 889adedc07ec9a26417eda437334fc530134f9d4 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 16:07:28 -0800 Subject: [PATCH 33/50] test(ContextMenu): moves held behind a block decide before the menu A finger that crosses 11.5 px at 650 ms while the page is blocked past the menu's moment, until 760, 800 or 900 ms, ends the hold when its coalesced move arrives, as separate moves do. A still finger behind a block gets its menu once no move comes. Every case fakes the page's clock from 0, and the on-time browser case checks that the moment's own timer task shows the menu. --- ...tMenuLongPress.interaction.browser.spec.ts | 9 +++ .../src/ContextMenu/contextMenuPress.test.ts | 64 ++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index f866dc632b4b0..df976edbcfc5b 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -249,6 +249,7 @@ test('a finger that shifts slightly just before 735 ms gets the menu at 735 ms w page, }) => { const {driver, center} = await openTouchStory(page); + await startFrameLog(page); // One 3 px move at 700 ms, then still. An idle page gets the move at once. await driver.play( drag( @@ -257,9 +258,17 @@ test('a finger that shifts slightly just before 735 ms gets the menu at 735 ms w {holdMs: 699, durationMs: 1, restMs: 300}, ), ); + const frames = await readFrameLog(page); const start = await touchStartedAt(page); const shown = await menuShownAt(page); + const decided = await menuShownTimerAt(page); expect(shown, 'the menu never showed').not.toBeNull(); + // The moment's own timer task shows the menu: nothing waits. + expect( + decided, + 'the menu showed after the task that decided it', + ).not.toBeNull(); + expectDecidedOnTime(frames, start + menuDisplayMs, decided as number); const after = (shown as number) - start; expect(after).toBeGreaterThanOrEqual(menuDisplayMs); expect(after).toBeLessThan(menuDisplayMs + 20); diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index 290cd0cbc48eb..81d0e7eecaa3c 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -13,7 +13,7 @@ * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts */ -import {describe, expect, it, vi} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {createHarness} from '../gestures/__tests__/harness'; import {contextMenu} from '../motion/uikit/contextMenu'; import {ContextMenuPressRecognizer} from './contextMenuPress'; @@ -34,20 +34,23 @@ function setup( } /** - * Runs `body` with the page's clock (`performance.now()`) faked. `at(time)` - * moves it to when the page gets the next input, which may be after the time - * the input came with. + * Runs `body` with the page's clock (`performance.now()`), which every case + * fakes from 0. `at(time)` moves it to when the page gets the next input, + * which may be after the time the input came with. */ function withPageClock(body: (at: (time: number) => void) => void): void { - vi.useFakeTimers({toFake: ['performance']}); - try { - body(time => vi.advanceTimersByTime(time - performance.now())); - } finally { - vi.useRealTimers(); - } + body(time => vi.advanceTimersByTime(time - performance.now())); } describe('ContextMenuPressRecognizer', () => { + // The page's clock starts with the harness's, so no case runs late by it. + beforeEach(() => { + vi.useFakeTimers({toFake: ['performance']}); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('commits at UIKit’s moment (iOS 26.2: 400 ms, first 60 Hz tick 417 ms), not a millisecond before', () => { const {harness, press, states} = setup(); harness.finger(1).down(100, 200, 1000, {recognizers: [press]}); @@ -199,6 +202,47 @@ describe('ContextMenuPressRecognizer', () => { }); }); + for (const blockedUntil of [760, 800, 900]) { + it(`judges a coalesced move from behind a block until ${blockedUntil} ms before the menu shows`, () => { + withPageClock(at => { + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // On time until 640 ms. Then the page is blocked past the moment. + at(640); + finger.move(3, 0, 640); + // The finger crossed 11.5 px at 650 ms. Its moves wait behind the + // block and come as one coalesced move. + at(blockedUntil); + finger.move(14, 0, blockedUntil - 5, [ + {x: 13, y: 0, time: 650}, + {x: 14, y: 0, time: 700}, + ]); + expect(press.isMenuDue).toBe(false); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@640', + `cancelled@${blockedUntil - 5}`, + ]); + }); + }); + } + + it('shows a still finger’s menu after a block, once no move comes from behind it', () => { + withPageClock(at => { + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + // The moment's timer runs 65 ms late behind a block. + at(DISPLAY + 65); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + at(DISPLAY + 115); + harness.advance(DISPLAY + 50); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); + }); + }); + it('shows the menu once the late moves pause for 50 ms', () => { withPageClock(at => { const {harness, press} = setup(); From 45bfb443caab5da2ec78aa94cb81d235679abb38 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 16:16:26 -0800 Subject: [PATCH 34/50] fix(ContextMenu): a late menu timer waits for moves behind the block When the menu's timer runs more than a frame late, the page was blocked, and the finger's moves from before the moment may still be queued. The menu then waits up to 50 ms, counted from when the page runs again, so those moves are judged at their own times first. On an idle page the timer runs on time, and nothing waits. The record's FR16 gap line states the shift and its 50 ms bound. --- .../specs/AST-094-touch-presentations/spec.md | 5 ++ .../src/ContextMenu/contextMenuPress.test.ts | 6 +- .../core/src/ContextMenu/contextMenuPress.ts | 57 ++++++++++++++----- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index f34804ca76a50..201c4232848e3 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -1036,6 +1036,11 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and - FR6 gap: under a starved compositor, Chromium can show a frame 40 to 60 ms after the main-thread frame that builds it. A retarget then first shows that far into its curve: up to 6.5 px on a loaded Switch. +- FR16 gap: ContextMenu's long press keeps its outcome on a blocked main + thread, but its menu can show later. When the menu's timer runs more than a + frame late, the menu waits up to 50 ms for moves still queued. A move from + before the moment that reaches the page more than two frames late holds it + the same way. - Consumer docs: none until a component ships its touch presentation; each component documents its own, including the `presentation` value that keeps the released surface (FR25). diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index 81d0e7eecaa3c..b2f979513a0e8 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -237,7 +237,7 @@ describe('ContextMenuPressRecognizer', () => { harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); at(DISPLAY + 115); - harness.advance(DISPLAY + 50); + harness.advance(DISPLAY + 115); expect(press.isMenuDue).toBe(true); finger.up(DISPLAY + 300); }); @@ -295,9 +295,9 @@ describe('ContextMenuPressRecognizer', () => { at(DISPLAY + 40); harness.advance(DISPLAY); expect(press.isMenuDue).toBe(false); - // No more moves came: the wait ends 50 ms on, on the arena's clock. + // No more moves came: the wait ends 50 ms after the late timer ran. at(DISPLAY + 90); - harness.advance(DISPLAY + 50); + harness.advance(DISPLAY + 90); expect(press.isMenuDue).toBe(true); finger.up(DISPLAY + 300); }); diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index 344c007e6cbf9..2084d06856f76 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -35,6 +35,13 @@ const LATE_MOVE_GAP_MS = 50; */ const LATE_INPUT_MS = 33; +/** + * The menu's timer ran behind a blocked page when it runs this long after its + * deadline. In ms: one frame at 60 Hz. A timer has no dispatch delay of its + * own, so one frame is enough. + */ +const LATE_TIMER_MS = 17; + /** * The foundation's own press timer, which this recognizer never lets fire: * the hold below commits instead. It is longer than any hold, and under the @@ -223,22 +230,47 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { * The finger is still down at the menu's moment: the menu is due. When the * page is behind its input, the finger's moves can reach it after that * moment, though they came before it. While they still come, the menu - * waits for them, so a drift in them still ends the hold. + * waits for them, so a drift in them still ends the hold. A timer that ran + * late ran behind a blocked page, which may hold such moves too. */ private showMenu(): void { + if (!this.isMenuPending()) { + return; + } + const moment = this.holdStart + menuDisplayMs; + if (performance.now() - moment > LATE_TIMER_MS || this.hasLateMove()) { + this.waitForMoves(); + return; + } + this.presentMenu(); + } + + /** The wait's end: moves that still come late keep the menu waiting. */ + private endMoveWait(): void { this.stopMoveWait(); - if (this.holdFrom == null || !isActive(this.state) || this.isMenuDue) { + if (!this.isMenuPending()) { return; } - if (this.isBehindInput()) { - this.isMenuWaiting = true; - // On the arena's clock, like the hold's own deadlines. - this.setTimer(this.now() + LATE_MOVE_GAP_MS, () => this.showMenu()); + if (this.hasLateMove()) { + this.waitForMoves(); return; } this.presentMenu(); } + private isMenuPending(): boolean { + return this.holdFrom != null && isActive(this.state) && !this.isMenuDue; + } + + private waitForMoves(): void { + this.isMenuWaiting = true; + // On the arena's clock, like the hold's own deadlines. It counts from when + // the page runs again. Behind a block, the event clock still reads the + // moment, and moves stamped after it are still queued. + const from = Math.max(this.now(), performance.now()); + this.setTimer(from + LATE_MOVE_GAP_MS, () => this.endMoveWait()); + } + private presentMenu(): void { this.stopMoveWait(); this.isMenuDue = true; @@ -246,20 +278,17 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { } /** - * Whether the page may still hold moves from before the menu's moment. A - * move from before it reached the page in the last `LATE_MOVE_GAP_MS`, and - * that move or the moment's timer ran more than `LATE_INPUT_MS` late. + * Whether a move from before the menu's moment reached the page in the last + * `LATE_MOVE_GAP_MS`, more than `LATE_INPUT_MS` after its own time. */ - private isBehindInput(): boolean { + private hasLateMove(): boolean { const move = this.latestMove; - const moment = this.holdStart + menuDisplayMs; const now = performance.now(); return ( move != null && - move.time < moment && + move.time < this.holdStart + menuDisplayMs && now - move.arrivedAt < LATE_MOVE_GAP_MS && - (move.arrivedAt - move.time > LATE_INPUT_MS || - now - moment > LATE_INPUT_MS) + move.arrivedAt - move.time > LATE_INPUT_MS ); } From d7439227a9522e8b3c1c604c5a0a1972ded277df Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 17:14:58 -0800 Subject: [PATCH 35/50] test(ContextMenu): the press's page time comes from the arena's clock The press's units drive the page's time with the gesture arena's own injected clock, the test harness's manual clock, instead of faking performance.now(). A move can reach the page after its own time, and a blocked page runs the menu's timer late on that clock. A blocked main thread until 900 ms now shows the still finger's menu at 950 ms. --- .../src/ContextMenu/contextMenuPress.test.ts | 317 ++++++++---------- .../core/src/gestures/__tests__/harness.ts | 20 +- 2 files changed, 153 insertions(+), 184 deletions(-) diff --git a/packages/core/src/ContextMenu/contextMenuPress.test.ts b/packages/core/src/ContextMenu/contextMenuPress.test.ts index b2f979513a0e8..324e11d6e7a12 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.test.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.test.ts @@ -13,7 +13,7 @@ * @position Unit tests of packages/core/src/ContextMenu/contextMenuPress.ts */ -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {describe, expect, it} from 'vitest'; import {createHarness} from '../gestures/__tests__/harness'; import {contextMenu} from '../motion/uikit/contextMenu'; import {ContextMenuPressRecognizer} from './contextMenuPress'; @@ -33,24 +33,7 @@ function setup( return {harness, press, states}; } -/** - * Runs `body` with the page's clock (`performance.now()`), which every case - * fakes from 0. `at(time)` moves it to when the page gets the next input, - * which may be after the time the input came with. - */ -function withPageClock(body: (at: (time: number) => void) => void): void { - body(time => vi.advanceTimersByTime(time - performance.now())); -} - describe('ContextMenuPressRecognizer', () => { - // The page's clock starts with the harness's, so no case runs late by it. - beforeEach(() => { - vi.useFakeTimers({toFake: ['performance']}); - }); - afterEach(() => { - vi.useRealTimers(); - }); - it('commits at UIKit’s moment (iOS 26.2: 400 ms, first 60 Hz tick 417 ms), not a millisecond before', () => { const {harness, press, states} = setup(); harness.finger(1).down(100, 200, 1000, {recognizers: [press]}); @@ -66,10 +49,13 @@ describe('ContextMenuPressRecognizer', () => { const {harness, press, states} = setup(); harness.finger(1).down(0, 0, 0, {recognizers: [press]}); // Nothing ran until 900 ms (a blocked main thread): the press still - // began at 400, and the menu was due at 735. + // began at 400. The menu's timer ran late, so it waits 50 ms for moves + // queued behind the block, and none came. harness.advance(900); - expect(states).toEqual([`began@${COMMIT}`, `changed@${DISPLAY}`]); + expect(states).toEqual([`began@${COMMIT}`]); expect(press.beganAt).toBe(COMMIT); + harness.advance(950); + expect(states).toEqual([`began@${COMMIT}`, 'changed@950']); }); it('claims the finger at the commit and drops the lift’s click', () => { @@ -115,192 +101,167 @@ describe('ContextMenuPressRecognizer', () => { }); it('waits at the menu’s moment for moves that came before it but reach the page late, and a drift in them cancels', () => { - withPageClock(at => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - // The page gets a move from 450 ms only at 700 ms: it is behind. - at(700); - finger.move(3, 0, 450); - at(DISPLAY); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // A late move that came at 600 ms, 14 px over. The arena holds its time - // at the clock, and the recognizer reads the time it came with. - at(DISPLAY + 10); - finger.move(14, 0, 600); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@450', - `cancelled@${DISPLAY}`, - ]); - expect(press.isMenuDue).toBe(false); - finger.up(DISPLAY + 300); - }); + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // The page gets a move from 450 ms only at 700 ms: it is behind. + finger.move(3, 0, 450, undefined, 700); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // A late move that came at 600 ms, 14 px over. The arena holds its time + // at the clock, and the recognizer reads the time it came with. + finger.move(14, 0, 600, undefined, DISPLAY + 10); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `cancelled@${DISPLAY}`, + ]); + expect(press.isMenuDue).toBe(false); + finger.up(DISPLAY + 300); }); it('shows the menu for a move stamped from its moment on, even past the tolerance, and lets the finger move freely after', () => { - withPageClock(at => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - at(700); - finger.move(3, 0, 450); - at(DISPLAY); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // Inside the tolerance at the moment, 14 px over 15 ms after it. - at(DISPLAY + 20); - finger.move(14, 0, DISPLAY + 15); - expect(press.isMenuDue).toBe(true); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@450', - `changed@${DISPLAY + 15}`, - `changed@${DISPLAY + 15}`, - ]); - finger.up(DISPLAY + 300); - }); + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 450, undefined, 700); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // Inside the tolerance at the moment, 14 px over 15 ms after it. + finger.move(14, 0, DISPLAY + 15, undefined, DISPLAY + 20); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `changed@${DISPLAY + 15}`, + `changed@${DISPLAY + 15}`, + ]); + finger.up(DISPLAY + 300); }); it('judges each coalesced sample of a late move at its own time while the menu waits', () => { - withPageClock(at => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - at(700); - finger.move(3, 0, 450); - at(DISPLAY); - harness.advance(DISPLAY); - // One late move with two samples: 13 px at 725 ms, before the moment, - // then 14 px at 740 ms. Two separate moves would end the hold at the - // first, so this one does too. - at(DISPLAY + 20); - finger.move(14, 0, DISPLAY + 5, [{x: 13, y: 0, time: DISPLAY - 10}]); - expect(press.isMenuDue).toBe(false); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@450', - `cancelled@${DISPLAY + 5}`, - ]); - }); + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 450, undefined, 700); + harness.advance(DISPLAY); + // One late move with two samples: 13 px at 725 ms, before the moment, + // then 14 px at 740 ms. Two separate moves would end the hold at the + // first, so this one does too. + finger.move( + 14, + 0, + DISPLAY + 5, + [{x: 13, y: 0, time: DISPLAY - 10}], + DISPLAY + 20, + ); + expect(press.isMenuDue).toBe(false); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@450', + `cancelled@${DISPLAY + 5}`, + ]); }); it('shows the waiting menu for a coalesced move whose samples before the moment stay inside the tolerance', () => { - withPageClock(at => { - const {harness, press} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - at(700); - finger.move(3, 0, 450); - at(DISPLAY); - harness.advance(DISPLAY); - at(DISPLAY + 20); - finger.move(14, 0, DISPLAY + 5, [{x: 10, y: 0, time: DISPLAY - 10}]); - expect(press.isMenuDue).toBe(true); - finger.up(DISPLAY + 300); - }); + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 450, undefined, 700); + harness.advance(DISPLAY); + finger.move( + 14, + 0, + DISPLAY + 5, + [{x: 10, y: 0, time: DISPLAY - 10}], + DISPLAY + 20, + ); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); }); for (const blockedUntil of [760, 800, 900]) { it(`judges a coalesced move from behind a block until ${blockedUntil} ms before the menu shows`, () => { - withPageClock(at => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - // On time until 640 ms. Then the page is blocked past the moment. - at(640); - finger.move(3, 0, 640); - // The finger crossed 11.5 px at 650 ms. Its moves wait behind the - // block and come as one coalesced move. - at(blockedUntil); - finger.move(14, 0, blockedUntil - 5, [ + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // On time until 640 ms. Then the page is blocked past the moment. + finger.move(3, 0, 640); + // The finger crossed 11.5 px at 650 ms. Its moves wait behind the block + // and come as one coalesced move. + finger.move( + 14, + 0, + blockedUntil - 5, + [ {x: 13, y: 0, time: 650}, {x: 14, y: 0, time: 700}, - ]); - expect(press.isMenuDue).toBe(false); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@640', - `cancelled@${blockedUntil - 5}`, - ]); - }); + ], + blockedUntil, + ); + expect(press.isMenuDue).toBe(false); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@640', + `cancelled@${blockedUntil - 5}`, + ]); }); } it('shows a still finger’s menu after a block, once no move comes from behind it', () => { - withPageClock(at => { - const {harness, press} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - // The moment's timer runs 65 ms late behind a block. - at(DISPLAY + 65); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - at(DISPLAY + 115); - harness.advance(DISPLAY + 115); - expect(press.isMenuDue).toBe(true); - finger.up(DISPLAY + 300); - }); + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + // The moment's timer runs 65 ms late behind a block. + harness.advance(DISPLAY, DISPLAY + 65); + expect(press.isMenuDue).toBe(false); + harness.advance(DISPLAY + 115); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); }); it('shows the menu once the late moves pause for 50 ms', () => { - withPageClock(at => { - const {harness, press} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - at(700); - finger.move(3, 0, 450); - at(DISPLAY); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // The wait runs on the arena's clock. - at(DISPLAY + 49); - harness.advance(DISPLAY + 49); - expect(press.isMenuDue).toBe(false); - at(DISPLAY + 50); - harness.advance(DISPLAY + 50); - expect(press.isMenuDue).toBe(true); - finger.up(DISPLAY + 300); - }); + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 450, undefined, 700); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(false); + // The wait runs on the arena's clock. + harness.advance(DISPLAY + 49); + expect(press.isMenuDue).toBe(false); + harness.advance(DISPLAY + 50); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); }); it('shows the menu at its moment after a move that reached the page on time, with no wait', () => { - withPageClock(at => { - const {harness, press, states} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - // A 3 px shift at 720 ms that the page gets at once: it is not behind. - at(720); - finger.move(3, 0, 720); - at(DISPLAY); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(true); - expect(states).toEqual([ - `began@${COMMIT}`, - 'changed@720', - `changed@${DISPLAY}`, - ]); - }); + const {harness, press, states} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + // A 3 px shift at 720 ms that the page gets at once: it is not behind. + finger.move(3, 0, 720); + harness.advance(DISPLAY); + expect(press.isMenuDue).toBe(true); + expect(states).toEqual([ + `began@${COMMIT}`, + 'changed@720', + `changed@${DISPLAY}`, + ]); }); it('waits at the menu’s moment when its own timer runs late after a recent move', () => { - withPageClock(at => { - const {harness, press} = setup(); - const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); - harness.advance(COMMIT); - at(730); - finger.move(3, 0, 730); - // The page is busy: the moment's timer runs 40 ms late, so later moves - // may still be queued. - at(DISPLAY + 40); - harness.advance(DISPLAY); - expect(press.isMenuDue).toBe(false); - // No more moves came: the wait ends 50 ms after the late timer ran. - at(DISPLAY + 90); - harness.advance(DISPLAY + 90); - expect(press.isMenuDue).toBe(true); - finger.up(DISPLAY + 300); - }); + const {harness, press} = setup(); + const finger = harness.finger(1).down(0, 0, 0, {recognizers: [press]}); + harness.advance(COMMIT); + finger.move(3, 0, 730); + // The page is busy: the moment's timer runs 40 ms late, so later moves + // may still be queued. + harness.advance(DISPLAY, DISPLAY + 40); + expect(press.isMenuDue).toBe(false); + // No more moves came: the wait ends 50 ms after the late timer ran. + harness.advance(DISPLAY + 90); + expect(press.isMenuDue).toBe(true); + finger.up(DISPLAY + 300); }); it('shows a still finger’s menu at its moment, with no wait', () => { diff --git a/packages/core/src/gestures/__tests__/harness.ts b/packages/core/src/gestures/__tests__/harness.ts index 5b9c46c2681ce..3e7fecf58ff86 100644 --- a/packages/core/src/gestures/__tests__/harness.ts +++ b/packages/core/src/gestures/__tests__/harness.ts @@ -83,8 +83,11 @@ export interface Harness { view?: {left?: number; top?: number; width: number; height: number}; }; finger(id: number): Finger; - /** Advance the clock and run every timer due by `time`. */ - advance(time: number): void; + /** + * Advance the clock and run every timer due by `time`. `runsAt`: when the + * page runs those timers on the clock, later behind a block. + */ + advance(time: number, runsAt?: number): void; } export function createHarness(): Harness { @@ -139,8 +142,8 @@ export function createHarness(): Harness { } return finger; }, - advance: time => { - clock.time = time; + advance: (time, runsAt = time) => { + clock.time = runsAt; arena.advanceTime(time); }, }; @@ -190,17 +193,22 @@ export class Finger { return this; } - /** `history`: the coalesced samples before this one, oldest first. */ + /** + * `history`: the coalesced samples before this one, oldest first. + * `arrivesAt`: when the page gets the move on the clock, for a move that + * reaches it after its own time. + */ move( x: number, y: number, time: number, history?: ReadonlyArray, + arrivesAt = time, ): this { this.x = x; this.y = y; this.time = time; - this.harness.clock.time = time; + this.harness.clock.time = arrivesAt; this.harness.arena.moved([ {id: this.id, x, y, time, ...(history == null ? {} : {history})}, ]); From cae3644e6c5569e3ac09bdb775420cfd487dc891 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 17:14:58 -0800 Subject: [PATCH 36/50] fix(ContextMenu): the press reads the page's time from the arena The press measured how late a move or its timer ran with performance.now(). It now reads the gesture arena's injected clock, whose new pageTime() returns the clock's own reading. In a browser that clock is performance.now(), so nothing changes there; the units drive it with the test harness's manual clock. --- packages/core/src/ContextMenu/contextMenuPress.ts | 13 +++++++++---- packages/core/src/gestures/GestureArena.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/core/src/ContextMenu/contextMenuPress.ts b/packages/core/src/ContextMenu/contextMenuPress.ts index 2084d06856f76..f62761fd92bc0 100644 --- a/packages/core/src/ContextMenu/contextMenuPress.ts +++ b/packages/core/src/ContextMenu/contextMenuPress.ts @@ -153,7 +153,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { protected override touchesMoved(touches: ReadonlyArray): void { const touch = touches.find(each => each.id === this.pressTouchId); if (touch != null) { - this.latestMove = {time: touch.inputTime, arrivedAt: performance.now()}; + this.latestMove = {time: touch.inputTime, arrivedAt: this.pageTime()}; } const hasDrifted = touch != null && !this.followHold(touch); if (hasDrifted && this.state === 'possible') { @@ -238,7 +238,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { return; } const moment = this.holdStart + menuDisplayMs; - if (performance.now() - moment > LATE_TIMER_MS || this.hasLateMove()) { + if (this.pageTime() - moment > LATE_TIMER_MS || this.hasLateMove()) { this.waitForMoves(); return; } @@ -267,10 +267,15 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { // On the arena's clock, like the hold's own deadlines. It counts from when // the page runs again. Behind a block, the event clock still reads the // moment, and moves stamped after it are still queued. - const from = Math.max(this.now(), performance.now()); + const from = Math.max(this.now(), this.pageTime()); this.setTimer(from + LATE_MOVE_GAP_MS, () => this.endMoveWait()); } + /** When the page runs now, on the arena's injected clock. */ + private pageTime(): number { + return this.arena?.pageTime() ?? this.now(); + } + private presentMenu(): void { this.stopMoveWait(); this.isMenuDue = true; @@ -283,7 +288,7 @@ export class ContextMenuPressRecognizer extends LongPressGestureRecognizer { */ private hasLateMove(): boolean { const move = this.latestMove; - const now = performance.now(); + const now = this.pageTime(); return ( move != null && move.time < this.holdStart + menuDisplayMs && diff --git a/packages/core/src/gestures/GestureArena.ts b/packages/core/src/gestures/GestureArena.ts index d9a353fad7ab1..6a0cf0887a85f 100644 --- a/packages/core/src/gestures/GestureArena.ts +++ b/packages/core/src/gestures/GestureArena.ts @@ -181,6 +181,14 @@ export class GestureArena { return this.currentTime ?? Math.max(this.latestTime, this.clock.now()); } + /** + * The injected clock's own reading: when the page runs now. Behind a block, + * the event clock lags it. + */ + pageTime(): number { + return this.clock.now(); + } + /** Whether a recognizer owns this touch, so the browser must not pan or zoom it. */ isClaimed(id: number): boolean { return this.records.get(id)?.isClaimed === true; From 4e93b5f5b453daf1b2d8211c6bce064c38b64385 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 01:57:13 -0800 Subject: [PATCH 37/50] refactor(ContextMenu): a private mark finds the trigger, not a registry The press feedback kept every mounted trigger in a module-level set to find the trigger a press mark belongs to. Each trigger now carries the private context-menu-trigger attribute while its press feedback is attached, and closest() finds it. The attribute's owner module is privateAttributes/contextMenuTrigger.stylex.ts. --- .../ContextMenu/contextMenuPressFeedback.ts | 42 +++++++------------ .../contextMenuTrigger.stylex.ts | 27 ++++++++++++ 2 files changed, 42 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/privateAttributes/contextMenuTrigger.stylex.ts diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts index 7f6c4462cc450..f05de1af2fc0c 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -5,8 +5,9 @@ /** * @file contextMenuPressFeedback.ts * @input UIKit's context-menu springs, the context-menu constants, the - * compositor animator, the presses ContextMenu's recognizer takes, and the - * press state core's press machine writes + * compositor animator, the presses ContextMenu's recognizer takes, the + * press state core's press machine writes, and the context-menu-trigger + * mark (privateAttributes/contextMenuTrigger.stylex.ts) * @output useContextMenuPressFeedback: hold and release, from ContextMenu's * menu; the growth follows the recognizer's finger, or a pen's press mark * @position The press feedback of ContextMenu's touch invocation @@ -28,6 +29,10 @@ import {useCallback, useRef, type RefObject} from 'react'; import {useAttachment} from '../hooks/useAttachment'; import {usePressFeedback} from '../hooks/usePressFeedback'; +import { + closestContextMenuTrigger, + markContextMenuTrigger, +} from '../privateAttributes/contextMenuTrigger.stylex'; import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; import {PRESS_ONSET_MS} from '../utils/pressGesture'; import {observeMutations} from '../utils/sharedMutationObserver'; @@ -69,11 +74,6 @@ const NO_LANDING: RefObject<() => number | null> = {current: () => null}; /** With no recognizer to ask, every "on" is a pen's. */ const NO_FINGER: RefObject<() => boolean> = {current: () => false}; -// The triggers a press mark belongs to: every mounted trigger, whether it -// shows growth or not. A mark inside a trigger belongs to the nearest of them -// at or above it. So a press on a nested trigger grows no outer trigger. -const pressMarkOwners = new WeakSet(); - /** * Calls `onDue` in the first animation frame that runs at or after `dueAt`, on * the event clock, with the time its callback runs. `onWait` runs in each frame @@ -100,19 +100,6 @@ function watchFramesFrom( }; } -function pressMarkOwnerOf(marked: Element): Element | null { - for ( - let node: Element | null = marked; - node != null; - node = node.parentElement - ) { - if (pressMarkOwners.has(node)) { - return node; - } - } - return null; -} - /** * The press feedback of a ContextMenu trigger. `isShown` is read at each * press: false for an inline trigger. It follows the press mark on the @@ -277,16 +264,17 @@ export function useContextMenuPressFeedback( useAttachment( triggerRef, element => { - pressMarkOwners.add(element); + // Every mounted trigger is marked, whether it shows growth or not. A + // press mark belongs to the nearest trigger at or above it, so a press + // on a nested trigger grows no outer trigger. + const unmark = markContextMenuTrigger(element); if (!isShown) { - return () => { - pressMarkOwners.delete(element); - }; + return unmark; } const isMarkedOn = () => element.getAttribute(PRESSED_ATTRIBUTE) === 'on' || [...element.querySelectorAll(`[${PRESSED_ATTRIBUTE}="on"]`)].some( - marked => pressMarkOwnerOf(marked) === element, + marked => closestContextMenuTrigger(marked) === element, ); let isPressed = isMarkedOn(); let markedAt = 0; @@ -319,7 +307,7 @@ export function useContextMenuPressFeedback( }; fingerOnsetRef.current = (landed, target) => { // A finger on a nested trigger grows only that trigger. - if (target != null && pressMarkOwnerOf(target) !== element) { + if (target != null && closestContextMenuTrigger(target) !== element) { return; } startAt(landed + PRESS_ONSET_MS); @@ -349,7 +337,7 @@ export function useContextMenuPressFeedback( stopOnset(); stopObserving(); fingerOnsetRef.current = null; - pressMarkOwners.delete(element); + unmark(); }; }, isShown, diff --git a/packages/core/src/privateAttributes/contextMenuTrigger.stylex.ts b/packages/core/src/privateAttributes/contextMenuTrigger.stylex.ts new file mode 100644 index 0000000000000..fb57645f753b2 --- /dev/null +++ b/packages/core/src/privateAttributes/contextMenuTrigger.stylex.ts @@ -0,0 +1,27 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file privateAttributes/contextMenuTrigger.stylex.ts + * @input A ContextMenu trigger whose touch press feedback is attached + * @output markContextMenuTrigger, which marks the trigger, and + * closestContextMenuTrigger, which finds the trigger at or above an element + * @position Owner module of the private context-menu-trigger attribute: the + * only file that writes its name. ContextMenu's press feedback + * (ContextMenu/contextMenuPressFeedback.ts) marks its trigger and finds the + * trigger a press belongs to. No package entry exports it. + */ + +const CONTEXT_MENU_TRIGGER = 'data-astryx-context-menu-trigger'; + +/** Marks `trigger` as a ContextMenu trigger. The returned call unmarks it. */ +export function markContextMenuTrigger(trigger: Element): () => void { + trigger.setAttribute(CONTEXT_MENU_TRIGGER, ''); + return () => { + trigger.removeAttribute(CONTEXT_MENU_TRIGGER); + }; +} + +/** The ContextMenu trigger at or above `element`, or null outside one. */ +export function closestContextMenuTrigger(element: Element): Element | null { + return element.closest(`[${CONTEXT_MENU_TRIGGER}]`); +} From f908089c3988263394ce2fab1254a2b8ee2c0d26 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 02:00:22 -0800 Subject: [PATCH 38/50] docs(ContextMenu): name the context-menu-trigger mark's owner The Ownership section names the private attribute's owner module, its writer and its closest reader. --- docs/specs/components/ContextMenu/ContextMenu.spec.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 66321187e4214..9f68299d070a8 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -77,6 +77,11 @@ Consumer migration instructions belong in consumer docs and release notes. `context-menu` target. - Cursor-anchor placement across the two presentation paths, and open state (FR10). +- The private context-menu-trigger attribute and its owner module, + `privateAttributes/contextMenuTrigger.stylex.ts`: `markContextMenuTrigger` + marks a trigger while its touch press feedback is attached, and + `closestContextMenuTrigger` finds the trigger a press belongs to, so a press + on a nested trigger grows no outer trigger. **Does not own / non-goals** From 03eb7d768dc7bd748f27cfe7e1932c2cd6776e7d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 00:47:10 -0800 Subject: [PATCH 39/50] refactor(ContextMenu): the touch layer imports its recognizer directly ContextMenuTouch.ts only re-exported the press recognizer for the touch layer, its one importer. The layer now imports contextMenuPress.ts. --- packages/core/src/ContextMenu/ContextMenu.tsx | 2 +- .../core/src/ContextMenu/ContextMenuTouch.ts | 17 ----------------- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 4 ++-- 3 files changed, 3 insertions(+), 20 deletions(-) delete mode 100644 packages/core/src/ContextMenu/ContextMenuTouch.ts diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx index 9af4297f24e84..85e569735845d 100644 --- a/packages/core/src/ContextMenu/ContextMenu.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.tsx @@ -38,7 +38,7 @@ * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/ContextMenu/ContextMenu.doc.mjs * - /packages/core/src/ContextMenu/ContextMenu.test.tsx - * - /packages/core/src/ContextMenu/ContextMenuTouch.ts (the touch layer's one import site) + * - /packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx (the touch layer) * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline) * - /packages/core/src/ContextMenu/index.ts * - /apps/storybook/stories/ContextMenu.stories.tsx diff --git a/packages/core/src/ContextMenu/ContextMenuTouch.ts b/packages/core/src/ContextMenu/ContextMenuTouch.ts deleted file mode 100644 index f8f22f0b96c95..0000000000000 --- a/packages/core/src/ContextMenu/ContextMenuTouch.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file ContextMenuTouch.ts - * @input ContextMenu's touch modules - * @output ContextMenu's touch layer, its one import site: the press - * recognizer (contextMenuPress.ts) - * @position ContextMenuTouchLayer.tsx, which ContextMenu renders under the - * touch model, imports its touch behaviour from here only; the mouse and - * keyboard paths stay in ContextMenu.tsx - * - * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx - */ - -export {ContextMenuPressRecognizer} from './contextMenuPress'; diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index a42432403acf2..30d47b133048f 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -4,7 +4,7 @@ /** * @file ContextMenuTouchLayer.tsx - * @input ContextMenu's touch layer (ContextMenuTouch.ts), the gesture + * @input ContextMenu's press recognizer (contextMenuPress.ts), the gesture * foundation and the UIKit context-menu constants * @output ContextMenuTouchLayer: renders nothing; runs the trigger's press * recognizer while ContextMenu is under the touch model @@ -18,7 +18,7 @@ import {flushSync} from 'react-dom'; import {playHaptic} from '../gestures/haptics'; import {useGestureRecognizer} from '../gestures/useGestureRecognizer'; import {contextMenu} from '../motion/uikit/contextMenu'; -import {ContextMenuPressRecognizer} from './ContextMenuTouch'; +import {ContextMenuPressRecognizer} from './contextMenuPress'; /** What ContextMenu reaches of a mounted touch layer. */ export interface ContextMenuTouchHandle { From f37d633d4c748125cfb07489674344560f7a4815 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 01:51:17 -0800 Subject: [PATCH 40/50] test(ContextMenu): a drift queued behind a block still ends the hold A finger crosses 11.5 px at 700 ms while the page is blocked from 600 ms to 850 ms, so its move reaches the page after the menu's moment. The hold must still end, with no menu. The case checks that the move reached the page behind the block and within the press's 50 ms wait. --- ...tMenuLongPress.interaction.browser.spec.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index df976edbcfc5b..359dc9de1d1a0 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -20,6 +20,7 @@ import { type Page, } from '@astryxdesign/a11y-spec/playwright'; import { + blockMainThread, drag, FRAME_STARVATION_MS, FrameStarvationError, @@ -417,6 +418,95 @@ test('a slow drift past 11.5 px late in the hold cancels it: no menu and no clic } }); +test('a drift from before 735 ms that reaches the page behind a block still cancels the hold: no menu (AST-094 FR16)', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'touches stamped and sent while the page is blocked need Chromium’s protocol', + ); + // Each move's own time, and when the page got it. + await page.addInitScript(() => { + const w = window as unknown as {__moves: [number, number][]}; + w.__moves = []; + window.addEventListener( + 'pointermove', + event => { + w.__moves.push([event.timeStamp, performance.now()]); + }, + {capture: true, passive: true}, + ); + }); + const {center} = await openTouchStory(page); + const cdp = await page.context().newCDPSession(page); + const start = Date.now(); + const at = async (ms: number) => + new Promise(resolve => { + setTimeout(resolve, Math.max(0, start + ms - Date.now())); + }); + const touch = async ( + type: 'touchStart' | 'touchMove' | 'touchEnd', + ms: number, + dx: number | null, + ) => + cdp.send('Input.dispatchTouchEvent', { + type, + touchPoints: dx == null ? [] : [{x: center.x + dx, y: center.y}], + timestamp: (start + ms) / 1000, + }); + await touch('touchStart', 0, 0); + // The page is blocked from 600 ms to 850 ms, past the menu's moment. The + // finger crosses 11.5 px at 700 ms, so the page gets that move after the + // block. + await at(600); + const blocking = blockMainThread(page, 250); + await at(650); + const moves = [touch('touchMove', 650, 6)]; + await at(700); + moves.push(touch('touchMove', 700, 14)); + const blocked = await blocking; + await Promise.all(moves); + await at(1150); + await touch('touchEnd', 1150, null); + await afterTwoFrames(page); + const moment = (await touchStartedAt(page)) + menuDisplayMs; + const origin = await page.evaluate(() => performance.timeOrigin); + const blockEnd = blocked.to - origin; + expect( + blocked.from - origin, + 'the block starts before the menu’s moment', + ).toBeLessThan(moment); + expect(blockEnd, 'the block ends after the menu’s moment').toBeGreaterThan( + moment, + ); + const early = ( + await page.evaluate( + () => (window as unknown as {__moves: [number, number][]}).__moves, + ) + ).filter(([time]) => time < moment); + expect( + early.length, + 'a move from before the menu’s moment reached the page', + ).toBeGreaterThan(0); + for (const [, receivedAt] of early) { + expect( + receivedAt, + 'the move reached the page after the block', + ).toBeGreaterThanOrEqual(blockEnd); + // The press waits 50 ms for moves still queued. + expect( + receivedAt - blockEnd, + 'the move reached the page within the press’s wait', + ).toBeLessThan(50); + } + expect( + await menuShownAt(page), + 'the menu showed for a drift from before its moment', + ).toBeNull(); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); +}); + test('a finger still inside 11.5 px at the menu’s moment that crosses it just after still gets the menu (AST-094 FR12)', async ({ page, }) => { From 8575ec71b84f00843cd95f8e8d0ded62eda8e44a Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 02:07:31 -0800 Subject: [PATCH 41/50] docs(records): why the press keeps its own wait, and outsidePress types AST-094's current state says why the press keeps a 50 ms wait beside the arena's one-task yield, and names the arena's page time and the touch fields the press reads. The FR16 gap line stays as it was. Layer runtime's outsidePress owner line names ContextMenu's watch and its pressTypes option. --- docs/architecture/layer-runtime.md | 6 ++++-- docs/specs/AST-094-touch-presentations/spec.md | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md index 356f2a2210748..56c0fcc0613e9 100644 --- a/docs/architecture/layer-runtime.md +++ b/docs/architecture/layer-runtime.md @@ -820,8 +820,10 @@ it on the surface inside the layer and run no layout while it plays. touch-only outside listener for Tooltip/HoverCard. - `Layer/outsidePress.ts` owns the outside-press watch for a text field's manual layer, used by BaseTypeahead, DateInput, DateTimeInput and - ChatComposerInput's trigger menu, and the per-document count of outside - closes that a modal backdrop press reads (`family:overlay-dismissal` FR11). + ChatComposerInput's trigger menu, and for ContextMenu's menu, which judges + a mouse's or a pen's press as it goes down (`pressTypes`). It also owns the + per-document count of outside closes that a modal backdrop press reads + (`family:overlay-dismissal` FR11). - `DropdownMenuContext` and `DropdownMenuSubMenu` own the current local menu-cascade parent-close chain. - Dialog families own native modal/backdrop presentation and their local channel diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index 201c4232848e3..6ec91e8aec18f 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -1041,6 +1041,10 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and frame late, the menu waits up to 50 ms for moves still queued. A move from before the moment that reaches the page more than two frames late holds it the same way. +- Two waits: the arena's yield covers one task; the press's 50 ms covers moves + Chromium queues past the task and frame (FR16). The press reads when the page + runs from the arena (`pageTime()`), and each touch carries the time and + samples of the input that moved it (`inputTime`, `inputSamples`). - Consumer docs: none until a component ships its touch presentation; each component documents its own, including the `presentation` value that keeps the released surface (FR25). From 22ae97e26191a36435c2b15fe5e2f83b4008bce1 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 02:38:11 -0800 Subject: [PATCH 42/50] docs(ContextMenu): name the measured table and the moved default The record now links motion/uikit/contextMenu.ts, the context menu's measured UIKit table, by path. Its public-contract line says that an omitted presentation means the touch model under a coarse pointer, with presentation="popover" as the way back, instead of no change. --- .../ContextMenu/ContextMenu.spec.md | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index c01fca1ee48c0..7adec14d50e81 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -34,17 +34,25 @@ system_specs: [spec:AST-094, spec:AST-107] # ContextMenu component contract + + +```json +{ + "paths": ["packages/core/src/motion/uikit/contextMenu.ts"] +} +``` + ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Public contract | None changes. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | -| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | -| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger; mouse and keyboard opens keep today's look. | -| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | -| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow and trigger lift (FR5) are listed under Current-state impact. | -| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | -| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | +| Area | Contract | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public contract | One default moves: under a coarse primary pointer, an omitted `presentation` means the touch model (`spec:AST-094` FR25), and `presentation="popover"` keeps the released surface. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | +| Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | +| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger; mouse and keyboard opens keep today's look. | +| Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | +| Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow and trigger lift (FR5) are listed under Current-state impact. | +| Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | +| Governing rules | `spec:AST-107` FR8; `spec:AST-094` FR26–FR35; `module:DropdownMenu/useMenuPress`; `family:overlay-dismissal`; `architecture:component-theming-surface`; `architecture:layer-runtime`. | This table is a review projection; the body below is authoritative. From 56b2f621a44a1b3323be4fa46f79a2b3ce0463aa Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 04:05:56 -0800 Subject: [PATCH 43/50] test(ContextMenu): a finger in a second document on its own clock A ContextMenu rendered into a same-origin iframe gets a 120 ms tap stamped on that window's clock. It must open no menu and keep its click. The gesture host reads each document's own clock since the second-document clock fix. --- .../core/src/ContextMenu/ContextMenu.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx index 40ab42f49e959..aeb0c1705db1b 100644 --- a/packages/core/src/ContextMenu/ContextMenu.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenu.test.tsx @@ -2514,6 +2514,78 @@ describe('ContextMenu outside dismissal', () => { fireEvent.mouseDown(outside); expect(onOpenChange).toHaveBeenLastCalledWith(false); }); + + it('times a finger on the second document’s own clock: a short tap opens no menu and keeps its click', () => { + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'], + }); + clockEpoch += 100_000; + vi.advanceTimersByTime(clockEpoch); + const view = secondDocument.defaultView as Window & typeof globalThis; + // The frame opened after the page, so its clock reads 5 s less. Its + // events come stamped on that clock. + const frameNow = vi + .spyOn(view.performance, 'now') + .mockImplementation(() => performance.now() - 5000); + const coarse = vi.fn().mockImplementation((query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + vi.stubGlobal('matchMedia', coarse); + Object.defineProperty(view, 'matchMedia', { + value: coarse, + configurable: true, + }); + try { + const onOpenChange = vi.fn<(isOpen: boolean) => void>(); + render( + createPortal( + +
Long-press me
+
, + secondDocument.body, + ), + ); + const trigger = within(secondDocument.body).getByTestId('ctx'); + const send = (type: 'touchstart' | 'touchend') => { + const event = new view.Event(type, {bubbles: true, cancelable: true}); + const contact = { + identifier: 1, + clientX: 20, + clientY: 20, + target: trigger, + }; + Object.defineProperty(event, 'changedTouches', {value: [contact]}); + Object.defineProperty(event, 'touches', { + value: type === 'touchstart' ? [contact] : [], + }); + Object.defineProperty(event, 'timeStamp', { + value: view.performance.now(), + }); + trigger.dispatchEvent(event); + return event; + }; + send('touchstart'); + act(() => { + vi.advanceTimersByTime(120); + }); + const lift = send('touchend'); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onOpenChange).not.toHaveBeenCalledWith(true); + expect(lift.defaultPrevented).toBe(false); + } finally { + frameNow.mockRestore(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); }); }); From fac565ad2390051c6d9d929ae7139a12256ecb2b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 06:34:23 -0800 Subject: [PATCH 44/50] docs(ContextMenu): the record owns the touch hold's two modules component:ContextMenu's Ownership names contextMenuPress.ts and ContextMenuTouchLayer.tsx: the touch hold's recognizer, and the layer that attaches it and opens the menu. Records only, no code. --- docs/specs/components/ContextMenu/ContextMenu.spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 7adec14d50e81..709e0037b626a 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -81,6 +81,10 @@ Consumer migration instructions belong in consumer docs and release notes. **Owns** - The Trigger area's context-menu invocation wiring (FR10). +- The touch hold under a coarse pointer (FR12). `ContextMenuPressRecognizer`, + in `contextMenuPress.ts`, times the commit, the display and the drift. + `ContextMenuTouchLayer.tsx` attaches it to the Trigger area and opens the + menu. - The alternative Pointer menu surface and Touch menu surface and their shared `context-menu` target. - Cursor-anchor placement across the two presentation paths, and open state From 69e6dd86665f0fd6abd82f0abf1f1fb4253fa373 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 07:46:00 -0800 Subject: [PATCH 45/50] docs(ContextMenu): the hold's comments match what this code does The touch layer's header read as if any second finger ended the hold. The recognizer fails on a lift, a second finger or a scroll only before the commit. The timeline's header, the spec's header and one spec comment named a press feedback, its growing copy and its scales, which this code does not have. Comments only. --- .../src/ContextMenu/ContextMenuTouchLayer.tsx | 4 ++-- ...textMenuLongPress.interaction.browser.spec.ts | 9 +++------ packages/core/src/motion/uikit/contextMenu.ts | 16 ++++++---------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx index 30d47b133048f..8b8814f6e0800 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx @@ -48,8 +48,8 @@ export interface ContextMenuTouchLayerProps { * recognizer claims the finger. The page stops scrolling under it, and its * lift sends no click and no compatibility mouse events. The menu opens where * the finger landed: at `menuDisplayMs` if it is still down, or at the lift. - * A lift before the commit, a second finger or a scroll gets no menu. So does - * a drift past the tolerance before the menu shows. + * Before the commit, a lift, a second finger or a scroll gets no menu. So + * does a drift past the tolerance before the menu shows. */ export function ContextMenuTouchLayer({ triggerRef, diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index 359dc9de1d1a0..bdb2a1e64d345 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -5,10 +5,9 @@ * @input The built "Touch / long press" ContextMenu story, real touch input, * the interaction harness, the context-menu constants and UIKit's frames * @output Interaction and animation proofs for a context menu that a held - * finger opens on UIKit's timeline. They cover the menu's moments, the - * press feedback against UIKit's frames and a blocked main thread. They - * also cover the page under the finger, a pan outside and a storyboard of - * the press + * finger opens on UIKit's timeline. They cover the menu's moments, on + * time and behind a blocked main thread. They also cover the page under + * the finger, a pan outside and a storyboard of the press * @position Interaction specs of packages/core/src/ContextMenu; Chromium's * finger stands for Android's engine, and WebKit's for iOS */ @@ -305,8 +304,6 @@ async function openScrollableStory(page: Page) { throw new Error('no trigger'); } const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; - // Where the trigger's container sits: the trigger itself is scaled by the - // press feedback. const top = async () => story.trigger.evaluate( el => (el.parentElement ?? el).getBoundingClientRect().y, diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts index a6c168b433270..1b7ac9cb221fd 100644 --- a/packages/core/src/motion/uikit/contextMenu.ts +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -15,22 +15,18 @@ /** * UIContextMenuInteraction's touch timeline on iOS 26.2. A context menu is not - * a plain long press. A finger that rests on the view starts a press feedback - * after 150 ms. A copy of the view grows uniformly, and its size follows a - * linear ramp through a spring. At 400 ms the press commits. The click haptic - * plays, and the touch can no longer become a tap. The menu displays at 735 ms - * if the finger is still down, or at the lift after the commit. A finger that - * lifts before the commit gets no menu, and the copy springs back. A finger - * that drifts too far before the menu displays gets the same. + * a plain long press. At 400 ms the press commits. The click haptic plays, and + * the touch can no longer become a tap. The menu displays at 735 ms if the + * finger is still down, or at the lift after the commit. A finger that lifts + * before the commit gets no menu. A finger that drifts too far before the menu + * displays gets the same. * * UIKit runs this on display-link ticks. So a 60 Hz screen shows each moment - * on the first frame after it: the feedback at 151 ms, the commit at 417 ms. + * on the first frame after it: the commit at 417 ms. * The times here are the moments themselves, from the `touchstart` timestamp. * UIKit counts from the main thread's receipt of the touch, about a frame * after the hardware timestamp that a browser stamps. A browser's compositor * shows a value about a frame after its time, so the two offsets cancel. - * Scales are fractions of the view's own size. The lift and dismissal springs - * are `springs.contextMenuLift` and `springs.contextMenuDismiss`. * * Every leaf has one or more `@source`. A GUESS value also has the `@measure` * task that confirms or replaces it. uikit.test.ts enforces both. From cecb382902baf60f62faa4ef12a4a93f794fddd9 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 08:12:34 -0800 Subject: [PATCH 46/50] docs(ContextMenu): the timeline and the spec name the press feedback The press feedback ships here. So the timeline's header names it, its growing copy and its scales, and the long-press spec's header and its scroll helper name it again. Comments only. --- ...textMenuLongPress.interaction.browser.spec.ts | 9 ++++++--- packages/core/src/motion/uikit/contextMenu.ts | 16 ++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts index fc890e5e04532..f1268d10f5499 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -5,9 +5,10 @@ * @input The built "Touch / long press" ContextMenu story, real touch input, * the interaction harness, the context-menu constants and UIKit's frames * @output Interaction and animation proofs for a context menu that a held - * finger opens on UIKit's timeline. They cover the menu's moments, on - * time and behind a blocked main thread. They also cover the page under - * the finger, a pan outside and a storyboard of the press + * finger opens on UIKit's timeline. They cover the menu's moments, the + * press feedback against UIKit's frames and a blocked main thread. They + * also cover the page under the finger, a pan outside and a storyboard of + * the press * @position Interaction specs of packages/core/src/ContextMenu; Chromium's * finger stands for Android's engine, and WebKit's for iOS */ @@ -1293,6 +1294,8 @@ async function openScrollableStory(page: Page) { throw new Error('no trigger'); } const center = {x: box.x + box.width / 2, y: box.y + box.height / 2}; + // Where the trigger's container sits: the trigger itself is scaled by the + // press feedback. const top = async () => story.trigger.evaluate( el => (el.parentElement ?? el).getBoundingClientRect().y, diff --git a/packages/core/src/motion/uikit/contextMenu.ts b/packages/core/src/motion/uikit/contextMenu.ts index e2ea9ddcb1230..7d7743e20b664 100644 --- a/packages/core/src/motion/uikit/contextMenu.ts +++ b/packages/core/src/motion/uikit/contextMenu.ts @@ -16,18 +16,22 @@ /** * UIContextMenuInteraction's touch timeline on iOS 26.2. A context menu is not - * a plain long press. At 400 ms the press commits. The click haptic plays, and - * the touch can no longer become a tap. The menu displays at 735 ms if the - * finger is still down, or at the lift after the commit. A finger that lifts - * before the commit gets no menu. A finger that drifts too far before the menu - * displays gets the same. + * a plain long press. A finger that rests on the view starts a press feedback + * after 150 ms. A copy of the view grows uniformly, and its size follows a + * linear ramp through a spring. At 400 ms the press commits. The click haptic + * plays, and the touch can no longer become a tap. The menu displays at 735 ms + * if the finger is still down, or at the lift after the commit. A finger that + * lifts before the commit gets no menu, and the copy springs back. A finger + * that drifts too far before the menu displays gets the same. * * UIKit runs this on display-link ticks. So a 60 Hz screen shows each moment - * on the first frame after it: the commit at 417 ms. + * on the first frame after it: the feedback at 151 ms, the commit at 417 ms. * The times here are the moments themselves, from the `touchstart` timestamp. * UIKit counts from the main thread's receipt of the touch, about a frame * after the hardware timestamp that a browser stamps. A browser's compositor * shows a value about a frame after its time, so the two offsets cancel. + * Scales are fractions of the view's own size. The lift and dismissal springs + * are `springs.contextMenuLift` and `springs.contextMenuDismiss`. * * Every leaf has one or more `@source`. A GUESS value also has the `@measure` * task that confirms or replaces it. uikit.test.ts enforces both. From 3e17bd2404c3faec3b0e7406944bb3db4d893242 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 08:12:34 -0800 Subject: [PATCH 47/50] test(ContextMenu): a growth in a second document times its onset A ContextMenu in a same-origin iframe or a Picture-in-Picture window gets its touches on that window's clock. Its growth must start in the first frame at or after that clock's onset, 150 ms after the landing. --- .../ContextMenuTouchGrowth.test.tsx | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx index 93c371a050b04..82bfc21c40ed5 100644 --- a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx +++ b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx @@ -10,7 +10,8 @@ * packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx */ -import {act, render, screen} from '@testing-library/react'; +import {act, cleanup, render, screen, within} from '@testing-library/react'; +import {createPortal} from 'react-dom'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; import {ContextMenu} from './ContextMenu'; @@ -194,6 +195,70 @@ describe('ContextMenu’s growth under a finger', () => { expectGrowthFromOnsetFrame(trigger, landedAt); }); + it('times a finger’s growth on a second document’s own clock: it starts at that clock’s onset, 150 ms after the landing', async () => { + // An app can render a ContextMenu into a same-origin iframe or a Document + // Picture-in-Picture window through a portal. + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const secondDocument = frame.contentDocument as Document; + const view = secondDocument.defaultView as Window & typeof globalThis; + // The frame opened after the page, so its clock reads 5 s less. Its + // events come stamped on that clock. + const frameNow = vi + .spyOn(view.performance, 'now') + .mockImplementation(() => performance.now() - 5000); + // Its animation frames come with the page's fake ones. + const frameRequest = vi + .spyOn(view, 'requestAnimationFrame') + .mockImplementation(callback => requestAnimationFrame(callback)); + const frameCancel = vi + .spyOn(view, 'cancelAnimationFrame') + .mockImplementation(handle => cancelAnimationFrame(handle)); + Object.defineProperty(view, 'matchMedia', { + value: window.matchMedia, + configurable: true, + }); + try { + render( + createPortal( + +
Hold me
+
, + secondDocument.body, + ), + ); + const trigger = within(secondDocument.body).getByTestId('ctx'); + const contact = { + identifier: 1, + clientX: 20, + clientY: 20, + target: trigger, + }; + const event = new view.Event('touchstart', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'changedTouches', {value: [contact]}); + Object.defineProperty(event, 'touches', {value: [contact]}); + const landedAt = view.performance.now(); + Object.defineProperty(event, 'timeStamp', {value: landedAt}); + trigger.dispatchEvent(event); + // Its nodes come from another window, so the checks read numbers. + advance(149); + expect(growths(trigger).map(startOf)).toEqual([]); + advance(1 + FRAME_MS); + expectGrowthFromOnsetFrame(trigger, landedAt); + } finally { + // Unmount before the frame goes, or React removes nodes from a + // document that no longer exists. + cleanup(); + frame.remove(); + frameNow.mockRestore(); + frameRequest.mockRestore(); + frameCancel.mockRestore(); + } + }); + it('grows the trigger at the onset when the press machine marks the press before the recognizer has the finger', async () => { const trigger = renderMenu(); const contact = {id: 1, x: 20, y: 20}; From 69a6851b31d057847738d87cb552c6431c1fce45 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 08:15:25 -0800 Subject: [PATCH 48/50] fix(ContextMenu): a growth runs on its trigger window's clock The gesture host times each document's touches on its own window's clock, so a trigger in a same-origin iframe or a Picture-in-Picture window reports its landing on that clock. The press feedback still watched the page's frames and clock, so the growth started in the first frame, dated on the page's clock. It now reads the trigger's window for its frames and clock. For another window, its animator runs on that window's clock and frames; the page's own document keeps the defaults. A comment that only restated a name goes. --- .../ContextMenu/contextMenuPressFeedback.ts | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts index f05de1af2fc0c..53898c0c2727f 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -75,28 +75,38 @@ const NO_LANDING: RefObject<() => number | null> = {current: () => null}; const NO_FINGER: RefObject<() => boolean> = {current: () => false}; /** - * Calls `onDue` in the first animation frame that runs at or after `dueAt`, on - * the event clock, with the time its callback runs. `onWait` runs in each frame - * before it. It reads only the clock. A frame comes on time where a timer does - * not: Chromium holds the page's timers for about 100 ms after it handles a - * touchstart. Returns the function that stops it. + * The window that shows `element`. A trigger in a same-origin iframe or a + * Picture-in-Picture window gets its touches, frames and timeline from that + * window, whose clock starts later than the page's. + */ +function windowOf(element: Element): Window { + return element.ownerDocument.defaultView ?? window; +} + +/** + * Calls `onDue` in the first animation frame of `view` that runs at or after + * `dueAt`, on that window's clock, with the time its callback runs. `onWait` + * runs in each frame before it. It reads only the clock. A frame comes on time + * where a timer does not: Chromium holds the page's timers for about 100 ms + * after it handles a touchstart. Returns the function that stops it. */ function watchFramesFrom( + view: Window, dueAt: number, onWait: () => void, onDue: (time: number) => void, ): () => void { - let frame = requestAnimationFrame(function look() { - const time = performance.now(); + let frame = view.requestAnimationFrame(function look() { + const time = view.performance.now(); if (time >= dueAt) { onDue(time); return; } onWait(); - frame = requestAnimationFrame(look); + frame = view.requestAnimationFrame(look); }); return () => { - cancelAnimationFrame(frame); + view.cancelAnimationFrame(frame); }; } @@ -132,7 +142,6 @@ export function useContextMenuPressFeedback( const fingerOnsetRef = useRef< ((landedAt: number, target: Element | null) => void) | null >(null); - // Stops the frame watch of a growth still to come. const stopOnsetRef = useRef<(() => void) | null>(null); const stopOnset = useCallback(() => { stopOnsetRef.current?.(); @@ -160,7 +169,19 @@ export function useContextMenuPressFeedback( if (element == null) { return null; } - animatorRef.current ??= createMotionEngine(element); + // Another window's timeline runs on its own clock and frames. + const view = windowOf(element); + animatorRef.current ??= createMotionEngine( + element, + view === window + ? {} + : { + now: () => view.performance.now(), + requestFrame: callback => { + view.requestAnimationFrame(frame => callback(frame)); + }, + }, + ); return animatorRef.current; }, [visualRef]); @@ -281,6 +302,7 @@ export function useContextMenuPressFeedback( const startAt = (onsetAt: number) => { stopOnset(); stopOnsetRef.current = watchFramesFrom( + windowOf(element), onsetAt, // The engine is built in a frame, not in the handler of the touch, // so the frame that starts the growth only creates its animation. @@ -322,7 +344,7 @@ export function useContextMenuPressFeedback( if (next) { if (!followsFinger.current()) { stopOnset(); - markedAt = performance.now(); + markedAt = windowOf(element).performance.now(); growAtMark(); } } else if (!isFollowed.current()) { From 1d6ec3721c6ccb60f9a331c6f46378492be694e7 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 08:15:31 -0800 Subject: [PATCH 49/50] docs(ContextMenu): the End-user impact row states no build status The touch grow and the trigger lift are a gap that Current-state impact states against FR5. So the End-user impact row drops its "does not yet" sentence. Records only. --- docs/specs/components/ContextMenu/ContextMenu.spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md index 810aa02115950..ec36bddcf7b20 100644 --- a/docs/specs/components/ContextMenu/ContextMenu.spec.md +++ b/docs/specs/components/ContextMenu/ContextMenu.spec.md @@ -48,7 +48,7 @@ system_specs: [spec:AST-094, spec:AST-107] | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Public contract | One default moves: under a coarse primary pointer, an omitted `presentation` means the touch model (`spec:AST-094` FR25), and `presentation="popover"` keeps the released surface. ContextMenu takes caller `children` as the Trigger area, data-driven `items` or compound `menuContent`, `label`, `presentation`, `isDisabled`, and `triggerAs`. | | Behavior | Right-click, Shift+F10 or the Menu key, and a touch long press open a menu at the cursor (or the trigger's bottom-left from the keyboard), or a bottom sheet; rows follow DropdownMenu's rows and press model. | -| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. A touch-opened menu does not yet grow from the finger or lift its trigger; mouse and keyboard opens keep today's look. | +| End-user impact | People reach a region's actions by mouse, keyboard, or a held finger; a sub-menu drills in on a phone. Mouse and keyboard opens keep today's look. | | Builder impact | Builders wrap any region, including an inline reference in prose (`triggerAs="span"`). | | Compatibility/readiness | Records shipped behavior; no API change; a disabled row gains `tabindex="-1"`; the released behavior under a finger moves as `spec:AST-094` FR25 lists, with `presentation="popover"` keeping it. The touch grow and trigger lift (FR5) are listed under Current-state impact. | | Review checks | Reject a trigger that gains a role or `aria-haspopup`, a menu that bypasses the shared Escape stack, a `context-menu` target moved off the painted surface, or a second open-state owner. | From 8fb305f8b0ea0e4bdffa613bb273dd9816782c01 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 08:26:16 -0800 Subject: [PATCH 50/50] docs(ContextMenu): the press feedback's file comment keeps one line The block repeated what the hook's and the touch layer's JSDoc say. One line stays: only the recognizer's failure or cancel returns the growth. Comment only. --- .../core/src/ContextMenu/contextMenuPressFeedback.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts index 53898c0c2727f..e59218002be1b 100644 --- a/packages/core/src/ContextMenu/contextMenuPressFeedback.ts +++ b/packages/core/src/ContextMenu/contextMenuPressFeedback.ts @@ -15,16 +15,7 @@ * SYNC: When modified, update /packages/core/src/ContextMenu/ContextMenu.tsx */ -// The growth starts in the first frame at or after the onset, 150 ms after a -// finger lands, while the recognizer follows that finger. It plays its whole -// spring from rest, from that frame, as Core Animation starts an animation at -// its commit. The recognizer reports the landing; a pointer-only pen's press -// waits for the press machine's "on". Only the recognizer's failure or cancel -// returns it. A long press is a gesture, so the -// growth plays UIKit's lift spring and each return its dismissal spring. The -// compositor plays them, so a busy main thread does not stall them. Once the -// menu opens, the trigger holds its size until the menu closes. Then it returns -// on the dismissal spring. An inline trigger and Reduce Motion show no growth. +// Only the recognizer's failure or cancel returns the growth. import {useCallback, useRef, type RefObject} from 'react'; import {useAttachment} from '../hooks/useAttachment';