Skip to content

feat(ContextMenu): open from a held finger on iOS 26's timeline - #8125

Open
vjeux wants to merge 192 commits into
facebook:mainfrom
vjeux:uikit/c20-contextmenu-press
Open

vjeux wants to merge 192 commits into
facebook:mainfrom
vjeux:uikit/c20-contextmenu-press

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8065, the gesture stack (#8336 on #8315, #8309, #8270 and #8076) and #8298 (the presentation resolver); review the last five non-merge commits.

Evidence

iOS 26.2 Safari simulator; HUD: ms since the touch, clicks; dashed: the card at rest.

Before (main): no feedback, the menu at ~500 ms, first row lit; the closing tap clicks.
main: held finger

After:
#8125: held, lifted after the commit, lifted early
#8125: tolerance, drags, no scroll
#8125: main thread blocked #8125: Reduce Motion

Android 15 emulator, Chromium 158: #8125 on Android

presentation="popover", today's long press: #8125: popover keeps today's 500 ms long press

Second finger (Chromium): #8125: second finger

User need

A held finger should open a context menu on UIKit's sequence: growth from 150 ms, the menu at ~0.74 s, the page still.

Problem and solution fit

One touch model where the primary pointer is coarse (AST-094 FR13), resolved by #8298's usePresentation(presentation, {whenOmitted: 'touch'}). Where it is fine, and with presentation="popover" on every device (INV16), a finger keeps today's 500 ms long press: no recognizer, no growth.

  • The hold runs on useLongPress's detector with chore(motion): ContextMenu's touch timeline and press curve, measured on iOS 26.2 #8065's values: the commit at 400 ms claims the finger (no click, no scroll), the menu opens at 735 ms, or at a lift after 400 ms. A drift past 11.5 px, a second finger, an early lift or a scroll opens nothing. The recognizer takes a finger or a pen (allowedPointerTypes, UIKit's allowedTouchTypes).
  • The trigger is a pressable surface of core's one press machine (utils/pressFeedback.ts); the growth starts at its "on" (150 ms) and springs back when the press ends with no menu. A context-menu press profile is proposed to the controller's owner; follow-up: a press-state callback replaces the growth's MutationObserver.
  • Fixed here: outside the touch model a finger was left to the browser, so presentation="popover" opened nothing in iOS Safari (no contextmenu for a long press).
  • Reduced motion (design:reduced-motion, through the animator): no growth, the menu on time.

Current specification

component:ContextMenu is a draft; AST-094 (FR4, FR13, FR25, FR33) and design:reduced-motion govern.

Owner decisions, proposed; pending owner review:

  1. Under a finger, touch follows UIContextMenuInteraction's timeline (150/400/735 ms, per-axis drift).
  2. A block trigger grows about 6% while its menu is up.
  3. A touch-opened popover locks page scroll.
  4. No haptic in iOS Safari (it plays one only on a tap).
  5. An omitted presentation means the touch model under a finger (AST-017 FR3).

Public delta and caller burden

useLongPress takes stages and start(point, time); ContextMenu's presentation default becomes the touch model. Touch layer: ContextMenu/ContextMenuTouch.ts, its one import site. Size: +2684/−213, about 60% tests.

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

  • pnpm vitest run packages/core/src/{ContextMenu,hooks,utils,Layer,gestures,motion}: 2106 pass. The pressable coverage guard counts a script that follows the pressed state (getAttribute(PRESSED_ATTRIBUTE)) as a press arm, as ContextMenu's growth does. The touch suite runs on iOS and Android stubs alike. presentation="popover" under a finger opens at 500 ms through today's long press (failed before the fix on both stubs: no menu), and a drift past 10 px or an early lift opens nothing. Where the primary pointer is fine, a finger opens at 500 ms with no growth, no scroll lock and no handoff, and a right-click keeps the cursor menu. The same finger opens at 735 ms coarse and at 500 ms fine. useLongPress covers stages, tolerance after the long press, and deadlines counted from a given time on a given clock.
  • pnpm test:interaction ContextMenuLongPress: Chromium 19/19, WebKit 16/16 (+3 Chromium-only; the fine-primary touch case is skipped, as both engines report a coarse primary pointer whenever touch is emulated), under each engine's own finger with no iOS shim:
    • presentation="popover": the menu at 500 ms (not 735), with no growth;
    • the menu at 735 ms, the anchor under the finger, the press curve within 0.033 pt rms of UIKit's;
    • growth through a 450 ms block, 0 Layout and 0 Paint during it, no page scroll;
    • reduced motion: no growth, the menu still at 735 ms;
    • the selection and callout lock comes from the gesture host;
    • a theme's feedback latency of 300 ms keeps the card at rest at 350 ms.
  • Android 15 (Chromium 158) and iOS 26.2: every path above, reviewed frame by frame; iOS 26.2 shows the popover long press at 500 ms.
  • Core typecheck, eslint, prettier, check:changesets, the experimental-isolation golden (no experimental surface), the table-import rule.

vjeux added 10 commits October 23, 2026 20:05
Adds internal/interaction-harness (@astryxdesign/interaction-harness) and
playwright.interaction.config.ts: phone-sized touch projects in Chromium and
WebKit for *.interaction.browser.spec.ts specs.

Touch scripts are data (tap, drag with hold and rest, flick with an exact
release velocity and velocity profiles, hold-then-drag, pinch, rotate, any
path, a second finger mid-gesture), played as real engine input: CDP
Input.dispatchTouchEvent with scripted timestamps in Chromium, WebKit's own
input command in WebKit. The harness's self-test proves the timestamps, a
native fling and multi-touch delivery; a pr-interaction CI job runs the
proofs against each PR's Storybook build, and a container script runs the
WebKit project where host libraries are missing.
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
…sholds

Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt
apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on
the event clock within 10 pt, even when the main thread was blocked, and a
zero duration begins on the touch itself. The arena tests with these real
recognizers cover tap against double tap, delegate requirements, deferred
clicks and cancellation.
Touch behaviours key on a coarse primary pointer, layout behaviours on a
compact viewport, and presentations that need both (a sheet in place of a
popover) ask for both. utils/touchPresentation.ts holds the three queries,
SSR-safe hooks (useTouchPresentation; useMotionPresentation for components
with no interaction to infer from: UIKit motion under a finger, theme
tokens under a mouse) and a one-moment read. Styles write the same queries
as StyleX media keys, and a test keeps core's compact-touch keys spelled as
the shared query.

A compact viewport is narrower than 744 px or shorter than 500 px, the
closest a size gets to UIKit's compact size class: an iPad mini in portrait
keeps the anchored popover and a phone in landscape gets the sheet; a
Max-size iPhone in landscape is the known miss. The adaptive popover-or-
sheet presentation reads the shared query; its docs, stories and tests
follow.
A file that reads only defineConsts values from a .stylex.ts module lost
that import under the legacy {stylexOptions} form Storybook uses: the
babel wrapper's StyleX instance ran without treeshakeCompensation (the
unplugin applies its Vite default only to its own instance, which runs
after the wrapper), so once stylex.create was compiled the TypeScript
transform dropped the import and the defining module never reached the
build. A constant used as a media-query key failed the build with
"Invalid empty selector"; one used as a value shipped as a custom
property nothing declares. The wrapper now gets treeshakeCompensation by
default.

The layer split also substituted constants only within each layer, so a
constant defined on the library side and read on the product side stayed
a placeholder in either form; every layer now sees every constant.

A production build now warns, naming the variable and the rule that reads
it, when the StyleX stylesheet reads a generated custom property nothing
declares; undefinedVariables: 'error' fails the build instead, and this
repo's Storybook and Sandbox configs set it so CI catches the next case.
Pure functions for the touch presentations: a closed-form damped spring
with every Apple parameterization and Core Animation's settling duration,
a spring chasing a moving target, UIScrollView's coast, rubber band and
release projection. Tests hold them to an RK4 integration, WebKit's spring
solver, the WWDC conversion equations and values read from UIKit, and play
the constants table through them.
createAnimator plays springs and UIScrollView coasts as Web Animations
keyframes on translate, rotate, scale and opacity, on one explicit clock, so
they keep running while JavaScript is busy. Presentation values and
velocities come from the curves, so a finger can catch any animation and a
new target keeps the velocity; the end state is committed inline and held by
the keyframes through a main-thread block, and a finished animation retires
when its own time reaches the curve's end, so a sampler can pause it; Reduce
Motion substitutes UIKit's cross-fade.
@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 26, 2026 7:02am 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 26, 2026, 8:23 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
vjeux added 5 commits October 23, 2026 23:31
… Chromium

The Chromium driver sent a lift of one finger of several as a touchMove
listing the fingers still down, assuming CDP releases a point a move
omits. It does not: the lifted finger stayed down. A touchEnd that lists
the lifting fingers releases exactly those while the others keep moving,
so the driver sends that, and a self-test lifts one of two fingers and
checks the release, the other finger's moves and the touchend's
remaining touches.

Playwright's WebKit sends that touchend with no remaining touches, which
iOS never does. The harness does not work around it: drivers expose
partialLifts, and PARTIAL_LIFT_SKIP is the reason a proof cites when it
skips; the README lists it with the other engine limits.
Pause every Web Animation, seek it to a time and read the presented
transform and opacity, then compare the curve with oracles that share no
code with the motion under test: cubic-bezier, the SwiftUI spring
(response, dampingFraction, initial velocity), UIScrollView deceleration
and UIKit's rubber band. Storyboards photograph seeked animations at
t = 0, 16.7, 33.3 ... ms or each step of a scripted touch, and write a PNG
contact sheet plus the JSON trace a spec asserts, under the gitignored
test-results/interaction-evidence. A timer gate holds a component's own
timers while a filmstrip is taken. pnpm storyboards runs the tagged tests.
…sented values

An animation that keeps its end value in a run of identical final
keyframes (held so a later interruption can read it) reported its whole
duration as the curve: storyboards filmed the hold, waitForAnimation's
interval reached past the landing, and waitForRest, which also required
every animation to leave the running state and read only transform, timed
out. The curve now ends where that run starts (HeldAnimation.curveEndMs,
waitForAnimation's to, the default storyboard times), and rest is no
animation pending or before its curve's end plus the presented transform,
translate, rotate, scale and opacity (and ::backdrop opacity) still for 3
frames, whatever the playState. sampleCurve composes translate, rotate and
scale with transform, so curves of the individual properties are sampled.
Timestamped composited frames (Chromium's screencast and WebKit's own),
a marker the frames can find, and the busy-main-thread proof: block the
page's main thread mid-flight and show the element still moving every
frame, with a requestAnimationFrame animation that freezes as the
negative control. Chromium trace counts of Layout, Paint and style work
inside an animation window, and long tasks. The exemplar spec proves the
BottomSheet drag to a detent with all of it: the drag follows the
finger, a flick dismisses, the settle matches its timing function, keeps
advancing through a blocked main thread in Chromium and WebKit, does no
Layout or Paint, and gets a drag and settle storyboard.
…ed machine

The proof asserted a stall under 40 ms and near-exact frame counts, so a
dropped screencast frame or two on a loaded runner failed it, and frames
delivered out of order read as the element stepping backwards.
busyWindowProof now returns a verdict that tolerates what load does to a
screencast and nothing a frozen animation does: a median frame interval
of at most 20 ms, no stall longer than three screencast intervals, and
at least 90% of the window's whole frame intervals presenting a new
position (one miss always allowed), with the raw numbers in a summary.
Screencast frames are sorted by presentation time, the cadence comes
from the frames around the window, and blockMainThread can block from
inside the page in the frame an animation has run, so a short settle is
still judged at length on a busy machine. The exemplar judges its settle
until it is within 2 px of rest, since the screencast rounds to whole
pixels. A unit test covers the limits; the rAF negative control still
fails every criterion.
vjeux added 6 commits October 23, 2026 23:46
The pan begins past 10 pt on either axis and leaves that travel out of its
translation, as UIPanGestureRecognizer does; axis and one-way directions
with axisRatio, axis lock, finger changes without jumps, scrollEdge, and an
opt-in non-UIKit scrollHandoff. VelocityTracker defaults to UIKit's filter
(0.2 x the newest event's velocity + 0.8 x the previous one's), which
reproduces every value UIKit reported on recorded streams.
…zers

The rest of UIKit's recognizer set on the gesture system: swipe recognized
during the motion, pinch measured from the starting span moved by its
hysteresis, unwrapped rotation, edge pans resolved against the writing
direction that fail at touch-down outside their band, and hover for mice
and hovering pens.
The touch-action an element gets from its recognizers (pinch-zoom stays
unless a pinch or rotation recognizer is attached), what the scrollers
under a touch could still do along the scroll chain, and UIKit's haptic
feedback kinds on the web: navigator.vibrate where it exists, the switch
tap haptic in iOS Safari.
…s takeover

One host per document routes fingers (touch events, paired with their
pointer events), mice and pens to the recognizers on the hit path. A
recognizer that owns a touch cancels its touchmove through a non-passive
listener; pointercancel, an uncancelable move or a scroll mean the browser
took the touch; a claimed touch's touchend is cancelled and its click
swallowed, a deferred click replayed when its recognizer fails.
pointerIdOf names the pointer behind a tracked contact, so a held finger
can be handed to code that follows pointers.
… and hooks

PressTracker applies UIControl's events and its 70 pt tracking margin, the
content-touch delay inside scrollers, the quick-tap flash and the scroll
brake, with two profiles: control (UIButton) and cell (a row: delayed
highlight everywhere, cancelled past the pan hysteresis, no flash).
paintPress is the one writer of data-astryx-press and never clobbers a held
row. installPressAttribute paints every marked surface with one tracker
per touch, picks the profile from the marker's value and releases on its
clock. useGestureRecognizer attaches a recognizer with a stable identity
and no render per move.
The same touch scripts run in Chromium (CDP touches with timestamps) and
WebKit (its own touch input): touch-action per element, claims over a
scroller, no ghost click, scroll edge, pinch on its element only, a second
finger, and a long press at exactly 500 ms under a blocked main thread.
The README documents the system for component authors.

@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 an engineering owner's spec decision. ContextMenu's new touch timeline (growth from 150 ms, commit at 400 ms, menu at 735 ms), the trigger growing while its menu is up, the touch scroll lock and the no-haptic choice for iOS Safari rest only on the draft ContextMenu record and the unapproved spec:AST-094. An engineering owner can record the decision by commenting /approve-spec <full head SHA> on this pull request (the author can do so as an engineering owner), or say which decision should change. Whatever that decision is, these changes are still needed:

  • Land the stacked predecessors first, or retarget onto the last of them, so that this diff is the 1,840-line ContextMenu commit rather than 24,862 authored lines.
  • Regenerate the search close CLI golden, which the new ContextMenu guidance pushes from 44 to 45 matches, so that the required test check passes.
  • Re-attach the press recognizer when the trigger node changes, because a triggerAs switch currently leaves it on the detached element (useGestureRecognizer.ts:71-79, ContextMenu.tsx:882).
  • Move the setIsTouchOpen(false) and release() calls from the Effect at ContextMenu.tsx:744-753 into the close handlers, and restore the two consumer-handler regression cases from #7167.
  • Add the ## Writing rubric results for the new AST-094 record to the description.

[Automated review]

{
guidance: true,
description:
'Leave a little room around a block trigger: a held finger grows it from its center, by about 6%, until the menu closes.',

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 guidance ends with "until the menu closes", and the CLI search scores guidance text. The required test check fails here: test-node (1/4) finds 45 matches for astryx search close where packages/cli/test/__golden__/no-icon-set/search-close.json expects 44. Please regenerate that golden, or reword the line.

Comment on lines +747 to +753
useEffect(() => {
if (wasOpenRef.current && !isOpen) {
setIsTouchOpen(false);
pressFeedbackRef.current.release();
}
wasOpenRef.current = isOpen;
}, [isOpen]);

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 sets state (setIsTouchOpen(false)) and starts the spring-back in reaction to isOpen, and it synchronizes no external system (react-component-runtime INV2/INV3). The handlers that close the menu can do both calls directly: the layer's onHide and updateOpenState(false). Then the trigger springs back with the close itself, and the extra render goes away.

Comment on lines +71 to +79
useIsomorphicLayoutEffect(() => {
const element = ref.current;
if (element == null) {
return undefined;
}
return getGestureHost(element.ownerDocument).attach(element, recognizer, {
manageStyle,
});
}, [ref, recognizer, manageStyle]);

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 attach is keyed on the ref object, so it never re-runs when the node behind the ref changes. ContextMenu swaps its trigger between div and span with triggerAs, and after that swap the recognizer, its touch-action and its non-passive listener stay on the detached node, so a held finger opens nothing in iOS Safari. Attaching through a callback ref, or re-running the attach on node change, handles replacement (react-component-runtime INV6).

expect(document.body.style.position).toBe('');
});

it('passes touch events through to the consumer’s own handlers', () => {

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 replaces two #7167 regression cases only in part. Please add a held press with consumer touch handlers that still opens at 735 ms, and an early lift whose consumer onTouchEnd calls preventDefault() and opens nothing (component-test-sufficiency INV3).

@vjeux

vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Two regressions at 265cec4, with a fix offered on top of it (not pushed here): vjeux@9a763f4 (branch uikit/c20-contextmenu-press-loadfix on the fork).

  1. CI test-ui (2/4): utils/pressableCoverage.test.ts › "marks nothing that does not paint a press" fails on ContextMenu/ContextMenu.tsx. It now calls usePressFeedback() itself, while its press arm (getAttribute(PRESSED_ATTRIBUTE)) lives in the touch layer's press feedback.
  2. A finger that lands before the touch layer has loaded opens nothing. Under the touch model today's long press is off, and iOS Safari sends no contextmenu for a long press, so until the lazy layer runs a held finger gets no menu. In the touch long-press story, a press 30 ms after the story rendered opened nothing in Chromium and in WebKit; 2 s later it opened normally. ContextMenuLongPress.interaction at this head: Chromium 5/19, WebKit 4/16. CI's pr-interaction shows the same 14 Chromium failures.

The offered commit keeps today's 500 ms long press armed until the touch layer runs, and makes the trigger a pressable surface only then: the layer's handle carries the marker, so ContextMenu no longer calls usePressFeedback.

Tests in that commit:

  • jsdom, with the layer's module held loading: a 500 ms hold opens the menu (fails without the fix).
  • Chromium and WebKit, with the layer's chunk held back: a press gets the menu at 500 ms.
  • The touch specs wait for the layer to mark the trigger before pressing.

Results: unit 2109 pass, pressable coverage green, ContextMenuLongPress.interaction Chromium 20/20 and WebKit 17/17. It can go on this branch whenever you like.

@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 is on hold until an engineering owner decides two additions that no current spec covers. One is the page scroll lock under a touch-opened ContextMenu popover: spec:AST-094 names a page lock only for modal presentations, and its FR32 wants an outside pan to close an anchored menu. The other is the new public useLongPress stages and start(point, time) API. Any engineering owner can record 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 decision, these need changes:

  • A held finger that lands before the lazily loaded touch layer attaches opens nothing, which is why 14 of the ContextMenu interaction proofs fail in CI.
  • The test check fails on pressableCoverage.test.ts and, once merged with main, on the duration guard for the new pr-interaction job, which sets no timeout.
  • Removing the trigger's user-select: none lets a mouse select trigger text, a fine-pointer change (spec:AST-017 FR59).
  • Under AST-094 FR33, the touch-opened menu should sit beside the lifted trigger, the lift should follow the recognizer's drift rule, and a sheet presentation should not lift.
  • The diff carries about 36,000 authored lines of unmerged predecessors, which should land or be retargeted so this pull request holds only the ContextMenu change.

[Automated review]

onTouchCancel: handleLongPressTouchCancel,
} = useLongPress({
disabled: isDisabled,
disabled: isDisabled || touchModes,

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 the touch model this turns the 500 ms long press off, but the recognizer only exists once the lazily loaded ContextMenuTouchLayer has rendered and attached. A hold that starts before that opens nothing. In this head's static Storybook, a hold started as the story renders gets no menu, and the same hold after 1.5 s opens it. That matches the 14 ContextMenuLongPress proofs failing in pr-interaction. Keeping this long press on until the layer reports attached would close the gap.

Comment on lines -114 to -116
WebkitTouchCallout: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removing these from the trigger also removes them outside the touch model. With a mouse, the trigger's computed user-select is now auto, and a drag selects text inside a ContextMenu trigger, which the base prevents. Under presentation="popover" in iOS Safari, the comment above explained they stop the native text and callout UI from competing with the long press. spec:AST-017 FR59 keeps fine-pointer behavior unchanged in touch work, so these should stay on the trigger outside the touch model.

readPresentation(presentation, {whenOmitted: 'touch', compact})
.touchModes,
);
openAtLocalPoint(local.x, local.y, triggerRef.current, 'menu');

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 anchors the touch-opened menu at the finger, inside the lifted trigger. In a capture, the menu's top edge sits 74 px above the lifted card's bottom edge, over its corner. spec:AST-094 FR33 asks a held finger's ContextMenu on a block trigger to "lift the trigger above the page and show the menu beside it".

<Suspense fallback={null}>
<ContextMenuTouchLayer
triggerRef={triggerRef}
isShown={triggerAs !== 'span'}

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 grows the trigger for every surface, including presentation="bottom-sheet" and adaptive on a compact touch screen, where the card stays at scale 1.062 behind the sheet's scrim. AST-094 FR33 says "a menu presented as a bottom sheet follows the sheet", so the growth should also be off when surface === 'bottom-sheet'.

<TriggerElement
ref={useMergedRefs(ref, triggerRef)}
{...triggerProps}
{...(touchModes ? pressable : null)}

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 marks a disabled trigger too. The press controller skips only :disabled and [aria-disabled="true"], so it times the onset, writes data-astryx-press and locks selection on a disabled ContextMenu that has no touch layer to read it (AST-094 FR24). touchModes && !isDisabled ? pressable : null avoids that.

// The trigger is a pressable surface of core's one press machine
// (utils/pressFeedback.ts) under the touch model; the touch layer's press
// feedback follows that machine's pressed state.
const pressable = usePressFeedback();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since the growth moved into the touch layer, this file marks the trigger but no longer reads the pressed state. pressableCoverage.test.ts > "marks nothing that does not paint a press" now fails on ContextMenu/ContextMenu.tsx, and that failure is what turns test-ui (2/4) red.

if (next) {
start(performance.now());
} else {
fail(performance.now());

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 growth springs back when the press machine drops the press, at 10 px of straight-line travel (PRESS_SLOP_PX). The menu's recognizer keeps the press up to 11.5 px on either axis. A finger that drifts (8, 8) px before 735 ms therefore gets the menu over a trigger at rest. Ending the growth on the recognizer's own failed or cancelled would keep the two in step (AST-094 FR1, FR33).

},
);

useEffect(() => {

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 publishes the layer's handle to ContextMenu and names no external system it synchronizes (architecture:react-component-runtime INV3). useImperativeHandle on a ref that ContextMenu passes would expose the same handle without an Effect of its own.

Comment thread .github/workflows/ci.yml Outdated
# them, so a change to an instrument is proven by its negative controls
# before a component spec relies on it. Chromium only on these runners;
# see the harness README for running WebKit.
pr-interaction:

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 the merge with current main, test-node (4/4) fails main's CI duration guard with "pr-interaction has no pull-request timeout". This job needs a timeout-minutes within the guard's budget.

cixzhang and others added 4 commits October 25, 2026 14:25
…s the menu

The lazy touch layer (265cec4) left a finger that landed before the layer
attached with nothing to open the menu, which is why the pull request's own
interaction proofs failed when pressed as the story rendered.

- Under a coarse primary pointer the layer starts loading with ContextMenu's
  module, so it is usually ready before the first finger lands.
- Until the layer attaches, a finger keeps today's 500 ms long press, so an
  early hold still opens the menu.
- ContextMenu spreads core's static pressableProps marker instead of calling
  usePressFeedback itself; the layer installs the press machine. This keeps
  pressableCoverage.test.ts's rule that a file calling usePressFeedback
  paints a press.
- The trigger keeps main's iOS callout and text selection lock
  (spec:AST-017 FR59), so a mouse cannot select the trigger's text.

Test: a finger held before the layer attaches opens the menu at 500 ms
(the layer's module never arrives); it fails without the fallback.

@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 is on hold until an engineering owner decides two additions that no current spec covers: the page scroll lock under a touch-opened ContextMenu popover (spec:AST-094 names a page lock only for modal presentations, and its FR32 wants an outside pan to close an anchored menu) and the new public useLongPress stages and start(point, time) API. Any engineering owner can provide or identify the governing spec for them, or record approval by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • The branch conflicts with main in 13 files, so test, build and lint have not run; rebase onto main and keep its interaction harness and pr-interaction job.
  • Under AST-094 FR33 the touch-opened menu should sit beside the lifted trigger, the trigger should not lift behind a bottom sheet, and the lift should not drop when a finger drifts 10 to 11.5 px before the menu opens.
  • FR33's handoff (the opening finger picking a row after 10 pt of travel) and FR32's dismissal on an outside pan are missing under the touch model.
  • The Effect that publishes the touch layer's handle names no external system (architecture:react-component-runtime INV3), a disabled trigger still carries the press marker (FR24), and one changeset still says no component uses the context-menu constants.
  • The diff carries about 36,600 authored lines of unmerged predecessors, which should land or be retargeted so this pull request holds only the ContextMenu change.

[Automated review]

// Under the touch model the trigger is a pressable surface of core's
// one press machine (utils/pressFeedback.ts); the touch layer installs
// the machine and its press feedback follows the pressed state.
{...(touchModes ? pressableProps : null)}

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 marks a disabled trigger too. The shared touch press controller skips only :disabled and [aria-disabled="true"] (utils/pressFeedback.ts:77), so while any other pressable surface keeps it installed it times its onset, writes data-astryx-press and locks selection on a disabled ContextMenu that has no touch layer to read it (spec:AST-094 FR24). touchModes && !isDisabled ? pressableProps : null avoids that.

'@astryxdesign/cli': patch
---

[chore] The UIKit constants table gains ContextMenu's touch timeline as measured on iOS 26.2: the press feedback starts at 150 ms, the press commits at 400 ms, and the menu displays at 735 ms. It also records the drift tolerance, and the press feedback's growth, spring and display latency. A closed-form press-feedback curve, checked against UIKit's per-frame presentation, comes with them. No component uses them yet. The feedback's spring and display latencies are motion roles a theme can retune, and `astryx docs motion` lists them. The onset, the commit and the menu's moment decide when a press counts and who gets the touch, so they stay out of the theme.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"No component uses them yet" no longer holds in this diff: .changeset/contextmenu-touch-timeline.md ships ContextMenu on these constants, so the combined release notes contradict each other. Dropping that sentence, or letting this changeset leave with its predecessor when the stack is split, fixes it.

The harness, ci.yml, ci-test-routing and playwright.interaction.config.ts
take facebook#8045's canonical resolution. ContextMenu's doc and test keep this
branch's text (it already carries main's compact-touch wording).
One conflict: ContextMenu.test.tsx's imports, resolved to the union.

@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 is on hold until an engineering owner decides two additions that no current spec covers: the page scroll lock under a touch-opened ContextMenu popover (spec:AST-094 names a page lock only for modal presentations, and its FR32 wants an outside pan to close an anchored menu) and the new public useLongPress stages option and start(point, time) argument. An engineering owner can provide or identify the governing spec for them, or record the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • Land or retarget the predecessor pull requests so this diff holds only the ContextMenu change; it carries about 36,000 authored lines today.
  • Under AST-094 FR33, show the touch-opened menu beside the lifted trigger, leave the trigger unlifted under a bottom sheet, and keep the lift when a finger drifts 10 to 11.5 px before the menu opens.
  • Add FR33's handoff, so the opening finger picks a row after 10 pt of travel, and FR32's dismissal on an outside pan.
  • Reset the velocity tracker after a still pause and drop its alternative strategies (spec:AST-100 FR2), name the external system for the touch layer's handle Effect or drop it, keep the press marker off a disabled trigger (FR24), and correct the changesets that say no component uses this code.
  • Add real-device iOS Safari and Android Chrome evidence, and explain or fix ContextMenuItem's import growth from 16.4 KB to 53.4 KB gzip.

[Automated review]

// where it is; ContextMenu places the anchor under the finger.
feedbackRef.current.hold();
hasPressOpenedRef.current = true;
onOpenRef.current(press.press ?? press.location());

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 menu opens here, but the finger that opened it is never handed to the menu's press model, so under the touch model sliding it onto a row and lifting picks nothing. spec:AST-094 FR33 has that finger pick once it has travelled the handoff distance (FR26–FR28 from then on), and module:DropdownMenu/useMenuPress FR11, now current on main, makes a handed-off pointer the opening press. FR33 also sends a press outside to FR32, which closes the menu as an outside pan begins; ContextMenu still dismisses on an outside mousedown only.

'@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.

"No component reads it yet; behavior is unchanged" no longer holds in this diff: ContextMenu resolves its presentation with usePresentation (ContextMenu.tsx:359) and .changeset/contextmenu-touch-timeline.md ships that change, so the combined release notes contradict each other. Drop the sentence here, or let this changeset leave with its predecessor when the stack is split.

'@astryxdesign/cli': patch
---

[feat] Themes can retune touch motion roles. `defineTheme({motion})` takes `springs`, `durations` and `curves` keyed by motion role, the table path of a measured touch motion value (`springs.sheetPresent`, `navigation.transitionMs`), at the root or in an adaptation rule, and writes one `--motion-*` variable per role it sets. A role the theme leaves out keeps its measured default, an unknown role id is refused, and the duration scale (`fast`, `medium`, `ratio`) is optional when a theme sets only roles. No shipped component reads the roles yet. `astryx docs motion` lists every motion role with its CSS variable and default, and the theme authoring guide and template describe the new fields.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"No shipped component reads the roles yet" no longer holds in this diff: ContextMenu's press feedback reads its spring and latency roles (contextMenuPressFeedback.ts:149, :178), and the interaction proof "a theme retunes the press feedback (a motion role)" depends on it. Drop the sentence here, or let this changeset leave with its predecessor.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

vjeux's position as an engineering owner: "Adding an extra load on mobile is extremely costly." So touch layers stay in their component's own module, with no chunk of their own.

What we measured on this head (55466e9):

  • The lazy layer wins the race: it attached before the trigger painted, at p50 2 ms earlier on the iOS simulator and 42 ms earlier on the Android emulator. We could only make it lose by holding the layer's script back on purpose; then a finger gets today's 500 ms long press (Chromium opens the menu at 506 ms).
  • Static, a desktop page pays for it up front. import {ContextMenu} grows from 45.8 KB to 74.2 KB gzip (+28.5 KB, minified, React and StyleX external). It's the same code a phone fetches today as a second 27.3 KB request.

A static version is on vjeux/astryx uikit/c20-contextmenu-press-static, at e5b4114 on this head. It imports the touch layer statically and removes lazyContextMenuTouchLayer, its Suspense and the 500 ms fallback while it loads. The marker spread, the callout and the selection lock are unchanged. The test commit before it (fd68396) fails on this head in jsdom and Chromium. Results: ContextMenu jsdom 146/146; ContextMenuLongPress Chromium 20/20, WebKit 17/17.

Whether to take it is your call.

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

A screencast-cadence note on this head's ContextMenuLongPress busy-main-thread proofs: the harness now judges each delivered frame (verdict.failures), so the absolute bounds next to them flake on a loaded runner. expect(proof.medianFrameMs).toBeLessThan(20) and absolute counts such as expect(proof.last - proof.first).toBeGreaterThanOrEqual(17) both test the screencast's delivery, not the animation; the last one read 16 once in Chromium. Replacing them with expect(proof.verdict.failures, proof.summary).toEqual([]), and keeping frames >= expectedFrames - 2, judges the motion itself. Whether to take that here is your call.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
… lock

The test asserted the gesture host's inline user-select at rest, which the
host's newer model (spec:AST-094 FR41) no longer writes there: it locks
selection for a held press only and lifts the lock with the finger, while
the trigger's own style keeps selection off at rest (spec:AST-017 FR59).
It now checks the trigger's computed value at rest, the host's inline
lock during a held finger short of the commit, and the rest value again
after the lift, so it holds under the host on this branch and under FR41's.
The ContextMenu comment says the host locks for a held press.

@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 is on hold until an engineering owner decides two additions that no current spec covers: the page scroll lock under a touch-opened ContextMenu popover (spec:AST-094 names a page lock only for modal presentations, and its FR32 wants an outside pan to close an anchored menu) and the new public useLongPress stages option with its start(point, time) argument. An engineering owner can provide or identify the governing spec for them, or record the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • Rebase onto main and land or retarget the predecessor pull requests so this diff holds only the ContextMenu change; it carries about 36,000 authored lines and now conflicts with main.
  • Take the text-selection lock only where a hold does something (spec:AST-094 FR41 and DEC-9), and keep touch motion roles out of defineTheme until the public set is decided (FR4 and DEC-11).
  • Under FR32 and FR33, show the menu beside the lifted trigger, follow the sheet without a lift, keep the lift through the whole drift allowance, hand the opening finger to the menu, and close on an outside pan with the outside tap reaching nothing.
  • Build the trigger lift as the shared lift-from-trigger module (architecture:layer-runtime INV15), keep the pressable marker off a disabled trigger, replace the touch layer's handle Effect, and correct the changesets that say no component uses this code.
  • Add real-device iOS Safari and Android Chrome evidence for the claims about those browsers, and explain or fix the growth of the ContextMenuItem import from 16.4 KB to 53.4 KB gzip.

[Automated review]

@@ -283,6 +301,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-094 FR41 keeps native selection on a label or plain text whose hold does nothing ("such as a checkbox row's label"), and DEC-9 rejects "suppressing selection on every press-tracked control". This lock runs for every usePressFeedback surface; take it only on elements whose hold does something.

readPresentation(presentation, {whenOmitted: 'touch', compact})
.touchModes,
);
openAtLocalPoint(local.x, local.y, triggerRef.current, 'menu');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FR33 asks the menu to sit "beside" the lifted trigger. Anchoring it at the finger inside the trigger draws it over the card instead.

);
// The page stays put while a menu a finger opened is up, as it does under
// UIKit's context menu. A sheet locks the page itself.
useScrollLock(isOpen && isTouchOpen && !usesBottomSheet);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No current record covers a page scroll lock under a non-modal popover (AST-094 FR19 names one only under a modal presentation). With dismissal on mousedown only, a pan outside a touch-opened menu then neither scrolls the page nor closes the menu, which FR32 requires. This lock waits on an engineering owner's decision.

// Under the touch model the trigger is a pressable surface of core's
// one press machine (utils/pressFeedback.ts); the touch layer installs
// the machine and its press feedback follows the pressed state.
{...(touchModes ? pressableProps : null)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With isDisabled this marker still makes the trigger a surface of the shared press controller, so a finger starts its onset timer and selection lock. FR24: "while a touch presentation is disabled, no touch-only listener, observer or timer is active". Spread it only when touchModes && !isDisabled.

},
);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV3: a new Effect names the external system it synchronizes. This one hands ContextMenu a handle that sets isTouchLayerReady state; a ref ContextMenu owns (useImperativeHandle) with readiness read from the loaded module would avoid the Effect.

* ```
*/
motion?: MotionScaleConfig;
motion?: MotionConfig;

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-094 FR4 and DEC-11 keep touch motion roles internal until the public motion-token set is decided ("a theme cannot override them yet"). This field makes them theme-settable, with docs, the template and an interaction case to match.

@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

An offer for this pull request's touch layer, because a review on #8228 asks which external system its handle Effect keeps in step. Branch vjeux:offer/8125-handle-effect (5cf1f7e, one commit on bf001e6) adds only a comment on the Effect in ContextMenuTouchLayer.tsx: it keeps ContextMenu in step with this layer's mount, which ContextMenu can't see from render. Until the layer has committed, ContextMenu holds no handle and a finger keeps the 500 ms long press. On unmount the handle goes, so ContextMenu never calls a layer that is gone.

Separately: once this branch takes main (#8257: themes do not set motion roles), the sentences saying a theme may retune the press feedback (both changesets, contextMenuPressCurve.ts, contextMenuPressFeedback.ts, motion/uikit/contextMenu.ts) and the code behind them need the same conform as #8228. On this branch's current base they are accurate.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Your 17:05Z touch model (#8464 (comment)) has components import the gesture and touch modules directly, with the bundler keeping one copy per page. That replaces the import() in lazyContextMenuTouchLayer.ts:38, and it matches vjeux's position as an engineering owner: "Adding an extra load on mobile is extremely costly." So here is the static offer again, refreshed onto this head.

Measured on this head (bf001e6) with the size report's tool (component-size.js: the gzip JavaScript import {ContextMenu} loads up front). These replace the earlier comment's figures, which used other bundler options.

  • Today: 79.6 KB up front; under a coarse pointer the touch layer then arrives as a second request.
  • With the offer: 106.6 KB up front (+27.0 KB), and no second request. Table grows by 27.1 KB and Markdown by 27.2 KB the same way; DropdownMenu doesn't change.
  • Everything a phone ends up loading, the bottom sheet's own on-demand chunk included: 124.2 KB today, 124.7 KB with the offer.

The offer is vjeux:uikit/c20-contextmenu-press-static, now at 16dc55e. It fast-forwards bf001e6 and supersedes the head named in #8125 (comment). It's the same two commits, rebuilt on this head (vjeux/astryx@bf001e6...16dc55e):

  • e36238c checks that a finger landing on the first render gets UIKit's timeline: no menu at 500 ms, the menu at 735 ms. On this head it fails in jsdom and in Chromium, where the menu opens at 506 ms.
  • 16dc55e imports the touch layer directly and removes lazyContextMenuTouchLayer, its Suspense and the 500 ms fallback while it loads. Under a fine pointer none of it attaches. The press marker, the callout and the selection lock are unchanged.

Results on 16dc55e: ContextMenu jsdom 146/146; ContextMenuLongPress Chromium 20/20, WebKit 17/17; core typecheck and lint clean.

Whether to take it is your call.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's decision on context menus: a public, framework-agnostic createContextMenu(element, {items | render, onOpenChange?, presentation?}) → {open(at?), close(), destroy()} and a thin useContextMenu(ref, opts) hook, with <ContextMenu> rebuilt on them. Row menus attach to the list item element itself, so ul > li stays valid. They get a full-row hit area, open on long-press, right-click or Shift+F10, show press paint and lift on the row, and return focus on close. The records are being written now.

For this PR, a small change:
the hold timeline moves into the factory as the long-press part of its context-request input (long-press, right-click, Shift+F10), listening on the attached element.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…k#8201

The touch layer is imported directly, so it runs from the first render under
the touch model. Where both sides edited a header, the offer's loading
wording is kept along with the lift; the lazy loader and its test go with
the offer.
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
…k#8201

The press constants' entry keeps this stack's wording (no theme surface) and
takes the offer's correction: ContextMenu's touch timeline uses them.

This branch was successfully deployed

1 active deployment
Preview — bf001e65 Deployed Oct 26, 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants