Skip to content

feat(DropdownMenu, useMenuPress): a finger follows the menus’ finger rules on every device (AST-094 FR43) - #8702

Closed
vjeux wants to merge 1033 commits into
meta:mainfrom
vjeux:uikit/c19-menus-every-pointer
Closed

vjeux wants to merge 1033 commits into
meta:mainfrom
vjeux:uikit/c19-menus-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Depends on #8220, #8508, #8531. Review only this PR's change: vjeux/astryx@da77fee...1784c30

User need

On a touchscreen laptop (a fine primary pointer), a finger in a DropdownMenu or MoreMenu still gets the released finger behavior: its press on the trigger opens nothing, the finger that opened the menu picks with no handoff, and a cancelled touch leaves its row lit. spec:AST-094 FR43 (approved in #8646) has a finger follow the menus' finger rules on every device.

Problem and solution fit

  • DropdownMenu read the resolved touch modes (a coarse primary pointer) to pick its press model. It now runs UIMenu's model for a finger on every device unless the caller pins presentation="popover": the trigger opens as the finger presses (FR29), the opening finger picks after the 10 px handoff, and a cancelled touch or a second finger clears the lit row (FR28).
  • useMenuPress with no longPressDelayMs follows FR29 on every device: the coarse-pointer read goes.
  • A mouse is unchanged (module:DropdownMenu/useMenuPress FR1–FR3). The touch modes stay the device's: the touch styling and the grow and shrink (FR31) are unchanged.

Current specification

spec:AST-094 FR26–FR30, FR43 and DEC-21 (approved at c46ace4); module:DropdownMenu/useMenuPress FR1, FR2, FR9 (current, as amended there).

Public delta and caller burden

No new prop or option. On a fine primary pointer, a finger now opens the menu as it presses; presentation="popover" keeps the released finger behavior, and longPressDelayMs on the hook keeps its wait. The changeset and the docs of DropdownMenu, MoreMenu and useMenuPress say so.

Design and alternatives

The press model is chosen per gesture by the pointer type the model already reads, so nothing reads the primary pointer for behavior. Rejected: changing what the touch modes mean, which also drive the touch styling and the 44 px target, both of which FR43 keeps coarse-only.

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

  • Red first: "fine primary pointer: a finger opens the menu as it presses the trigger", "the finger that opened the menu picks only once it travels the handoff", "a finger opens the menu once, as it presses" (module AR5) and the public hook's "with no delay set, a finger opens the menu as it presses where the primary pointer is fine too" fail at the test commit and pass at the fix; "fine primary pointer, presentation="popover": a finger keeps the released behavior" passes at both.
  • pnpm vitest run hooks, DropdownMenu, AlertDialog, ContextMenu, MoreMenu, Selector, MultiSelector, Layer, utils, gestures, motion and BottomSheet at this head: 3711 passed.
  • pnpm test:interaction DropdownMenuTouchPress, DropdownMenuTouchHandoff and DropdownMenuTouchMotion: Chromium 19/19, WebKit (Playwright image) 15 + 4 skipped.
  • The DropdownMenu, MoreMenu and menu press accessibility specs (Chromium): 157/157. fix(DropdownMenu): a cancelled touch clears the row a finger lit, in sub-menus too (spec:AST-094 FR28) #8508's two FR28 cases now wait for the menu to land before pressing: with DropdownMenu: under a finger the menu grows out of its trigger and collapses back into it (UIMenu) #8080's grow in the tree, a press during it hit the next row (three runs: 45/45).
  • pnpm build, pnpm typecheck and CI=true pnpm lint with no error; CLI isolation and golden reads 50/50; check:knowledge, check:changesets.
  • Main's scripts/touch-support-bundle.test.mjs "is absent from the root entry" fails here: DropdownMenu and the public useMenuPress now reach the gesture module (gestures/nativeScroll.ts, for FR29's scroll-view wait). test(core): a touch component's import carries its gesture code, and nothing else does #8671 makes that check per component and lists them.

Stack (merge in this order): #8076, #8298 and #8446 (any order) → #8627 → #8078 → #8508 and #8531 and #8186 → #8080 → #8089; #7839 → #8220; then this PR.

vjeux and others added 30 commits October 26, 2026 04:18
…fine pointer); the changeset says the same

animator.test.ts keeps this layer's header (no compositor browser spec in
this layer). The changeset now says decay sends a spring back from past an
edge through the Reduce Motion policy: a cross-fade on a coarse pointer, at
once elsewhere.
…side a scroll view

The press profiles' README said a cell's highlight always waits the
content-touch delay. PressTracker waits it inside a scroll view and lights
at once outside one (spec:AST-094 FR11), as its own header says; the README
now says so too.
What one call becomes under Reduce Motion (an opacity-only cross-fade
whose travel lands while the element is transparent, or everything at
once; a dip on a borrowed stylesheet opacity) moves from createAnimator's
closure to reducedMotionPlan.ts, a pure planner that reads the animator
through a view and returns the plan; the animator turns the plan into its
segments and runs the landings. No behavior change: the animator's tests
pass unchanged, and the planner has its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ed pull requests

This pull request keeps the animator's Reduce Motion plan (now its own
planner module), the browser-set start of a call from rest, promotion,
atRest, rebase and animatorFor. Two features it carried move up the stack,
each with its own tests and changeset:
- play(curves), baking any closed-form curve like a spring;
- driving a pseudo-element (a dialog's ::backdrop).
The changeset is split the same way.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
play(curves) bakes any curve given as value and velocity functions of
time (a spring chasing a moving target from solveSpringTowardRamp, a
fitted UIKit curve) exactly like a spring: presentation values,
velocities, stop, finished and the shared clock work the same, a later
animate starts from the curve's value and velocity, and under Reduce
Motion a curve that moves is travel the policy replaces. Tests and a
changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
createAnimator(element, {pseudoElement: '::backdrop'}) animates a
pseudo-element: with no inline style, a static filling animation holds
its value after a curve ends and after set; a custom property on its host
promotes it; a Reduce Motion dip borrows and hands back the
pseudo-element's own opacity, and its holds follow a node replacement.
animatorFor keeps one animator per element and pseudo-element. Tests and
a changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ot the page's frames around it

"a harness read inside a trace is not the page's long task…" also asserted
that no long task started anywhere in the read's window. On CI's runner each
frame of the fixture's 1,200 running rows took 51-54 ms, the page's own long
tasks, and failed it; at 4x CPU locally they take 126-220 ms. The read
itself was set apart correctly.

The test now asserts the read's own classification, and removes the rows
before the timer, click and direct-call windows, so each holds only its own
work. It passes at 4x CPU, where the previous assertion failed.
…ot hold up a wait) into the frame evidence
…hed curve

waitForAnimation's finished branch read every recorded animation, so a
scroll- or view-timeline animation that reached its end (a scroller scrolled
to its end) came back as finished with a NaN duration and interval. The
recorded set now keeps document-timeline animations only. The scroll-timeline
self-test scrolls its scroller to the end first; it returned the NaN entry
before (Chromium, 3x repeat pass). The README, JSDoc and file header name
watchAnimations where they list what moves the wait window and what the file
exports.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…rves), pseudo-element support) as a dependency

# Conflicts:
#	packages/core/src/motion/README.md
#	packages/core/src/motion/animator.ts
… a finished curve) into the recorder stand-down
…ion plan

Main carries meta#8055 as one commit; this branch already had its head
(8fc7f2e), so the merge keeps this branch's animator, coast, test and
changeset files over it and takes main's other changes as they are.
… motion-role animator

# Conflicts:
#	packages/core/src/motion/animator.ts
…into the experimental touch layer

# Conflicts:
#	.changeset/uikit-motion-animator.md
The travel list moved to the planner with the Reduce Motion plan; nothing
in the animator reads it any more (no-unused-vars).
…deselect clock; the animator changeset drops its no-component clause

The press profiles' README gave a control UIButton's 0.25 s release.
PRESS_PROFILES releases every surface on the list's deselect clock until a
control runs its own release, as its comment says; the README now says so.
Its test list still credited VelocityTracker's tests with checking each
strategy against the true velocity, though one filter remains: it now
names what they check. The animator's changeset said no component uses
it, which goes false in the release that carries the components that do.
…removed at its end is still reported

- Arming the recorder now records first and stamps strictly after the
  newest entry: a watch taken right after a step that changed style no
  longer reports that step's transition (created by the arm's own style
  read) as started after it.
- Each entry keeps its target's ancestors as recorded, so an exit whose
  element the page removes at its end is still reported for the container
  it left, instead of a wait rejecting with nothing recorded.

A self-test for each; both failed before (Chromium, 3x repeat pass).

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
solveSpringTowardRamp went with play(curves)'s tests, and a coast test
destructured an element it never reads (no-unused-vars).
play(curves)'s tests use solveSpringTowardRamp, so this layer keeps its
import.
@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 27, 2026 2:11pm 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
❓ Needs maintainer input (for maintainers only) Oct 29, 2026, 10:39 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 27, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded cancelled. Current analysis is unavailable.

View Storybook for this PR · View Sandbox for this PR


Generated by PR Enrichment workflow | View current CI run

@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 pull request is on hold for spec decisions: making a finger on a fine-pointer touchscreen follow the menus' finger rules contradicts the current spec:AST-094 FR19, its menu-scope paragraph and FR24, and module:DropdownMenu/useMenuPress FR1, FR2, FR7 and FR9. The FR43 and DEC-21 amendment it implements is still in open #8646, so that amendment, plus a decision on OQ10 for the stacked in-place sub-menu that FR34 currently rules out, has to land on main first. The stacked record changes (DropdownMenu's FR20 and new FR23, DropdownMenuSubMenu's Sheet state, and architecture:layer-runtime's new INV13) need an engineering owner to approve them by commenting /approve-spec 1784c3023b2bc5a3c73e754a6ff0f8f34a4fc284 on this pull request; the author may do this as an engineering owner. Independent of those decisions, please address the following:

  • Rebase once the twelve stacked pull requests land, so this PR carries only its own change rather than 26,203 authored lines.
  • Sub-menu flyouts still take touchModes as their touch model (DropdownMenu.tsx:1908), so on a fine-pointer touchscreen a cancelled touch leaves a flyout row lit while the root menu clears it.
  • scripts/touch-support-bundle.test.mjs fails at this head because useMenuPress now reaches gestures/nativeScroll.ts; carry #8671's change or land it first.
  • .changeset/menu-press-public-default.md, .changeset/menu-press-handoff.md and .changeset/dropdownmenu-submenu-in-place.md describe behavior this head no longer has, so they need rewording.

[Automated review]

host: surfaceHost,
ownerId: menuId,
menuMaxHeight: resolvedMaxHeight ?? undefined,
touchModel: touchModes ? 'uikit' : 'pointer',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a fine primary pointer touchModes is false while the root's press runs pressTouchModel ('uikit'), so every sub-menu flyout (DropdownMenuSubMenu.tsx:1085) keeps the pointer model: a cancelled touch leaves its row lit there but clears it in the root. Deriving this from pressTouchModel, with the memo dependencies to match, keeps one model per menu; a fine-pointer flyout cancel case beside #8508's would pin it.

'@astryxdesign/core': patch
---

[feature] `useMenuPress` with no `longPressDelayMs` now opens the menu as a finger presses on a touch screen too (a coarse primary pointer), with DropdownMenu's timing (`spec:AST-094` FR25, FR29); pass `longPressDelayMs: 500` to keep the previous 500 ms hold. Where the primary pointer is fine it keeps the 500 ms hold.

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 a fine primary pointer no longer keeps the 500 ms hold: useMenuPress.ts:409–410 runs the 'uikit' model whenever longPressDelayMs is unset, as .changeset/menus-every-pointer.md says. Shipped together, these two notes contradict each other.

'@astryxdesign/core': patch
---

[feature] Under a finger, the finger that opened a DropdownMenu or MoreMenu now picks a row only after it has moved 10 px (`spec:AST-094` FR29), so a menu that appears under a resting finger never picks the row beneath it, however long the finger rests. To keep the previous behavior, where that finger's lift picked once the press had lasted 300 ms, pass `presentation="popover"`. A mouse and the public `useMenuPress` hook are 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.

The public useMenuPress hook now runs the same 'uikit' model, handoff included, whenever longPressDelayMs is unset, so "the public useMenuPress hook [is] unchanged" no longer holds at this head.

'@astryxdesign/core': patch
---

[feature] On a phone, a DropdownMenu sub-menu opens in place as UIMenu's does on iOS 26. The sub-menu's card grows down from the row it opened from, and the menu's box grows with it from the edge it is anchored by. The parent's rows stay under the card, faded to half and scaled to 97%. The sub-menu's header keeps the row's label, now semibold, with its chevron turned down; it is still named "Back to <parent>". Going back reverses it. The box plays UIKit's sub-menu springs. Under Reduce Motion the views cross-fade.

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 touch header row's accessible name is now <label>, Back to <parent> (DropdownMenuSubMenu.tsx:1148–1155, @astryx.dropdownMenu.subMenuHeader; the test expects "Move to, Back to Actions"), not Back to <parent>.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: its parts now live where each behavior starts, in #8627, #8078, #8508 and #8531. Closing in their favor; the branch stays.

@vjeux vjeux closed this Oct 30, 2026

This branch was successfully deployed

1 active deployment
Preview — 1784c302 Deployed Oct 27, 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