Skip to content

DropdownMenu: under a finger, the opening finger picks only after UIMenu's handoff (AST-094 FR29) - #8627

Draft
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c19-menu-handoff
Draft

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/c19-menu-handoff

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Rebuilt onto main alone, as review suggested; the earlier stacked history is replaced.

User need

Under a finger, on a phone or a touchscreen laptop, a menu can pick a row nobody chose. A finger held on a DropdownMenu or MoreMenu trigger opens the menu under it, and once the press has lasted 300 ms, lifting picks whatever row is beneath. On iOS, the finger that opened a menu picks only after it moves.

What led to it: comparing our menus with UIKit's on the iOS 26.2 simulator (spec:AST-094 FR29); FR43 applies it on every device.

Problem and solution fit

For a finger or pen, on every device, the opening press counts distance, not time. Until the finger is 10 px, in a straight line, from the first position the open menu sees, nothing lights up and lifting picks nothing, so the menu stays open. Then it slides and picks as usual. A mouse is unchanged. The trigger still opens after the 500 ms hold; #8078 adds opening on press.

Current specification

spec:AST-094 FR29, FR43 and FR25 (current); module:DropdownMenu/useMenuPress FR9 (current).

Public delta and caller burden

Under a finger, on every device, the opening finger moves 10 px before it picks; presentation="popover" keeps today's behavior. useMenuPress: no new option or type.

Design and alternatives

  • A longer wait: a finger resting long enough would still pick.
  • A distance per axis: on iOS a diagonal (9, 9) picks and (7, 7) does not, which only a straight-line distance matches.
  • Counting from where the finger was at the open: the simulator counts from the first position the open menu sees.

Chosen: 10 px in a straight line from that first position, which matches every capture.

Evidence

iOS 26.2 simulator, after a 1 pt first step: (0, 9) and (7, 7) picked nothing; (0, 10), (10, 0), (9, 9) and (10, 10) picked. Real Chromium and WebKit: a 9 px move onto "Edit", then a lift, picks nothing; without this change it picks "Edit".

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. Hold "Actions" until the menu opens, move a few pixels onto "Edit" and lift: nothing runs and the menu stays open.
  2. Hold again, slide to "Duplicate" and lift: it runs once.
  3. With a mouse, press "Actions" and release on a row: it runs as before.

Automated: the handoff unit tests (a fine-pointer case among them, which failed before the press model keyed on presentation) and DropdownMenuTouchHandoff (Chromium, WebKit), which fails without this change.

Stack: this PR → #8078. Besides the handoff, the one other change is the @astryxdesign/interaction-harness dev dependency its browser spec imports.

@vercel

vercel Bot commented Oct 27, 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 28, 2026 4:36pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 28, 2026, 5:03 PM UTC

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

MoreMenu (@astryxdesign/core) · View in Storybook
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/BreadcrumbItem 67.3KB 67.5KB +196B 11.2KB 11.2KB 0B
core/ContextMenu 75.3KB 75.5KB +214B 14.8KB 14.8KB 0B
core/ContextMenuItem 53.5KB 53.5KB 0B 9.8KB 9.8KB 0B
core/DropdownMenu 73.4KB 73.6KB +222B 15.0KB 15.0KB 0B
core/DropdownMenuCheckboxItem 16.4KB 16.4KB 0B 5.7KB 5.7KB 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.6KB 15.6KB 0B 5.3KB 5.3KB 0B
core/DropdownMenuRadioGroup 6.0KB 6.0KB 0B 3.2KB 3.2KB 0B
core/DropdownMenuRadioItem 16.4KB 16.4KB 0B 5.7KB 5.7KB 0B
core/DropdownMenuSubMenu 58.7KB 58.9KB +197B 9.7KB 9.7KB 0B
core/Markdown 145.2KB 145.4KB +207B 17.8KB 17.8KB 0B
core/MoreMenu 73.7KB 73.9KB +207B 15.0KB 15.0KB 0B
core/NavHeadingMenu 13.0KB 13.2KB +190B 3.3KB 3.3KB 0B
core/Pagination 85.5KB 85.7KB +194B 15.5KB 15.5KB 0B
core/PowerSearch 166.6KB 166.8KB +231B 20.1KB 20.1KB 0B
core/Selector 77.0KB 77.2KB +185B 15.2KB 15.2KB 0B
core/SideNavHeading 59.2KB 59.4KB +199B 10.4KB 10.4KB 0B
core/TabMenu 55.9KB 56.1KB +175B 10.2KB 10.2KB 0B
core/Table 84.7KB 84.9KB +186B 15.6KB 15.6KB 0B
core/TableCell 76.5KB 76.7KB +216B 15.3KB 15.3KB 0B
core/TableHeaderCell 76.1KB 76.3KB +185B 15.0KB 15.0KB 0B
core/TopNavHeading 58.6KB 58.8KB +204B 10.2KB 10.2KB 0B
core/TopNavMenu 58.3KB 58.5KB +214B 10.3KB 10.3KB 0B
lab/CommentThread N/A 217.0KB N/A N/A 2.4KB N/A
lab/Comments N/A 221.5KB N/A N/A 2.5KB N/A
richtext/RichTextEditorToolbar N/A 213.2KB N/A N/A 454B 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.

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 35.4KB 35.4KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 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 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | 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.

Changes requested. The menu handoff itself matches AST-094 FR29: the finger that opened the menu picks only after moving 10 px, and presentation="popover" keeps the released rule. But this branch also carries the still-open #8076, #8298 and #8446, so the diff is about 21,400 lines for a roughly 1,100-line change. Please land those pull requests first, or wait for them, and rebase onto main so this one carries only its handoff change; if the resolver ships in the same release, also drop "No component reads it yet; behavior is unchanged" from .changeset/presentation-resolver.md.

[Automated review]

Comment thread .changeset/presentation-resolver.md Outdated
'@astryxdesign/core': patch
---

[chore] An internal `usePresentation` / `readPresentation` resolves a component's `presentation` prop into the surface to render and whether its touch input modes apply, keeping an omitted value distinct from an explicit `'popover'` so a caller's choice always wins over a touch default. No component reads it yet; behavior is unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head DropdownMenu reads usePresentation (packages/core/src/DropdownMenu/DropdownMenu.tsx:1784) and its behaviour changes under a finger (.changeset/menu-press-handoff.md), so "No component reads it yet; behavior is unchanged" is no longer true when both ship in one release. Ship this resolver in an earlier release, or drop that sentence.

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 #8298 8d3af2f, which this pull request now carries.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 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.

Changes requested. The menu handoff itself matches AST-094 FR29: under a finger, the finger that opened the menu picks a row only after moving 10 px, and presentation="popover" keeps the released rule. But this branch still carries the open #8076, #8298 and #8446 (17,553 authored lines for a 1,094-line change), and it now conflicts with main in packages/core/src/motion/animator.ts and packages/core/src/motion/README.md. Please land those pull requests first, or wait for them, then rebase onto main so this one carries only the handoff change.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
meta#8080's dependencies in one commit, so its compare link shows only its own change: meta#8078 (carrying meta#8627, meta#8298 and meta#8076) and meta#8186, the layer runtime's exit copy.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 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.

Changes requested: the DropdownMenu handoff itself matches AST-094 FR29 and FR43 (a finger or pen picks only after moving 10 px from where the open menu first sees it, and presentation="popover" keeps the released rule), and the latest commit cleanly drops the unused presentation resolver call. The diff still carries the open #8076, #8446 and #8298, which make up 16,256 of its 17,387 authored lines next to this change's 1,131. Please land those pull requests first and rebase onto main, or rebase the handoff commits alone onto main with the one @astryxdesign/interaction-harness dev dependency their browser spec needs, so the diff holds only the handoff change.

[Automated review]

… moves (spec:AST-094 FR29, FR43)

Under a finger or pen, on every device, a DropdownMenu or MoreMenu opened
under a resting finger picked the row beneath it once the press had lasted
300 ms. The press machine's open phase now takes UIMenu's handoff: until
the finger is 10 px, in a straight line, from the first position the open
menu sees, nothing lights and a lift acts on nothing; then it slides and
picks as usual. A mouse keeps the settle rule, and presentation="popover"
pins the pointer model.

- menuPressGesture: the handoff state and transitions, on internal types;
  the released menuPressStep keeps its public shapes.
- useMenuPress: the internal useDropdownMenuPress entry takes a touch
  model; the public hook is unchanged.
- menuPressValues: UIKit's handoff distance, copied from the constants
  table so a menu bundles the number, not the table.
- DropdownMenu and MoreMenu run the touch model unless presentation is
  "popover"; docs and a changeset say so.
- A real-browser spec (DropdownMenuTouchHandoff); core declares the
  interaction harness it imports.
@vjeux
vjeux force-pushed the uikit/c19-menu-handoff branch from b38a16c to 503bc5b Compare October 28, 2026 16:27
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Oct 28, 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 looks ready to approve: the DropdownMenu change matches AST-094 FR29 and FR43, so under a finger or pen the finger that opened the menu picks only after moving 10 px from where the open menu first sees it, while presentation="popover" keeps the released settle rule, and the branch now carries only this change. The handoff unit tests and the Chromium touch spec pass, and a mouse behaves exactly as before. One optional cleanup: the extra TOUCH_QUERY and COMPACT_QUERY answers in the adaptive-presentation test's media stub are not needed by this change and could be dropped, with the COMPACT_QUERY import.

[Automated review]

Comment on lines +383 to +387
matches: [
COMPACT_TOUCH_PRESENTATION_QUERY,
TOUCH_QUERY,
COMPACT_QUERY,
].includes(query),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: nothing DropdownMenu renders at this head reads TOUCH_QUERY or COMPACT_QUERY, and this test passes with the original one-query stub, so this edit (and the COMPACT_QUERY import) could be dropped to keep the diff to the handoff.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
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.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
… 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.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8078 now takes meta#8627 503bc5b, meta#8360 045f666 and meta#8298 8cb7c3a.
handoffPosition's comment takes meta#8078's FR43 wording and keeps the
continued finger (FR33).
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…el keys on presentation) as a dependency
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
… device (spec:AST-094 FR43)

The public hook's unset default runs DropdownMenu's touch model for a
finger on every device: the coarse-pointer read goes. A set
longPressDelayMs keeps the released wait, so the two cases that show an
untyped touchModel reaches nothing now set one. From the review: the
changeset names the 10 px handoff and longPressDelayMs: 500 as the way
back, and meta#8627's changeset no longer says the public hook is unchanged.
The docs say "on every device".
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8078 now takes meta#8627 503bc5b, meta#8360 045f666 and meta#8298 8cb7c3a.
useMenuPress.ts keeps meta#8078's detector type; the useMediaQuery import
has no reader left and goes.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…el keys on presentation) as a dependency
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8080's dependencies in one commit, so its compare link shows only its own change: meta#8078 (with meta#8627) and meta#8186 with meta#8076, on main c86dc62.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
meta#8080's dependencies in one commit, so its compare link shows only its own change: meta#8078 (with meta#8627) and meta#8186 with meta#8076, on main c86dc62.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
…ency

meta#8627 no longer resolves the touch modes: since the press keys on presentation, nothing below reads them. The grow and collapse here do (spec:AST-094 FR31: a finger's on a touch device), so this pull request now brings them: usePresentation's touch default for the surface, and the popover's touchModes prop.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
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
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.
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 — 503bc5b0 Deployed Oct 28, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant