From 2bcc49a4622f3a024a2975c8bc487c473a2eb553 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 02:26:34 -0800 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 abb70e167d42684a20ea59c03959758be7d7e72b Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 10:41:35 -0800 Subject: [PATCH 08/29] 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 24c03c1e10039a5eb52569848433ab8f7643419f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 7 Nov 2026 18:05:19 -0800 Subject: [PATCH 09/29] 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 10/29] 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 11/29] 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 12/29] 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 a6253f312e786305be21b8206a419571b8672e0f Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 11:11:56 -0800 Subject: [PATCH 13/29] test(ContextMenu): a late drift past 11.5 px, a known Chromium gap A finger that rests past the commit and then drifts past 11.5 px before the menu's moment should get no menu and no click. WebKit cancels it. Chromium opens the menu at about 736 ms, so the case is a fixme there until a follow-up fixes it. --- ...tMenuLongPress.interaction.browser.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 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 b75dddb06f821..321de791549e4 100644 --- a/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts +++ b/packages/core/src/ContextMenu/__tests__/ContextMenuLongPress.interaction.browser.spec.ts @@ -353,6 +353,46 @@ test('a finger that lifts before 400 ms makes a plain tap: one click and no menu expect(await clicks()).toBe(1); }); +/** Two frames on the page: a click the lift makes has reached it by then. */ +const afterTwoFrames = async (page: Page) => + page.evaluate( + async () => + new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + +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. + // It crosses 11.5 px at about 655 ms, before the menu's moment, and it + // lifts well after that moment. + await driver.play( + drag( + center, + {x: center.x + 14, y: center.y}, + {holdMs: 450, durationMs: 250, restMs: 400}, + ), + ); + await touchStartedAt(page); + await afterTwoFrames(page); + expect(await menuShownAt(page)).toBeNull(); + await expect(page.getByRole('menu', {name: 'Photo actions'})).toBeHidden(); + // Only Chromium makes a click from a scripted touch, so only it can show + // that none came. + if (driver.drivesNativeGestures) { + expect(await clicks()).toBe(0); + } +}); + test('a new finger that pans outside the open menu closes it, and the page scrolls', async ({ page, }) => { From 3c39a05a474458f986ffc0fdcd307a56c09de9ff Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 11:43:41 -0800 Subject: [PATCH 14/29] 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 15/29] 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 3b76a8c589d83265097166bdeec7de82c600e618 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 10 Nov 2026 13:32:33 -0800 Subject: [PATCH 16/29] 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 17/29] 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 18/29] 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 19/29] 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 20/29] 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 21/29] 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 22/29] 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 23/29] 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 03eb7d768dc7bd748f27cfe7e1932c2cd6776e7d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Wed, 11 Nov 2026 00:47:10 -0800 Subject: [PATCH 24/29] 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 25/29] 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 26/29] 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 27/29] 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 28/29] 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 29/29] 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