Skip to content

DropdownMenu: under a finger the menu grows out of its trigger and collapses back into it (UIMenu) - #8080

Draft
vjeux wants to merge 1805 commits into
meta:mainfrom
vjeux:uikit/c19-menu-motion
Draft

vjeux wants to merge 1805 commits into
meta:mainfrom
vjeux:uikit/c19-menu-motion

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8078, #8186. Review only this PR's change: vjeux/astryx@b3853ff...2d82af7

Since the spec approval at 10c11d4: main merges and review fixes (no style write on the hidden surface during the close; only UIKit's two menu springs imported and registered; the usage text and FR5's state corrected). The layer-runtime record text is #8186's.

User need

On a phone, DropdownMenu and MoreMenu fade in and out like a desktop menu; iOS menus grow out of their button and collapse back into it.

What led to it: comparing our menus with UIKit's on the iOS 26.2 simulator (spec:AST-094 FR31 and FR32).

Problem and solution fit

Under a finger, a small square on the button grows into the menu on UIKit's menu spring (0.32 s), on the compositor, so it stays smooth while the page is busy; closing plays the reverse (0.35 s). Reduce Motion gets the motion system's reduced version. A tap outside closes at the lift; a drag starting outside closes as it begins, with focus back on the trigger.

Current specification

spec:AST-094 FR31 and FR32 (current); component:DropdownMenu FR21 (on main, #8515). A mouse or presentation="popover" keeps today's entrance.

Public delta and caller burden

No API change. Under a finger: UIKit's two menu springs (per theme), a short-lived [data-astryx-menu-exit] copy while closing, and the drag-outside dismissal. A tap during the grow is held by #8458 (spec:AST-094 FR35), stacked on this.

Design and alternatives

Evidence

iOS 26.2 simulator, Safari, tap to open, then outside; the third clip has a blocked main thread. Chromium's curves sit within 0.75 px of UIKit's. All recordings are from the iOS 26.2 simulator and the Android emulator; none is from a physical device.

main this PR main thread blocked
main this PR blocked

Android Chromium 158 emulator, the same taps:

main this PR
main this PR

Writing rubric results

R1 narration: none. R2 research: none. R3 evidence: none in the record. R4 rejected alternatives: none. R5 change references: none. R6 open questions: none added.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

On the DropdownMenu "Default" story, with touch emulation:

  1. Tap "Actions": the menu grows out of the button. Tap outside: it collapses back into it.
  2. With Reduce Motion on, repeat: the reduced motion plays.
  3. Open it, then drag from outside the menu: it closes as the drag starts, and focus is back on "Actions".
  4. With a mouse: the menu appears as before.

Automated: the motion and touch unit tests and DropdownMenuTouchMotion (Chromium, WebKit). A bundle test (failing before the fix) checks the touch layer carries no spring row but the menu's two. A new unit test, "leaves the closed surface alone while its copy closes", watches the hidden menu during the close animation: before the fix it caught one style write 150 ms in (the open's compositing hint, will-change, being removed); after it, none. The spec reads picks from the page's console.

Stack (merge in this order): #8360, #8298 and #8627 (any order) → #8671 → #8078 and #8186 (any order) → this PR. Main's touch-bundle check fails here until #8671 makes it per component.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 29, 2026 11:20pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 29, 2026, 11:42 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 24, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's decision before it can be approved: the touch motion follows the draft AST-094, whose FR4 narrows the current theme-tokens architecture (INV3) to pointer presentations, and the useMenuPress FR8, FR9, DEC-4 and new DEC-8 are still proposals. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the ruling, these changes are still needed:

  • Rebase onto the landed predecessors, or split, so this pull request carries its own change rather than 26,186 changed lines.
  • Keep the pan-outside dismissal's pointer set across renders in DropdownMenu.tsx, because closeMenu changes identity on every render and the set starts empty again.
  • Bring useMenuPress.spec.md (the 500 ms row, FR9 and DEC-4) in line with the 150 ms hold and the 10 px handoff, and name the old longPressDelayMs default and the option that restores it in the changeset.

[Automated review]

doc.removeEventListener('pointerup', handleEnd, true);
doc.removeEventListener('pointercancel', handleCancel, true);
};
}, [isPopoverOpen, closeMenu]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This effect re-runs on every render: closeMenu depends on popover, and usePopover returns a new object each render, so the outside set starts empty again. A re-render between the outside pointerdown and the pan's pointercancel leaves the menu open while the page scrolls. Keeping the set in a ref, or depending on isPopoverOpen alone and reading closeMenu through a ref, keeps the pointer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at e88dc4c: the pan-outside pointer set lives for the whole open (the effect depends on the open state alone and reads closeMenu through a ref). New jsdom case: an outside pointerdown, a re-render, then the pointercancel still dismisses the menu. It fails on the old code.

@@ -140,6 +140,7 @@ Consumer migration instructions belong in consumer docs and release notes.
| stray-click window | 400 ms, or the next `pointerdown` | how long after a tracked release the browser's click is still swallowed | `MENU_PRESS_STRAY_CLICK_MS` | `module:DropdownMenu/useMenuPress` | stable |
| settle time | 300 ms | proposed: how long an opening press must last before its release may act | `MENU_PRESS_SETTLE_MS` | `module:DropdownMenu/useMenuPress` | proposed |
| long-press delay | 500 ms | proposed: how long a finger rests on the trigger before the menu opens under it | `longPressDelayMs` | `module:DropdownMenu/useMenuPress` | proposed |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The hook now holds 150 ms (300 ms inside a scrolling region), and a finger that opened the menu picks only after a 10 px handoff, but this row still says 500 ms and FR9 still says the same settle rule. Could this row, FR9 and DEC-4 describe the shipped behavior?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8078 at 309b4eb: the record now gives the 150/300 ms hold on every touch device, the 10 px handoff and DEC-4’s mouse-only settle rule, all as proposed text.

Comment thread .changeset/menu-press-uimenu.md Outdated
'@astryxdesign/core': patch
---

[feature] A finger on a DropdownMenu or MoreMenu trigger follows UIMenu: resting on the trigger opens the menu after 150 ms (UIKit's delay; 300 ms inside a scrolling region, which waits for the scroll view's content-touch delay first), and the finger that opened the menu picks a row only after it has moved 10 px, so a menu that appears under a resting finger never picks the row beneath it. A mouse keeps its settle rule. `useMenuPress` gains `continueGesture` for a pointer that went down elsewhere (a context menu's long press), and `longPressDelayMs` defaults to 150.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

AST-017 FR3 asks a changed default's changeset to name the old default and the option that restores it: 500 ms, and longPressDelayMs: 500 (which inside a scrolling region becomes 650 ms, since the content-touch delay is added on top).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #8078’s changeset at 309b4eb (old default, longPressDelayMs: 500 to keep it, and the scroller addition).

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview. CI concluded failure.

Modified Components

ContextMenu (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 1.1KB 1.1KB 0B
Lines of Code 678 678 -
Complexity Very High Very High (86) -
MoreMenu (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 219B 219B 0B
Lines of Code 87 87 -
Complexity Low Low (4) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 57.3KB 57.4KB +119B 12.4KB 12.4KB 0B
core/AppShell 58.3KB 58.3KB +84B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 40.3KB 40.4KB +126B 6.4KB 6.4KB 0B
core/Banner 48.2KB 48.3KB +120B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.7KB 54.8KB +109B 9.9KB 9.9KB 0B
core/BreadcrumbItem 66.8KB 66.8KB +18B 11.0KB 11.0KB 0B
core/Button 43.7KB 43.8KB +94B 9.2KB 9.2KB 0B
core/Calendar 55.3KB 55.4KB +107B 10.0KB 10.0KB 0B
core/Carousel 50.6KB 50.7KB +101B 10.2KB 10.2KB 0B
core/ChatComposer 61.0KB 61.1KB +104B 10.8KB 10.8KB 0B
core/ChatComposerInput 56.2KB 56.3KB +98B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 56.2KB 56.3KB +98B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.9KB 47.0KB +108B 9.5KB 9.5KB 0B
core/ChatLayout 49.2KB 49.3KB +105B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.8KB 46.9KB +86B 9.6KB 9.6KB 0B
core/ChatSendButton 46.2KB 46.3KB +99B 9.2KB 9.2KB 0B
core/CheckboxInput 50.3KB 50.4KB +88B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.5KB 54.6KB +80B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 12.9KB +117B 6.9KB 6.9KB 0B
core/CodeBlock 60.9KB 61.0KB +100B 10.7KB 10.7KB 0B
core/Collapsible 17.0KB 17.1KB +108B 4.2KB 4.2KB 0B
core/CommandPalette 51.4KB 51.5KB +103B 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.5KB +110B 3.5KB 3.5KB 0B
core/ComplexSelector 56.2KB 56.3KB +108B 10.6KB 10.6KB 0B
core/ContextMenu 75.0KB 75.3KB +348B 14.6KB 14.6KB 0B
core/ContextMenuItem 53.3KB 53.4KB +106B 9.6KB 9.6KB 0B
core/DateInput 99.9KB 100.0KB +108B 14.8KB 14.8KB 0B
core/DateRangeInput 69.8KB 69.9KB +110B 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 110.2KB +106B 15.2KB 15.2KB 0B
core/DialogHeader 49.2KB 49.3KB +98B 9.8KB 9.8KB 0B
core/DrawerHeader 49.3KB 49.4KB +101B 11.2KB 11.2KB 0B
core/DropdownMenu 73.1KB 89.0KB +15.9KB ⚠️ 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.2KB +106B 5.6KB 5.6KB 0B
core/DropdownMenuDivider 6.6KB 6.6KB 0B 3.6KB 3.6KB 0B
core/DropdownMenuGroup 6.1KB 6.1KB 0B 3.3KB 3.3KB 0B
core/DropdownMenuItem 15.4KB 15.5KB +101B 5.2KB 5.2KB 0B
core/DropdownMenuRadioGroup 6.0KB 6.0KB 0B 3.2KB 3.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.3KB +110B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.5KB 58.4KB -67B 9.5KB 9.5KB 0B
core/FileInput 54.5KB 54.6KB +92B 10.3KB 10.3KB 0B
core/IconButton 43.7KB 43.8KB +113B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.5KB +98B 4.7KB 4.7KB 0B
core/LayerProvider 63.7KB 63.8KB +121B 10.1KB 10.1KB 0B
core/Lightbox 51.6KB 51.7KB +93B 9.8KB 9.8KB 0B
core/Link 43.9KB 44.0KB +101B 7.6KB 7.6KB 0B
core/ListItem 53.9KB 54.0KB +91B 10.5KB 10.5KB 0B
core/Markdown 144.8KB 145.1KB +349B 17.4KB 17.4KB 0B
core/MobileNav 51.4KB 51.5KB +96B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.6KB 46.7KB +102B 9.4KB 9.4KB 0B
core/MoreMenu 73.4KB 89.3KB +15.8KB ⚠️ 14.7KB 14.7KB 0B
core/MultiSelector 73.3KB 73.4KB +129B 14.6KB 14.6KB 0B
core/NumberInput 56.1KB 56.2KB +117B 10.7KB 10.7KB 0B
core/Outline 29.0KB 29.1KB +111B 4.0KB 4.0KB 0B
core/Pagination 85.2KB 85.2KB -15B 15.3KB 15.3KB 0B
core/Popover 50.3KB 50.4KB +99B 12.1KB 12.1KB 0B
core/PowerSearch 164.7KB 164.7KB +19B 19.9KB 19.9KB 0B
core/RadioListItem 52.6KB 52.7KB +93B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.7KB +116B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 12.9KB +114B 7.1KB 7.1KB 0B
core/Selector 76.7KB 76.7KB -7B 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.6KB +97B 5.1KB 5.1KB 0B
core/SideNav 58.4KB 58.5KB +99B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 47.0KB 47.1KB +93B 9.5KB 9.5KB 0B
core/SideNavHeading 55.1KB 55.2KB +93B 10.1KB 10.1KB 0B
core/SideNavItem 54.9KB 55.0KB +97B 10.1KB 10.1KB 0B
core/Step 32.8KB 32.9KB +118B 5.3KB 5.3KB 0B
core/Stepper 47.8KB 47.9KB +106B 9.6KB 9.6KB 0B
core/Switch 48.8KB 48.9KB +97B 9.3KB 9.3KB 0B
core/Tab 9.7KB 9.8KB +114B 4.1KB 4.1KB 0B
core/TabMenu 52.0KB 52.1KB +101B 10.0KB 10.0KB 0B
core/Table 84.2KB 84.6KB +348B 15.2KB 15.2KB 0B
core/TableCell 76.0KB 76.4KB +337B 14.9KB 14.9KB 0B
core/TableHeaderCell 75.8KB 76.1KB +351B 14.9KB 14.9KB 0B
core/TableSelectionToolbar 52.8KB 52.9KB +103B 11.2KB 11.2KB 0B
core/TextInput 54.0KB 54.0KB +92B 10.5KB 10.5KB 0B
core/Thumbnail 51.1KB 51.2KB +100B 11.0KB 11.0KB 0B
core/TimeInput 87.2KB 87.3KB +96B 13.8KB 13.8KB 0B
core/Timestamp 27.9KB 27.9KB -1B 9.5KB 9.5KB 0B
core/Toast 60.2KB 60.3KB +103B 9.7KB 9.7KB 0B
core/ToggleButton 44.5KB 44.6KB +90B 9.3KB 9.3KB 0B
core/Token 30.0KB 30.1KB +114B 4.5KB 4.5KB 0B
core/Tokenizer 69.0KB 69.1KB +124B 16.5KB 16.5KB 0B
core/TopNav 52.2KB 52.3KB +103B 10.4KB 10.4KB 0B
core/TopNavHeading 54.5KB 54.6KB +103B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.2KB +116B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.6KB 54.7KB +125B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.2KB +119B 4.1KB 4.1KB 0B
core/TopNavMenu 54.0KB 54.1KB +101B 10.1KB 10.1KB 0B
core/TreeList 38.6KB 38.7KB +114B 4.7KB 4.7KB 0B
core/Typeahead 61.9KB 62.0KB +100B 11.3KB 11.3KB 0B
lab/ChatEmojiPicker N/A 65.6KB N/A N/A 773B N/A
lab/ChatReactionBar N/A 66.1KB N/A N/A 1.1KB N/A
lab/CommentComposer N/A 94.5KB N/A N/A 1.9KB N/A
lab/CommentPin N/A 47.9KB N/A N/A 1.2KB N/A
lab/CommentThread N/A 232.6KB N/A N/A 2.4KB N/A
lab/Comments N/A 236.8KB N/A N/A 2.5KB N/A
lab/DiffViewer N/A 46.1KB N/A N/A 2.1KB N/A
lab/EmojiPicker N/A 63.4KB N/A N/A 722B N/A
lab/ListInput N/A 75.6KB N/A N/A 1.6KB N/A
lab/MobilePowerSearch N/A 147.0KB N/A N/A 1.4KB N/A
lab/MobileTokenizer N/A 92.6KB N/A N/A 809B N/A
lab/Schedule N/A 60.3KB N/A N/A 3.9KB N/A
lab/TourStep N/A 55.8KB N/A N/A 590B N/A
lab/TransferList N/A 67.2KB N/A N/A 1.7KB N/A
lab/TransferListSelector N/A 72.8KB N/A N/A 1.9KB N/A
richtext/RichTextEditor N/A 271.2KB N/A N/A 2.6KB N/A
richtext/RichTextEditorToolbar N/A 212.7KB N/A N/A 454B N/A
richtext/RichTextView N/A 251.7KB N/A N/A 2.3KB N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

⚠️ Touch-only size budget

These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.

Component JavaScript CSS
core/DropdownMenu +15.9KB ⚠️ 0B
core/MoreMenu +15.8KB ⚠️ 0B

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.9KB 34.9KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

57 of 504 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 37 more.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's decision before it can be approved: the touch motion follows the draft AST-094, whose FR4 narrows the current theme-tokens architecture (INV3) to pointer presentations, and the useMenuPress FR8, FR9, DEC-4 and DEC-8 are still proposals. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the ruling, these changes are still needed:

  • Rebase onto the landed predecessors, or split, so this pull request carries its own change rather than 28,469 changed lines.
  • Show the menu's closing copy in the top layer instead of beside the popover with position: fixed and z-index: 2147483647, which AST-027 DEC-3 rules out for menus.
  • Keep the pan-outside dismissal's pointer set across renders in DropdownMenu.tsx.
  • Bring useMenuPress.spec.md (the 500 ms row, FR9 and DEC-4) in line with the 150 ms hold and the 10 px handoff.
  • Name the old longPressDelayMs default and its restoring option in the changeset, and recommend only touch-action: pinch-zoom for a press-open trigger.

[Automated review]

Comment on lines +212 to +219
style.setProperty('position', 'fixed');
style.setProperty('margin', '0');
style.setProperty('left', `${box.left}px`);
style.setProperty('top', `${box.top}px`);
style.setProperty('width', `${box.width}px`);
style.setProperty('height', `${box.height}px`);
style.setProperty('box-sizing', 'border-box');
style.setProperty('z-index', '2147483647');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The closing copy leaves the top layer here: popover is removed at line 191, the copy is inserted beside the popover at line 365, and it relies on position: fixed plus z-index: 2147483647. A trigger inside a clipping container with a transform, or under a later stacking context such as a sticky header, gets a menu that is whole while open and cut off or covered while it collapses; AST-027 DEC-3 routes menus through Layer and rejects fixed positioning or a large z-index as a substitute. Could the copy be shown in the top layer (a manual popover through Layer), or the real popover stay shown until the close settles?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at e88dc4c: the closing copy is a manual popover shown in the top layer, so it no longer relies on position: fixed and a large z-index. jsdom asserts popover="manual" and showPopover(). A Chromium case puts the trigger in an overflow: hidden box with a transform and checks the copy is :popover-open and starts where the surface stood.

'@astryxdesign/core': patch
---

[feat] `DropdownMenu` takes `touchOpen="press"`: a finger opens the popover menu on its press-down, as a mouse press does, instead of on the tap that lifts. The press continues as a drag onto a row, picked on release; for that drag, give the trigger `touch-action: none` or `pinch-zoom`. Use it for a trigger outside any scrolling region; if the browser takes the press for a scroll before the finger reaches the menu, the menu closes again. The default, `touchOpen="tap"`, is unchanged. `useMenuPress` takes the same `touchOpen` option.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

touch-action: none also stops a page pinch-zoom that starts on the trigger, which this pull request's AST-094 FR18 rules out (“pinch-zoom MUST stay unless a pinch or rotation recognizer is attached”). Recommending pinch-zoom alone, as the TouchOpenPress story does, keeps zoom; the same advice appears in DropdownMenu.tsx:474, DropdownMenu.doc.mjs:260, useMenuPress.ts:245 and useMenuPress.doc.mjs:89.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That advice is in #7240's touchOpen docs; its new head (166c0ce, merged here) recommends pinch-zoom only.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

  • Reduced motion: the local handling and reducedMotion: 'keep' are gone; the animator resolves it (design:reduced-motion).
  • Following a press to its release: the document listeners for an outside press are held only while that press is live (react-component-runtime INV5), until the shared press-to-release tracker replaces them.
  • Exit copy: chore(core): the layer runtime's exit copy, for a touch presentation's close #8186, stacked on this PR, moves the close copy onto Layer/layerExitCopy.ts.

@vjeux
vjeux force-pushed the uikit/c19-menu-motion branch from 9f00657 to e88dc4c Compare October 25, 2026 01:56
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
vjeux added 19 commits October 29, 2026 10:12
Where a moving element is drawn away from its layout box, the host
hit-tests what is drawn under the finger with the document's
elementFromPoint, which answers a shadow host for anything inside its
open root. The hit test now descends through each open root's own
elementFromPoint and compares with the movers along the flat tree, so a
recognizer on a control inside a moving sheet's shadow root gets its
finger and the tap clicks that control.
…nt lets go

The host's touch-action rules sit in an adopted sheet or a style element
per document or shadow root, and were never removed, so a page whose
last recognizer detached kept them, against spec:AST-094 FR24 (nothing
touch-related remains after the last instance unmounts). Fails until a
root's rules go with its last marked element.
…th its last marked element

The touch-action writer records which elements it marked in each
document or shadow root, and when the last one is unmarked (its last
recognizer detached) it removes that root's adopted sheet or style
element, so nothing touch-related remains after the last instance
unmounts (spec:AST-094 FR24). A later mark writes the rules again; an
element marked in another root first lets go of the old one.
… the inline style it reads

- The motion registry, the host's engine gate and the README name the
  presented hit test as an engine workaround (spec:AST-013 FR6) with its
  review condition (re-measure on each iOS release; remove it once iOS
  WebKit hit-tests a compositor-moved element where it is drawn) and its
  regression fixture.
- While a curve runs the animator holds the value it starts from inline,
  so presentation.ts and the README no longer say the inline style holds
  the end.
…core/internal/touch

Core ships the internal entry @astryxdesign/core/internal/touch, which
re-exports the gesture primitives for Astryx's own packages, and the
Lab-import guard fails a Lab import of core outside core's exports
(architecture:react-component-runtime INV13). The README and the barrel's
header named internal workspace imports and said nothing here is
exported; they now name that entry.
…ill needs the element, and the listeners that cancel

The release note, the stream's header, the host's comment and the README
said the stream is one passive listener per event type and that a
document-capture stopPropagation no longer starves the recognizers. Such a
stop never hid a listener on the same node, and some subscriptions cancel.
They now say what changed: an app's window-capture stopPropagation, or a
document-capture stopImmediatePropagation registered before core, no
longer hides a touch; a recognizer that must claim still needs the touch to
reach its element; the gesture host's touchend, click and contextmenu
cancel while a claimer is attached and the page hold's touchmove while it
holds; and the stream stays ahead of an app's window-capture listener only
until a type's listener is re-added. The README's list of blocking
touchmove listeners gains the claimed touch's target and the document
while a claiming element moves.
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
This pull request sat on meta#8076 and carried its old copies of the harness
meta#8045 and meta#8046 landed on main. meta#8360 carries meta#8076 with main's harness,
so after the merge this pull request's own diff is the presentation
resolver's commits. The one conflict, the gestures README, keeps both
sides: this pull request's paragraph on the presentation prop ends the
touch presentation section, and meta#8360's migration section follows it.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.
meta#8078's dependencies in one commit, so its compare link shows only its own change: meta#8627 503bc5b, meta#8360 045f666 (which carries meta#8076, meta#8073 and meta#8446) and meta#8298 8cb7c3a.
Folds this pull request onto the canonical gesture foundation, meta#8360's
head, which carries meta#8076, and onto main, in place of the old meta#8076 and
harness copies it carried. No conflicts.
… as a dependency

meta#8627 is now one commit on main; meta#8360 carries meta#8076, meta#8073 and meta#8446.

useMenuPress.ts: the trigger half lives in menuTriggerPress.ts, so it
takes meta#8360's trigger changes there. The trigger's holds run on the
long-press detector and pass FR29's 13 px drift allowance as each press
starts, under every primary pointer (FR43). The hold duration comes from
motion/uikit/longPress.ts. MENU_PRESS_TRIGGER_DRIFT_PX is re-exported
beside MENU_PRESS_LONG_PRESS_MS. The stray-click window is the shared
click guard's, from meta#8360.
meta#8186 carries meta#8360 045f666 and main 90da4a7. AST-057 takes
meta#8186's text: this side never changed it.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:30
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…el's, a finger's on every device

spec:AST-094 FR43 is current: the touch model runs for a finger on every
device unless the menu pins presentation="popover", so the comments and the
hook's two FR28 cases name the touch model and the pointer model in place
of a coarse or fine primary pointer. The FR28 accessibility cases measure
the rows once the menu has landed, so they hold once the menu grows out
of its trigger (meta#8080).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8089 now takes meta#8080, which takes meta#8078 (with meta#8627, meta#8360 and meta#8298)
and meta#8186. The DropdownMenu spec's FR5 row takes meta#8080's list (FR26–FR32,
FR34 and FR35) and its state names FR35 verified by this PR; FR28's
cancel clear and FR34 stay pending on this line. Prettier re-pads the
table.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
The DropdownMenu spec's tables keep each side's rows: meta#8080's FR5 and the
sub-menu rows (FR15, FR20, FR23), re-padded by Prettier.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8089 now takes meta#8080, which takes meta#8078 (with meta#8627, meta#8360 and meta#8298)
and meta#8186. DropdownMenuTouch.ts registers UIKit's two menu springs for
the grow, from meta#8080, and keeps the springs table for the in-place
sub-menu; its header says so. The usage text takes meta#8080's wording,
meta#8089's popover sentence and this PR's in-place sub-menu sentence.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8080 now takes meta#8078 dcda3d0 (with meta#8627 503bc5b, meta#8360 045f666
and meta#8298 8cb7c3a) and meta#8186 c614025. The usage text keeps meta#8080's
wording and this PR's sentence on the popover beside its trigger.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner: this pull request adds an Exit copy section and INV13 to the current layer-runtime architecture record, the earlier owner approval of that text covers an earlier commit only, and this head also brings in, through its predecessors' merge, a rewording of the current swipe-actions record (AST-057) that an approval of this head would cover too. An engineering owner records the decision by commenting /approve-spec <full head SHA> on the pull request once the record text is settled, and the author may do so as an engineering owner. Whatever that decision, these still need changes before approval:

  • Hold taps and drags that land while the menu is still growing, as FR35 of the touch-presentations spec requires.
  • Keep main's touch-support bundle check green: the public useMenuPress now carries the gesture scroll probe into the hooks entry.
  • Restore main's AST-057 text, correct the changeset's mouse wording and FR21's state, add real-device evidence for the Android and iOS claims, and restack onto the landed predecessors so this change can be reviewed on its own.

[Automated review]

if (outside.size === 0) {
release();
}
closeMenuRef.current();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This closes the menu on an outside pan even while it is still growing, and light dismiss does the same for an outside tap. spec:AST-094 FR35 says a tap or a drag that lands while an anchored menu grows out of its trigger must not close it, scroll the page, move focus or reach anything beneath it. Could outside pointer input be held until the open settles (the motion already tracks it in openHandle), with the browser case the FR34/FR35 verification row lists?

- **Gesture tuning.** Axis-lock distance, commit ratio, fling velocity,
resistance past the panel: behavior constants in source, not public API and
not design tokens (the same ruling the touch press model received for its
clocks). The row's slide and spring motion plays on theme motion roles

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This sentence is new at this head and arrives through the predecessors' merge; main keeps slide and spring durations as behaviour constants, and no Item file changes here, so the current record would describe motion the row does not play. Could both passages go back to main's text, with any change to row motion in its own spec pull request?


import {useCallback, useMemo, type RefObject} from 'react';
import {currentGesture} from '../Layer/gestureCounter';
import {createNativeScrollProbe} from '../gestures/nativeScroll';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useMenuPress is exported from the hooks entry and imports this module, so the hooks and root entries now carry gestures/nativeScroll.ts, which main's scripts/touch-support-bundle.test.mjs rules out. The public hook forces the pointer model, where the probe changes nothing; could the probe stay out of that import graph, or the per-component check land first?

'@astryxdesign/core': patch
---

[feature] Under a finger, on every touch device, DropdownMenu and MoreMenu grow out of their trigger on UIKit's open spring and collapse back into it on the close spring, as UIMenu does. Both run on the compositor and can be interrupted either way. Under Reduce Motion the menu fades in place (the animator's policy). A pan that starts outside the open menu dismisses it as the page scrolls, and focus that was in the menu returns to the trigger. Under a mouse, or with `presentation="popover"`, the menu keeps the layer's entrance and the theme's motion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Under a mouse … the menu keeps the layer's entrance" holds only where the primary pointer is fine: the grow follows the touch modes, so a mouse on a device whose primary pointer is coarse grows the menu too (the jsdom case "grows out of its trigger" opens it with a mouse click under the coarse stub).

| FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved |
| FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 |
| FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review |
| FR21 | Under a finger, in the touch modes (`presentation` not pinned to `'popover'`), the pointer menu grows out of the Trigger button and collapses back into it (`spec:AST-094` FR31), and closes on a pan that starts outside it (FR32); a pinned `'popover'` keeps the layer's own entrance. A pan dismissal is the menu's own close: focus that was in the menu returns to the Trigger button, whose `aria-expanded` turns false. A mouse keeps the layer's own entrance and exit. | Owner decision, 2026-10-26; `spec:AST-094` FR31, FR32; `family:overlay-dismissal` AV6 | Decided; pending implementation |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR21's state still reads "Decided; pending implementation", but this pull request implements the grow, the collapse and the pan dismissal with their tests.

isUikitMotionRef.current = isUikitMotion;
const trigger = buttonRef.current;
if (surface != null && trigger != null) {
const motion = getSurfaceMotion(surface);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This runs on a mouse or pinned-popover open too, so a fine-pointer open registers the menu springs, builds the touch animator, keeps a beforetoggle listener until unmount, and reset() promotes scale and opacity with a 500 ms demotion timer. architecture:react-component-runtime INV5 asks optional behaviour to acquire resources only while enabled; could the motion be created only when touchModes is true?

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
… as a dependency [skip ci]

meta#8220 no longer carries the withdrawn meta#7839. This PR's action-sheet
card used two things only meta#7839 provided: its opensSubMenu helper, and
header and footer on a data row. isPlainAction now checks for a nested
level itself, by main's rule (a non-empty items), and drops the per-row
header and footer checks, since rows have neither. The level's own
header and footer still keep the sheet (hasRegions).

The DropdownMenu bottom sheet takes main's regions again, inside this
PR's card-or-sheet branch. DropdownMenuTouch.ts names both register
functions it calls, and says the card plays the alert's two springs
through useAlertMotion (meta#8373). The presentation description keeps this
PR's card sentence and meta#8080's wording.
cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…wins over touch defaults (#8298)

<!-- Template: maintenance.md (an internal resolver; no behavior change until a component reads it). -->

Small batch of the merge plan: one commit on main holding only this change. Its paragraph for the gestures README waits for that README, which comes with #8076; the resolver's file header carries the same contract.

## Maintainer impact

A component that behaves differently under a finger, such as a menu that tracks the finger or becomes a sheet, must still let a caller pin the desktop behavior. Today components read `presentation ?? 'popover'`, which cannot tell an omitted prop from an explicit `'popover'`, so a component that adopts a touch default would override a caller who asked for the popover. The menu, context menu and picker work that adopts touch modes builds on this resolver (#8104, #8627, #8080, #8125, #8201, #8159, #8162).

## Intended invariant

A caller's explicit `presentation` always wins over the touch default (`architecture:public-component-api` INV16), and the surface equals `useAdaptivePresentation`'s for every value on every device, so no caller sees a change:

| `presentation`                  | surface                                       | touch input modes |
| ------------------------------- | --------------------------------------------- | ----------------- |
| `'popover'`                     | popover                                       | never             |
| `'bottom-sheet'`                | sheet                                         | under a finger    |
| `'adaptive'`                    | sheet on a compact touch screen, else popover | under a finger    |
| omitted, `whenOmitted: 'touch'` | popover                                       | under a finger    |

The modes follow the pointer, never the platform. The server and hydrating render never present touch modes, and no hint changes that (`spec:AST-094` FR13). The hook listens only to the queries a request reads (`architecture:react-component-runtime` INV5): none for a pinned `'popover'`, the pointer for `'bottom-sheet'` and the omitted `'touch'` default, and the pointer and compact size for `'adaptive'`. A touch-mode style is applied through `touchModes`, never nested under the iOS-only `@supports` gate.

## Change

- `utils/presentation.ts`: `usePresentation` and `readPresentation` (and the pure `resolvePresentation`) return the surface to render and `touchModes`, whether the component's touch input modes apply; `usePresentation` subscribes to each media query only while the request reads it. Internal: no component reads it yet.
- `hooks/useAdaptivePresentation.ts`: its header points a component that also switches input modes to `usePresentation`, whose surface is its own.

## Evidence

- Failure before the change: an omitted `presentation` and an explicit `'popover'` resolve the same, so a touch default cannot tell them apart; a server render given a touch hint must still answer the popover with no touch modes, a case that failed first. On an iPhone, a resolver that seeded its server snapshot from the live window fails both the server-render case and the `hydrateRoot` case; listening to both queries for every value fails the subscription case. A resolver that gives `'adaptive'` the sheet without the coarse pointer, or reads compact by width only, fails the surface matrix; one that ignores the theme's `md` fails the themed case.
- Success after the change: the unit matrix (iPhone, iPad, Android, desktop, Mac Safari, a fine-pointer narrow window, a phone in landscape, and the 767/768 and 499/500 compact edges, plus `'adaptive'` under a theme whose `md` is 1000) covers each row above, a live pointer change and the one-moment read; on an iPhone, the server render and the hydrating render answer the popover with no touch modes, and after hydration the phone's sheet with touch modes; each request holds only the subscriptions it reads.
- Product/runtime behavior verified unchanged: every value renders `useAdaptivePresentation`'s surface on every device, and no component reads the resolver yet.

## Scope

- [x] No intended public API, product behavior, visual direction, or policy change.
- [x] Product changes discovered during the work were removed or split.
- [x] Public text and artifacts contain no internal Meta context.

## Testing

Local gates at 9f0595f, one commit on main d73908f holding only this change (4 files, +571/−1):

- `pnpm test:ui` (core UI): 654 files, 16,459 tests passed.
- `pnpm exec vitest run --project node`: 804 files passed; the only failures are main's own story-tree and release-cache reds, tracked in #8509 and #8520.
- `tsc --project packages/core/tsconfig.json --noEmit`, strict ESLint and Prettier on every changed file: clean.
- `pnpm check:repo`: clean.

This branch was successfully deployed

1 active deployment
Preview — 2d82af7c Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants