Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2bcc49a
feat(ContextMenu): open from a held finger on iOS 26's timeline
vjeux Nov 7, 2026
812d3a4
test(ContextMenu): the hold's menu commits in its deciding task (red)
vjeux Nov 7, 2026
0776b40
fix(ContextMenu): the hold commits its menu in the task that decides it
vjeux Nov 7, 2026
e8f5ca1
test(ContextMenu): the menu mounts, closed, at the hold's commit (red)
vjeux Nov 7, 2026
0fbd309
fix(ContextMenu): the hold mounts the menu at its commit, closed
vjeux Nov 7, 2026
32e75c7
test(ContextMenu): a mouse press closes the menu in its own document
vjeux Nov 7, 2026
ba77ca8
Merge commit 'c8b4cba38181263ffa8fcc897975b61154cb91b0' into HEAD
vjeux Nov 7, 2026
df6fd04
docs(ContextMenu): PR1 names the outside-press watch and the touch query
vjeux Nov 7, 2026
abb70e1
test(ContextMenu): a starved page fails the 735 ms proof by name
vjeux Nov 7, 2026
b8a9055
Merge commit '62c7802e873ed3d020192e8218e5e1bcd4f8fad2' into HEAD
vjeux Nov 7, 2026
e66ff1d
Merge commit '92784f658939912fae623dd497cd35e499e13878' into HEAD
vjeux Nov 8, 2026
24c03c1
test(ContextMenu): a starved page's late timer fails by name in WebKit
vjeux Nov 8, 2026
339bf7e
docs(ContextMenu): PR1 names each presentation's query subscriptions
vjeux Nov 8, 2026
3d78dae
docs(ContextMenu): the record names the touch open's focus gap
vjeux Nov 8, 2026
20dc83f
Merge branch 'main' into uikit/c20-contextmenu-press
cixzhang Nov 8, 2026
a9188e6
Merge main
vjeux Nov 10, 2026
a5a0e12
test(ContextMenu): a finger that lifts before 400 ms is a plain tap
vjeux Nov 10, 2026
a6253f3
test(ContextMenu): a late drift past 11.5 px, a known Chromium gap
vjeux Nov 10, 2026
3c39a05
test(ContextMenu): a late drift past 11.5 px cancels in every engine
vjeux Nov 10, 2026
a4e0404
fix(ContextMenu): the menu waits for the finger's late moves
vjeux Nov 10, 2026
3b76a8c
test(ContextMenu): a finger inside the tolerance at the menu's moment
vjeux Nov 10, 2026
88cc0d8
fix(ContextMenu): the press reads each move's own time from the arena
vjeux Nov 10, 2026
46bc0da
test(ContextMenu): coalesced late samples and an on-time move
vjeux Nov 10, 2026
cad79ec
fix(ContextMenu): judge each sample at its time, wait only when late
vjeux Nov 10, 2026
889aded
test(ContextMenu): moves held behind a block decide before the menu
vjeux Nov 11, 2026
45bfb44
fix(ContextMenu): a late menu timer waits for moves behind the block
vjeux Nov 11, 2026
d743922
test(ContextMenu): the press's page time comes from the arena's clock
vjeux Nov 11, 2026
cae3644
fix(ContextMenu): the press reads the page's time from the arena
vjeux Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/contextmenu-outside-press-watch.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .changeset/contextmenu-touch-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] ContextMenu opens from a held finger on iOS 26's measured timeline where the primary pointer is coarse. The hold keeps UIKit's measured moments. At 400 ms the press takes the finger: its lift no longer sends a click, and the page cannot scroll under it. The menu opens at 735 ms if the finger is still down, or as soon as it lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. The page stays free to scroll while the menu is up: a touch outside the menu closes it as it begins to pan, or at its lift. A touch open lights no row: the menu takes the focus, and a row lights only under the finger, as in UIKit (keyboard and pointer opens still focus the first item). A second finger cancels the press before 400 ms; after it, the menu still opens. A fine pointer (a mouse) keeps the right-click menu, and a finger there keeps today's 500 ms long press. Pass `presentation="popover"` to keep today's menu: the menu at the pointer, opened by a finger held for 500 ms.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (a miss in earlier rounds): in UIKit the opening finger slides onto a row. Here it cannot until #8133, which is component:ContextMenu FR5's recorded gap, so only a new finger lights a row. Suggest: "a row lights only under a finger pressed on it".


@vjeux
69 changes: 69 additions & 0 deletions apps/storybook/stories/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -647,3 +647,72 @@ export const BottomSheetSubmenu: Story = {
}
},
};

const touchStyles = stylex.create({
card: {
width: '200px',
height: '120px',
display: 'grid',
placeItems: 'center',
borderRadius: '12px',
backgroundColor: '#e5e7eb',
color: '#374151',
userSelect: 'none',
},
});

export const TouchLongPress: Story = {
name: 'Touch / long press',
parameters: {
docs: {
description: {
story:
'Where the primary pointer is coarse (a touch screen), hold a finger on the card. As on iOS, a haptic plays at 400 ms where the device can play one, and the menu opens at 735 ms, or as soon as the finger lifts after 400 ms. A finger that lifts before 400 ms, or drifts more than 11.5 px on either axis before the menu opens, gets no menu. While the menu is up, the page still scrolls: a touch outside the menu closes it as it begins to pan.',
},
},
},
render: () => (
<ContextMenu
data-testid="touch-context-menu"
label="Photo actions"
items={[
{label: 'Copy', icon: ClipboardIcon, onClick: () => {}},
{label: 'Share', icon: ShareIcon, onClick: () => {}},
{label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}},
{type: 'divider'},
{
label: 'Delete',
icon: TrashIcon,
variant: 'destructive',
onClick: () => {},
},
]}>
<div {...stylex.props(touchStyles.card)}>Hold for actions</div>
</ContextMenu>
),
};

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: () => (
<ContextMenu
data-testid="touch-context-menu"
label="Photo actions"
presentation="popover"
items={[
{label: 'Copy', icon: ClipboardIcon, onClick: () => {}},
{label: 'Share', icon: ShareIcon, onClick: () => {}},
{label: 'Save to Files', icon: ArrowDownTrayIcon, onClick: () => {}},
]}>
<div {...stylex.props(touchStyles.card)}>Hold for actions</div>
</ContextMenu>
),
};
5 changes: 5 additions & 0 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,11 @@ under a finger, and a mouse follows `module:DropdownMenu/useMenuPress` FR3 and
- FR6 gap: under a starved compositor, Chromium can show a frame 40 to 60 ms
after the main-thread frame that builds it. A retarget then first shows that
far into its curve: up to 6.5 px on a loaded Switch.
- FR16 gap: ContextMenu's long press keeps its outcome on a blocked main
thread, but its menu can show later. When the menu's timer runs more than a
frame late, the menu waits up to 50 ms for moves still queued. A move from
before the moment that reaches the page more than two frames late holds it
the same way.
- Consumer docs: none until a component ships its touch presentation; each
component documents its own, including the `presentation` value that keeps
the released surface (FR25).
Expand Down
Loading