diff --git a/.changeset/contextmenu-outside-press-watch.md b/.changeset/contextmenu-outside-press-watch.md
new file mode 100644
index 0000000000000..8540b95a11b32
--- /dev/null
+++ b/.changeset/contextmenu-outside-press-watch.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[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/.changeset/contextmenu-touch-timeline.md b/.changeset/contextmenu-touch-timeline.md
new file mode 100644
index 0000000000000..50c8df16b8668
--- /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. A block trigger whose menu opens at the finger starts to grow 150 ms after the finger lands, toward its held size on UIKit's lift spring, played on the compositor, and the hold keeps UIKit's measured moments. The growth scales an inner box that holds the trigger's children, so the menu never takes the press's scale. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the trigger springs back to its size. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. The trigger returns to its size when the menu closes. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms.
+
+@vjeux
diff --git a/apps/storybook/stories/ContextMenu.stories.tsx b/apps/storybook/stories/ContextMenu.stories.tsx
index 891b4ca1c4aa1..665e2d620e5ea 100644
--- a/apps/storybook/stories/ContextMenu.stories.tsx
+++ b/apps/storybook/stories/ContextMenu.stories.tsx
@@ -647,3 +647,190 @@ 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, it starts to grow after 150 ms, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu, and the card springs back to its size. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan. The card returns to its size when the menu closes.',
+ },
+ },
+ },
+ render: () => (
+ {}},
+ {label: 'Share', icon: ShareIcon, onClick: () => {}},
+ {label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}},
+ {type: 'divider'},
+ {
+ label: 'Delete',
+ icon: TrashIcon,
+ variant: 'destructive',
+ onClick: () => {},
+ },
+ ]}>
+
;
+}
+
+function LayoutContent() {
+ return (
+ <>
+
+ Quarterly report
+ Edited today
+ >
+ );
+}
+
+export const TouchTriggerLayouts: Story = {
+ name: 'Touch / trigger layouts',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The press grows only the trigger’s own visual, an inner box that lays the children out as the trigger would. Each trigger here sits beside a plain box with the same style, and both lay out the same, a set-height trigger with a full-height child too.',
+ },
+ },
+ },
+ render: () => (
+
+ ),
+};
+
+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/architecture/layer-runtime.md b/docs/architecture/layer-runtime.md
index c8418b9ce56e5..e9ad1dbccfcec 100644
--- a/docs/architecture/layer-runtime.md
+++ b/docs/architecture/layer-runtime.md
@@ -853,8 +853,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 abcee9328deb1..91e41284b35d3 100644
--- a/docs/specs/AST-094-touch-presentations/spec.md
+++ b/docs/specs/AST-094-touch-presentations/spec.md
@@ -1041,6 +1041,15 @@ 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.
+- 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).
diff --git a/docs/specs/components/ContextMenu/ContextMenu.spec.md b/docs/specs/components/ContextMenu/ContextMenu.spec.md
index 8eb98c0f25050..ec36bddcf7b20 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, 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 | 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. 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.
@@ -73,10 +81,19 @@ 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
(FR10).
+- The private context-menu-trigger attribute and its owner module,
+ `privateAttributes/contextMenuTrigger.stylex.ts`: `markContextMenuTrigger`
+ marks a trigger while its touch press feedback is attached, and
+ `closestContextMenuTrigger` finds the trigger a press belongs to, so a press
+ on a nested trigger grows no outer trigger.
**Does not own / non-goals**
@@ -100,19 +117,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. | 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. | Current source and tests |
+| FR9 | While the Pointer menu surface is open, it participates in `family:overlay-dismissal` through the shared stack and provides descendant depth. One Escape press affects only the top-most present nested surface; ContextMenu closes only when it is that surface. | `family:overlay-dismissal` FR1–FR6; `ContextMenu.test.tsx` nested-layer case |
+| FR10 | ContextMenu MUST be built on the public headless context menu (`spec:AST-107` FR8), with its invocation, open state, and cleanup the factory's and no behavior of its own beside the factory's. Implementation gap: no headless context menu ships; ContextMenu owns its invocation, open state, outside-press listener, and cleanup itself. | `spec:AST-107` FR8 |
+| FR11 | Tab or Shift+Tab on a row of the pointer menu, its own or an open sub-menu flyout's at any depth, closes every open level and the menu and calls `onOpenChange(false)` once, as `component:DropdownMenu` FR23 states for its menu. | WAI-ARIA Menu Button pattern; current tests |
+| FR12 | Under a finger, a block trigger whose menu opens at the finger grows during a hold toward 1.0735 times its size on `springs.contextMenuLift`, from the onset, 150 ms after the finger lands, and it returns after a lift before the commit on `springs.contextMenuDismiss`, as FR5 names them. The hold commits at 400 ms and the menu displays at 735 ms, as UIKit's do. They are measured input, not motion, and stay internal (`spec:AST-094` FR4, FR45). | Measured on iOS 26.2 (`motion/uikit/contextMenu.ts`): UIKit commits at 400 ms, shows the menu at 735 ms, and grows the view on a linear ramp chased by a spring. `spec:AST-094` FR4, FR45: the two moments are measured input, and the long-press presentation is a gesture component (FR5), so the growth and its return play their spring roles |
### Allowed variation
@@ -143,15 +161,18 @@ 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. 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.
+ 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
@@ -244,37 +265,42 @@ 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`; `contextMenuPressFeedback.test.tsx` "grows toward the held scale on springs.contextMenuLift, from the onset" and "returns on springs.contextMenuDismiss, at the time the recognizer ended the press"; `ContextMenuTouchGrowth.test.tsx` onset cases; the touch story's browser curve and busy-thread cases in Chromium, and an iOS Safari recording for WebKit | A held finger: the commit, the display, the onset, the growth, the early-lift return, their springs | A commit or a display off UIKit's moments, a growth before the onset, or a growth or return off its spring role fails | `audit:ContextMenu/behavior` |
+| Theming anatomy map | `scripts/check-knowledge.mjs` | Canonical anatomy and target | Missing, extra, prefixed, stale, or unclassified mappings fail repository validation. | `audit:ContextMenu/theming` |
+| RTL relation | RTL audit curated D2 target (`apps/storybook/rtl-audit/targets.json`) on the `Presentation / BottomSheet submenu` story | BottomSheet opened into a submenu, LTR and RTL | The sheet header's back button not at the inline start of its heading in both directions fails D2. | `audit:ContextMenu/i18n-rtl` |
## Current-state impact
- **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.
+- **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
diff --git a/packages/core/src/ContextMenu/ContextMenu.doc.mjs b/packages/core/src/ContextMenu/ContextMenu.doc.mjs
index f8ee2c5659074..7390609678f8b 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: a block trigger whose menu opens at the finger starts to grow at 150 ms, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms.',
bestPractices: [
{
guidance: true,
@@ -187,6 +186,11 @@ export const docs = {
description:
'Keep a visible MoreMenu or equivalent trigger for important mobile actions; long-press must not be the only route.',
},
+ {
+ guidance: true,
+ description:
+ 'Leave a little room around a block trigger: a held finger grows it from its center, by about 7%, while the menu is up.',
+ },
{
guidance: false,
description:
@@ -224,7 +228,7 @@ export const docs = {
export const docsZh = {
usage: {
description:
- '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。',
+ '右键点击时在光标位置出现的上下文菜单。用于为特定元素或区域提供上下文操作,而不使 UI 杂乱。在主指针为粗指针(触摸屏)时,按住手指即可打开,时序与 iOS 相同:150 毫秒时块级触发区域(菜单在手指处打开时)开始放大,735 毫秒时菜单打开;若手指在 400 毫秒后抬起,菜单在抬起时立即打开。',
bestPractices: [
{guidance: true, description: '保持菜单项简洁和面向操作。'},
{guidance: true, description: '有很多项时使用分组和分隔线。'},
@@ -242,6 +246,11 @@ export const docsZh = {
description:
'重要的移动端操作还应提供可见的 MoreMenu 或同等入口,不要只依赖长按。',
},
+ {
+ guidance: true,
+ description:
+ '块级触发区域周围请留出少量空间:按住时它会从中心放大约 7%,直到菜单关闭。',
+ },
{
guidance: false,
description: '将上下文菜单作为访问重要操作的唯一方式。',
@@ -260,7 +269,7 @@ export const docsDense = {
usage: {
anatomy,
description:
- 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions.',
+ 'A right-click context menu that appears at the cursor position. Use to provide contextual actions for specific elements or regions. Under a finger (a coarse primary pointer): a held finger opens it at 735 ms, or on a lift after 400 ms; a block trigger whose menu opens at the finger grows from 150 ms.',
bestPractices: [
{guidance: true, description: 'Keep items concise and action-oriented.'},
{
@@ -281,6 +290,11 @@ export const docsDense = {
guidance: true,
description: 'Keep a visible mobile entry point for important actions.',
},
+ {
+ guidance: true,
+ description:
+ 'Leave room around a block trigger: a held finger grows it about 7%.',
+ },
{
guidance: false,
description: 'Use as the only way to access important actions.',
diff --git a/packages/core/src/ContextMenu/ContextMenu.test.tsx b/packages/core/src/ContextMenu/ContextMenu.test.tsx
index ab315cfd9611a..4dc1207de7f0a 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,
@@ -19,6 +27,7 @@ import {
waitFor,
cleanup,
createEvent,
+ within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {readFileSync} from 'node:fs';
@@ -37,6 +46,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 +192,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 +397,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 +441,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 +460,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();
}
},
);
@@ -557,222 +603,785 @@ describe('ContextMenu', () => {
fireEvent.contextMenu(trigger, {clientX: 0, clientY: 0, detail: 0});
expect(HTMLElement.prototype.showPopover).toHaveBeenCalled();
});
+});
- describe('a menu that was never opened', () => {
- it('adds no popover element and no menu until its first open, which mounts and shows it', () => {
- render(
-
-
Right-click me
- ,
+describe('a menu that was never opened', () => {
+ it('adds no popover element and no menu until its first open, which mounts and shows it', () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ expect(document.querySelector('[popover]')).toBeNull();
+ expect(screen.queryByRole('menu', {hidden: true})).toBeNull();
+ openMenu();
+ const popover = document.querySelector('[popover]');
+ expect(popover).not.toBeNull();
+ expect(popover?.hasAttribute('popover-open')).toBe(true);
+ expect(
+ screen.getByRole('menuitem', {name: 'Cut', hidden: true}),
+ ).toBeInTheDocument();
+ });
+
+ it('shows a first open as its layer attaches, and a later open within the event', () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ const trigger = screen.getByText('Right-click me');
+ const open = () =>
+ trigger.dispatchEvent(
+ new MouseEvent('contextmenu', {bubbles: true, cancelable: true}),
);
- expect(document.querySelector('[popover]')).toBeNull();
- expect(screen.queryByRole('menu', {hidden: true})).toBeNull();
- openMenu();
- const popover = document.querySelector('[popover]');
- expect(popover).not.toBeNull();
- expect(popover?.hasAttribute('popover-open')).toBe(true);
+ const showPopover = vi.mocked(HTMLElement.prototype.showPopover);
+ act(() => {
+ open();
+ // No layer yet: the show waits for its popover element.
+ expect(showPopover).not.toHaveBeenCalled();
+ });
+ // The commit attached the layer, which showed it.
+ expect(showPopover).toHaveBeenCalledTimes(1);
+ fireEvent.keyDown(document, {key: 'Escape'});
+ act(() => {
+ open();
+ // Mounted: shown in the event itself.
+ expect(showPopover).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => {
+ render(
+
+
+ ,
+ );
+ const button = screen.getByRole('button', {name: 'Right-click me'});
+ button.focus();
+ // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count.
+ fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0});
+ await waitFor(() => {
expect(
screen.getByRole('menuitem', {name: 'Cut', hidden: true}),
- ).toBeInTheDocument();
+ ).toHaveFocus();
});
+ fireEvent.keyDown(document, {key: 'Escape'});
+ expect(button).toHaveFocus();
+ });
- it('shows a first open as its layer attaches, and a later open within the event', () => {
- render(
-
-
Right-click me
- ,
- );
- const trigger = screen.getByText('Right-click me');
- const open = () =>
- trigger.dispatchEvent(
- new MouseEvent('contextmenu', {bubbles: true, cancelable: true}),
- );
- const showPopover = vi.mocked(HTMLElement.prototype.showPopover);
- act(() => {
- open();
- // No layer yet: the show waits for its popover element.
- expect(showPopover).not.toHaveBeenCalled();
- });
- // The commit attached the layer, which showed it.
- expect(showPopover).toHaveBeenCalledTimes(1);
- fireEvent.keyDown(document, {key: 'Escape'});
- act(() => {
- open();
- // Mounted: shown in the event itself.
- expect(showPopover).toHaveBeenCalledTimes(2);
- });
+ it('carries the same trigger and cursor anchor attributes as after it opened', () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ const trigger = screen.getByTestId('ctx');
+ const describe = () => ({
+ trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`),
+ anchor: [...(trigger.lastElementChild?.attributes ?? [])].map(
+ a => a.name,
+ ),
+ anchorName: (
+ (trigger.lastElementChild as HTMLElement | null)?.style as unknown as
+ Record | undefined
+ )?.anchorName,
});
+ const before = describe();
+ openMenu();
+ fireEvent.keyDown(document, {key: 'Escape'});
+ expect(describe()).toEqual(before);
+ expect(trigger.hasAttribute('aria-controls')).toBe(false);
+ expect(trigger.hasAttribute('aria-haspopup')).toBe(false);
+ });
- it('a keyboard open moves focus to the first item once the menu shows, and Escape returns it', async () => {
- render(
-
-
- ,
- );
- const button = screen.getByRole('button', {name: 'Right-click me'});
- button.focus();
- // Shift+F10 / the Menu key: a contextmenu at (0, 0) with no click count.
- fireEvent.contextMenu(button, {clientX: 0, clientY: 0, detail: 0});
- await waitFor(() => {
- expect(
- screen.getByRole('menuitem', {name: 'Cut', hidden: true}),
- ).toHaveFocus();
+ it('keeps its layer mounted after the first close, and a second open shows the same menu', () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ openMenu();
+ const menu = screen.getByRole('menu', {hidden: true});
+ fireEvent.keyDown(document, {key: 'Escape'});
+ expect(menu.isConnected).toBe(true);
+ expect(
+ document.querySelector('[popover]')?.hasAttribute('popover-open'),
+ ).toBe(false);
+ openMenu();
+ expect(screen.getByRole('menu', {hidden: true})).toBe(menu);
+ expect(HTMLElement.prototype.showPopover).toHaveBeenCalledTimes(2);
+ });
+});
+
+/**
+ * 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',
+ ],
});
- fireEvent.keyDown(document, {key: 'Escape'});
- expect(button).toHaveFocus();
+ clockEpoch += 100_000;
+ vi.advanceTimersByTime(clockEpoch);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
});
- it('carries the same trigger and cursor anchor attributes as after it opened', () => {
+ function renderMenu(
+ props: {
+ isDisabled?: boolean;
+ presentation?: 'popover' | 'bottom-sheet';
+ triggerAs?: 'div' | 'span';
+ onTouchStart?: (event: React.TouchEvent) => void;
+ onTouchEnd?: (event: React.TouchEvent) => void;
+ } = {},
+ ) {
render(
-
-
Right-click me
+
+
Long-press me
,
);
- const trigger = screen.getByTestId('ctx');
- const describe = () => ({
- trigger: [...trigger.attributes].map(a => `${a.name}=${a.value}`),
- anchor: [...(trigger.lastElementChild?.attributes ?? [])].map(
- a => a.name,
- ),
- anchorName: (
- (trigger.lastElementChild as HTMLElement | null)?.style as unknown as
- Record | undefined
- )?.anchorName,
- });
- const before = describe();
- openMenu();
- fireEvent.keyDown(document, {key: 'Escape'});
- expect(describe()).toEqual(before);
- expect(trigger.hasAttribute('aria-controls')).toBe(false);
- expect(trigger.hasAttribute('aria-haspopup')).toBe(false);
+ return screen.getByTestId('ctx');
+ }
+
+ 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('keeps its layer mounted after the first close, and a second open shows the same menu', () => {
- render(
-
-
+ ,
+ );
+ 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();
});
});
@@ -1114,6 +1723,34 @@ describe('ContextMenu compound mode', () => {
expect(anchor.style.top).toBe('40px');
});
+ it("places a right-click's anchor at the pointer offset inside a scaled container, with no scale of its own", () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ const trigger = screen.getByTestId('ctx');
+ // A 200x120 trigger inside an ancestor scaled to 0.5.
+ Object.defineProperty(trigger, 'offsetWidth', {value: 200});
+ Object.defineProperty(trigger, 'offsetHeight', {value: 120});
+ trigger.getBoundingClientRect = () =>
+ ({
+ left: 100,
+ top: 50,
+ right: 200,
+ bottom: 110,
+ width: 100,
+ height: 60,
+ }) as DOMRect;
+ fireEvent.contextMenu(trigger, {clientX: 150, clientY: 80, detail: 1});
+ const anchor = getCursorAnchor(trigger);
+ expect(anchor.style.left).toBe('50px');
+ expect(anchor.style.top).toBe('30px');
+ // The ancestor's scale is not the press feedback's growth: nothing to cancel.
+ expect(anchor.style.scale).toBe('');
+ expect(anchor.style.translate).toBe('');
+ });
+
it('anchors a keyboard-invoked menu to the trigger bottom-left', () => {
render(
@@ -1629,6 +2266,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) {
@@ -1721,7 +2368,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});
@@ -1734,6 +2399,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>();
@@ -1746,7 +2442,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});
@@ -1754,18 +2450,181 @@ 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('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);
+ });
+
+ 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();
+ }
+ });
+ });
});
describe('ContextMenu: Tab from a flyout leaves the whole menu (APG menu button)', () => {
diff --git a/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx b/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx
index e384ddc50099c..146b5d288debb 100644
--- a/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx
+++ b/packages/core/src/ContextMenu/ContextMenu.triggerProps.test.tsx
@@ -4,7 +4,8 @@
* @file ContextMenu.triggerProps.test.tsx
* @input vitest, @testing-library/react, ContextMenu
* @output A guard that the trigger element receives only DOM props: React
- * warns about nothing, and no hook-internal function lands on it.
+ * warns about nothing, and no hook-internal function lands on it. The
+ * inner visual box adds no attribute to the trigger.
* @position Testing; validates ContextMenu.tsx. Its own file on purpose: React
* warns about an unknown DOM prop once per module, so this case must be the
* first ContextMenu render of its file to see the warning at all.
@@ -51,4 +52,26 @@ describe('ContextMenu trigger element', () => {
consoleError.mockRestore();
}
});
+
+ it('keeps its own attributes: the inner visual box carries only its class', () => {
+ render(
+
+
Right-click me
+ ,
+ );
+ const trigger = screen.getByTestId('block');
+ expect(trigger.getAttributeNames().sort()).toEqual([
+ 'class',
+ 'data-testid',
+ ]);
+ const [visual, anchor] = Array.from(trigger.children);
+ expect(visual.tagName).toBe('DIV');
+ expect(visual.getAttributeNames()).toEqual(['class']);
+ expect(visual.textContent).toBe('Right-click me');
+ expect(anchor.getAttributeNames().sort()).toEqual([
+ 'aria-hidden',
+ 'class',
+ 'style',
+ ]);
+ });
});
diff --git a/packages/core/src/ContextMenu/ContextMenu.tsx b/packages/core/src/ContextMenu/ContextMenu.tsx
index 5f6c2ef64a906..39e3b7e796790 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
*
@@ -27,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.
@@ -35,6 +38,9 @@
* 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/ContextMenuTouchLayer.tsx (the touch layer)
+ * - /packages/core/src/ContextMenu/contextMenuPress.ts (touch timeline)
+ * - /packages/core/src/ContextMenu/contextMenuPressFeedback.ts (press feedback)
* - /packages/core/src/ContextMenu/index.ts
* - /apps/storybook/stories/ContextMenu.stories.tsx
* - /packages/cli/assets/templates/blocks/components/ContextMenu/ (showcase blocks)
@@ -42,7 +48,6 @@
import React, {
useCallback,
- useEffect,
useId,
lazy,
useMemo,
@@ -59,7 +64,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';
@@ -78,9 +83,16 @@ import {
import {useMenuOverflow} from '../DropdownMenu/useMenuOverflow';
import {useMenuDrillIn} from '../DropdownMenu/useMenuDrillIn';
import {useInternalListFocus} from '../hooks/useListFocus';
+import {useLongPress} from '../hooks/useLongPress';
+import {pressableProps} from '../utils/pressFeedback';
import {useMenuPress} from '../hooks/useMenuPress';
import {useTypeahead} from '../hooks/useTypeahead';
-import {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 {
@@ -103,10 +115,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,
@@ -121,17 +130,47 @@ 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',
WebkitUserSelect: 'none',
userSelect: 'none',
},
+ // The trigger's own visual, which the press feedback grows. It lays its
+ // children out as the trigger would, and fills the trigger in block, flex
+ // and grid layout. A full-height child then keeps a set-height trigger's
+ // height. The cursor anchor stays outside it, so a layer never takes the
+ // press's scale.
+ triggerVisual: {
+ display: 'inherit',
+ flexDirection: 'inherit',
+ flexWrap: 'inherit',
+ alignItems: 'inherit',
+ alignContent: 'inherit',
+ justifyContent: 'inherit',
+ justifyItems: 'inherit',
+ rowGap: 'inherit',
+ columnGap: 'inherit',
+ gridTemplateColumns: 'inherit',
+ gridTemplateRows: 'inherit',
+ gridTemplateAreas: 'inherit',
+ gridAutoFlow: 'inherit',
+ gridAutoColumns: 'inherit',
+ gridAutoRows: 'inherit',
+ flexGrow: 1,
+ alignSelf: 'stretch',
+ gridColumn: '1 / -1',
+ gridRow: '1 / -1',
+ minWidth: 0,
+ height: '100%',
+ borderRadius: 'inherit',
+ },
// 1px anchor placed at the cursor point within the trigger. The menu is
// anchored to this element, so it sits under the cursor yet is positioned
// relative to the trigger's context — it follows the content on scroll and
@@ -168,6 +207,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: {
@@ -287,11 +331,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;
@@ -345,7 +391,7 @@ export function ContextMenu({
label: labelFromProps,
isDisabled = false,
onOpenChange,
- presentation = 'popover',
+ presentation,
triggerAs = 'div',
ref,
className,
@@ -363,8 +409,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.
@@ -386,6 +436,8 @@ export function ContextMenu({
// — it scrolls with the content instead of sitting at a fixed viewport point.
const positionRef = useRef({x: 0, y: 0});
const cursorAnchorRef = useRef(null);
+ // The trigger's own visual: what the press feedback grows.
+ const visualRef = useRef(null);
const triggerRef = useRef(null);
// Element focused before the menu opened, restored when it closes so focus
// does not fall to after Escape or outside-click dismissal.
@@ -398,32 +450,50 @@ 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, for its press feedback and whether it times a
+ // finger.
+ const touchHandleRef = useRef(null);
+ // The menu closed: a finger's menu is over, and the trigger returns with
+ // the close itself (run by every close handler below).
+ const endTouchOpen = useCallback(() => {
+ touchHandleRef.current?.release();
+ }, []);
+
const updateOpenState = useCallback(
(nextIsOpen: boolean) => {
if (!nextIsOpen) {
setSubmenuPath([]);
+ endTouchOpen();
}
setIsOpen(nextIsOpen);
onOpenChange?.(nextIsOpen);
},
- [onOpenChange],
+ [endTouchOpen, onOpenChange],
);
+ const outsidePress = useOutsidePress();
+ // 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();
+ endTouchOpen();
setIsOpen(false);
onOpenChange?.(false);
// Restore focus to the element that was focused before opening.
@@ -432,13 +502,19 @@ export function ContextMenu({
if (toRestore && document.contains(toRestore)) {
toRestore.focus();
}
- }, [onOpenChange]),
+ }, [endTouchOpen, 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,
@@ -493,6 +569,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.
@@ -516,44 +593,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) => {
@@ -608,7 +677,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;
@@ -630,13 +707,21 @@ 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();
}
},
[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(
@@ -644,6 +729,23 @@ export function ContextMenu({
[],
);
+ // A client point in the trigger's own coordinates, where the cursor anchor
+ // lives. The press feedback grows only the trigger's visual, so the
+ // trigger's box is as laid out. The point stays inside that box.
+ const toTriggerLocal = useCallback(
+ (point: {x: number; y: number}): {x: number; y: number} => {
+ const rect = triggerRef.current?.getBoundingClientRect();
+ if (rect == null) {
+ return point;
+ }
+ 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) {
@@ -675,19 +777,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();
@@ -703,17 +819,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);
@@ -743,6 +848,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;
@@ -882,6 +1004,10 @@ export function ContextMenu({
- {children}
+ {triggerAs === 'div' ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+ {touchModes && !isDisabled ? (
+
+ ) : null}
{usesBottomSheet ? (
diff --git a/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx
new file mode 100644
index 0000000000000..82bfc21c40ed5
--- /dev/null
+++ b/packages/core/src/ContextMenu/ContextMenuTouchGrowth.test.tsx
@@ -0,0 +1,411 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file ContextMenuTouchGrowth.test.tsx
+ * @input ContextMenu under a finger in jsdom, with the compositor animator
+ * replaced
+ * @output Checks that the trigger's visual grows only while ContextMenu's
+ * recognizer follows a finger, and springs back when the recognizer fails
+ * @position Unit tests of the press feedback's start and end in
+ * packages/core/src/ContextMenu/ContextMenuTouchLayer.tsx
+ */
+
+import {act, cleanup, render, screen, within} from '@testing-library/react';
+import {createPortal} from 'react-dom';
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback';
+import {ContextMenu} from './ContextMenu';
+
+const animator = vi.hoisted(() => ({
+ elements: [] as HTMLElement[],
+ play: vi.fn(),
+ animate: vi.fn(),
+}));
+
+vi.mock('../motion/animator', async importOriginal => {
+ const original = await importOriginal