Repository navigation
DropdownMenu: under a finger the menu grows out of its trigger and collapses back into it (UIMenu) - #8080
DropdownMenu: under a finger the menu grows out of its trigger and collapses back into it (UIMenu)#8080vjeux wants to merge 1805 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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, becausecloseMenuchanges 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 oldlongPressDelayMsdefault and the option that restores it in the changeset.
[Automated review]
| doc.removeEventListener('pointerup', handleEnd, true); | ||
| doc.removeEventListener('pointercancel', handleCancel, true); | ||
| }; | ||
| }, [isPopoverOpen, closeMenu]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | | |||
There was a problem hiding this comment.
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?
| '@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. |
There was a problem hiding this comment.
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).
PR Analysis Report
Modified ComponentsContextMenu (@astryxdesign/core)
MoreMenu (@astryxdesign/core)
Component Size
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.
|
| 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
f4634c0 to
9f00657
Compare
There was a problem hiding this comment.
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: fixedandz-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
longPressDelayMsdefault and its restoring option in the changeset, and recommend onlytouch-action: pinch-zoomfor a press-open trigger.
[Automated review]
| 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'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
|
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. |
|
Agreed: one implementation.
|
9f00657 to
e88dc4c
Compare
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.
… 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.
…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).
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.
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.
There was a problem hiding this comment.
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
useMenuPressnow 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(); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
"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 | |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
… 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.
…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.
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-094FR31 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-094FR31 and FR32 (current);component:DropdownMenuFR21 (on main, #8515). A mouse orpresentation="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-094FR35), 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.
Android Chromium 158 emulator, the same taps:
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
Testing
On the DropdownMenu "Default" story, with touch emulation:
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.