Skip to content
Draft
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
70 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
1cedda2
feat(ContextMenu): a held finger grows the trigger on the lift spring
vjeux Nov 7, 2026
7f9d428
test(ContextMenu): each animation hold names the motion it judges
vjeux Nov 7, 2026
791357d
test(ContextMenu): a late touch grows from rest at its onset (red)
vjeux Nov 7, 2026
6fd83b2
fix(ContextMenu): a frame, not a timer, starts the touch growth
vjeux Nov 7, 2026
d44956d
test(ContextMenu): the lift shows the menu inside its own dispatch
vjeux Nov 7, 2026
409e581
test(ContextMenu): the menu loads the animator at once for its growth
vjeux Nov 7, 2026
abb70e1
test(ContextMenu): a starved page fails the 735 ms proof by name
vjeux Nov 7, 2026
4edb900
test(ContextMenu): Reduce Motion's menu shows in its deciding task too
vjeux Nov 7, 2026
b8a9055
Merge commit '62c7802e873ed3d020192e8218e5e1bcd4f8fad2' into HEAD
vjeux Nov 7, 2026
8da39a8
Merge commit 'b8a9055733b01d1a2379923035f598d405df7606' into HEAD
vjeux Nov 7, 2026
e66ff1d
Merge commit '92784f658939912fae623dd497cd35e499e13878' into HEAD
vjeux Nov 8, 2026
343df06
Merge commit 'e66ff1d4f8d74ee9cce277cc7cb3ac14ed1368a0' into HEAD
vjeux Nov 8, 2026
24c03c1
test(ContextMenu): a starved page's late timer fails by name in WebKit
vjeux Nov 8, 2026
c9d07a9
Merge commit '24c03c1e10039a5eb52569848433ab8f7643419f' into HEAD
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
cc254d7
Merge commit '3d78dae488ec298c7ddefea756032ee181008321' into HEAD
vjeux Nov 8, 2026
20dc83f
Merge branch 'main' into uikit/c20-contextmenu-press
cixzhang Nov 8, 2026
4c936d1
Merge branch 'uikit/c20-contextmenu-press' into uikit/c20-contextmenu…
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
704cff3
Merge #8125's head into the press feedback
vjeux Nov 10, 2026
06c942c
test(ContextMenu): a press leaves the open menu as an unpressed open
vjeux Nov 10, 2026
597e158
fix(ContextMenu): the press grows the trigger's visual, not its anchor
vjeux Nov 10, 2026
2ef1714
test(ContextMenu): the long-press cases wait for conditions, not time
vjeux Nov 10, 2026
a6253f3
test(ContextMenu): a late drift past 11.5 px, a known Chromium gap
vjeux Nov 10, 2026
d26b15b
test(ContextMenu): a set-height trigger keeps a full-height child
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
7d23141
fix(ContextMenu): the trigger's visual takes the trigger's height
vjeux Nov 10, 2026
a75cf09
test(ContextMenu): the inner visual box adds no attribute
vjeux Nov 10, 2026
c346da0
Merge #8125's head into the press feedback
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
e25ec8c
Merge #8125's head into the press feedback
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
782cdbd
Merge #8125's head into the press feedback
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
2a36eba
Merge #8125's head into the press feedback
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
0b1f31c
Merge #8125's head into the press feedback
vjeux Nov 11, 2026
4e93b5f
refactor(ContextMenu): a private mark finds the trigger, not a registry
vjeux Nov 11, 2026
f908089
docs(ContextMenu): name the context-menu-trigger mark's owner
vjeux Nov 11, 2026
03eb7d7
refactor(ContextMenu): the touch layer imports its recognizer directly
vjeux Nov 11, 2026
f37d633
test(ContextMenu): a drift queued behind a block still ends the hold
vjeux Nov 11, 2026
8575ec7
docs(records): why the press keeps its own wait, and outsidePress types
vjeux Nov 11, 2026
22ae97e
docs(ContextMenu): name the measured table and the moved default
vjeux Nov 11, 2026
7c8cb86
Merge main
vjeux Nov 11, 2026
56b2f62
test(ContextMenu): a finger in a second document on its own clock
vjeux Nov 11, 2026
650a125
Merge main
vjeux Nov 11, 2026
fac565a
docs(ContextMenu): the record owns the touch hold's two modules
vjeux Nov 11, 2026
850a9c2
Merge #8125's next head into the press feedback
vjeux Nov 11, 2026
69e6dd8
docs(ContextMenu): the hold's comments match what this code does
vjeux Nov 11, 2026
da14211
Merge #8125's comment fixes into the press feedback
vjeux Nov 11, 2026
cecb382
docs(ContextMenu): the timeline and the spec name the press feedback
vjeux Nov 11, 2026
3e17bd2
test(ContextMenu): a growth in a second document times its onset
vjeux Nov 11, 2026
69a6851
fix(ContextMenu): a growth runs on its trigger window's clock
vjeux Nov 11, 2026
1d6ec37
docs(ContextMenu): the End-user impact row states no build status
vjeux Nov 11, 2026
8fb305f
docs(ContextMenu): the press feedback's file comment keeps one line
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. 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
187 changes: 187 additions & 0 deletions apps/storybook/stories/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<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>
),
};

const layoutStyles = stylex.create({
page: {
display: 'grid',
gap: '24px',
},
pair: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '24px',
},
block: {
padding: '12px',
borderRadius: '12px',
backgroundColor: '#f3f4f6',
},
row: {
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
backgroundColor: '#f3f4f6',
},
grid: {
display: 'grid',
gridTemplateColumns: '40px 1fr',
gridTemplateRows: 'auto auto',
columnGap: '12px',
rowGap: '4px',
padding: '8px',
backgroundColor: '#f3f4f6',
},
swatch: {
width: '40px',
height: '40px',
borderRadius: '8px',
backgroundColor: '#9ca3af',
gridRow: '1 / 3',
},
tall: {
height: '160px',
padding: '8px',
backgroundColor: '#f3f4f6',
},
fill: {
height: '100%',
borderRadius: '8px',
backgroundColor: '#d1d5db',
},
});

const LAYOUTS = ['block', 'row', 'grid'] as const;

function TallContent() {
return <div {...stylex.props(layoutStyles.fill)}>Fills the trigger</div>;
}

function LayoutContent() {
return (
<>
<div {...stylex.props(layoutStyles.swatch)} />
<span>Quarterly report</span>
<span>Edited today</span>
</>
);
}

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: () => (
<div {...stylex.props(layoutStyles.page)}>
{LAYOUTS.map(layout => (
<div key={layout} {...stylex.props(layoutStyles.pair)}>
<ContextMenu
data-testid={`layout-${layout}`}
label="Report actions"
triggerXstyle={layoutStyles[layout]}
items={[
{label: 'Copy', icon: ClipboardIcon, onClick: () => {}},
{label: 'Share', icon: ShareIcon, onClick: () => {}},
]}>
<LayoutContent />
</ContextMenu>
<div
data-testid={`layout-${layout}-reference`}
{...stylex.props(layoutStyles[layout])}>
<LayoutContent />
</div>
</div>
))}
<div {...stylex.props(layoutStyles.pair)}>
<ContextMenu
data-testid="layout-tall"
label="Report actions"
triggerXstyle={layoutStyles.tall}
items={[
{label: 'Copy', icon: ClipboardIcon, onClick: () => {}},
{label: 'Share', icon: ShareIcon, onClick: () => {}},
]}>
<TallContent />
</ContextMenu>
<div
data-testid="layout-tall-reference"
{...stylex.props(layoutStyles.tall)}>
<TallContent />
</div>
</div>
</div>
),
};

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>
),
};
Loading