Skip to content

feat(ContextMenu): createContextMenu and useContextMenu, a headless context menu (spec:AST-107 FR8) - #8693

Draft
vjeux wants to merge 2018 commits into
meta:mainfrom
vjeux:uikit/c20-headless-context-menu
Draft

vjeux wants to merge 2018 commits into
meta:mainfrom
vjeux:uikit/c20-headless-context-menu

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Carries #8125, #8201, #8213 and #8228 (ContextMenu's touch line) and #8670 (the context request); #8747 (ContextMenu built on the factory) is merged in, as asked. This head's own changes: vjeux/astryx@061a70f...b9608e9

User need

Part of the UIKit-parity program (spec:AST-107 FR8): a context menu on an element a product already renders, such as a list row, with or without React. ContextMenu and the factory are one implementation, so they open, present and dismiss alike.

Problem and solution fit

createContextMenu(element, options) attaches ContextMenu's menu to the element; it opens on the element's context request (#8670) or open(at), and destroy() removes all it added. useContextMenu(ref, options), from @astryxdesign/core/hooks, is its React wrapper (FR6). <ContextMenu> attaches the factory to its own trigger (component:ContextMenu FR10). Under a finger the menu runs ContextMenu's touch model: the press feedback, the haptic and, on a block element, the lift (spec:AST-094 FR33).

What led to this push, from review:

Current specification

spec:AST-107 FR5, FR6 and FR8; component:ContextMenu FR10; spec:AST-094 FR33 and FR43.

Public delta and caller burden

New exports: createContextMenu and its types, and useContextMenu from the root and /hooks. The element needs a tab stop for Shift+F10. Size (gzip): ContextMenu JS 177,734 B, CSS 15,189 B; it includes react-dom/client, which the factory's own menu root imports. Public-API gaps (FR7): the lift, its springs and UIKit tables.

Design and alternatives

  • A plain element's menu lives at the end of its nearest dialog, popover or shadow root, or of the body; ContextMenu's renders in its own tree, so its contexts reach it.
  • Owner question: does FR8's "focus starts on the first enabled row" cover a touch open, where ContextMenu lights no row, as UIKit does?

Evidence

The steps below, automated in Chromium and WebKit; unit tests for focus return, the lift on a factory row, the long press's moment, the shadow root, the nested layer, a moved ref and an unmount that logs nothing.

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

In the story "Headless / createContextMenu on a list row":

  1. Right-click "Inbox": the menu opens at the pointer, first row focused; Escape returns focus to the row.
  2. Focus "Drafts", then hold a finger on "Inbox": the row lifts and the menu opens; Escape returns focus to "Inbox".
  3. Right-click "Archive" (the hook's menuContent), pick "Restore": it acts once and closes.
  4. In "Touch / long press", hold the card: it grows, then lifts with the menu at 0.5 s; lift sooner and nothing opens.

pnpm test:interaction ContextMenu createContextMenu runs these. Actions are paused on the repository, so this head's results are local: unit 15,389, scripts 2,434, docsite 660, interaction 66 (Chromium) and 58 (WebKit), a11y 27; tsc, lint and pnpm check:repo clean.

vjeux added 30 commits October 27, 2026 00:48
…8641's moving-touch claim kept on this line's internals delivery) into meta#8315

# Conflicts:
#	packages/core/src/gestures/GestureArena.ts
The morph spring moved into the springs table as the motion role
springs.contextMenuMorph: the menu's growth from the finger keeps its start
width in the context menu's table and plays on the role.
The lift's springs are motion roles now: the close keeps its springs, the
preview's return and the tint on springs.contextMenuDismiss and the menu's
shrink on springs.menuClose, and the role test names them.
The case walked ContextMenu, DropdownMenu, useLongPress and useMenuPress's
imports and failed on the UIKit gestures table: it kept that table out of
the menus' bundles, the lazy design the no-lazy-load ruling and direct
imports retire. The long-press numbers keep one source, still pinned by the
table, recognizer and hooks case and by the hooks reaching longPress.ts.
…8589's watcher test reads its end; the gestures-table gate retired) into meta#8360
…8589's watcher test reads its end; the gestures-table gate retired) into the every-pointer host
… watcher test's tail fix) into the input hold
…of the document; DEC-22's screen-edge rule) into the animator's presentation start
…xposes no ontouchstart is taken once

Chromium fixes ontouchstart when the page is created, so turning touch
emulation on later sends Touch Events without it: the host reads Pointer
Events for the finger and must not read its Touch Events too. A one-finger
long press fails here, the finger tracked twice.
…from their pointer events

install() reads a finger from its pointer events when the window has no
ontouchstart, but the four touch handlers still took the Touch Events a
browser sends anyway (Chromium after touch emulation turns on), tracking
the finger twice, so a one-finger long press never recognized. Each touch
handler now returns while usesTouchEvents is false, after noting the
fingers down, as install()'s comment promised.
…n before the click guard

internal/motion.ts (meta#8562) re-exports UIKIT_CLICK from the barrel, so a
tree carrying both fails typecheck and the Storybook build since this
pull request dropped it. The barrel keeps it, with the click guard's
window, and the table of every gesture group still leaves it out. Fails
until the barrel exports it.
…'s window

This pull request dropped UIKIT_CLICK when the click guard took the
stray-click window over, but importers written before it read the barrel's
UIKIT_CLICK (internal/motion.ts in meta#8562 re-exports it), so a tree carrying
both failed typecheck and the Storybook build. UIKIT_CLICK stays in the
barrel, deprecated, with the guard's 400 ms (CLICK_GUARD_WINDOW_MS), and
UIKIT_GESTURES still leaves it out.
…ter events) into the every-pointer host

# Conflicts:
#	packages/core/src/gestures/__tests__/GestureHost.test.ts
vjeux and others added 17 commits October 28, 2026 11:52
…o on close (spec:AST-107 FR8)

With focus on another control (a tap there, then a hold or open() on the
element), closing the menu returned focus to that control: the menu
restored whatever had focus before it opened. Closing now focuses the
element, or what had focus inside it before the open (a focused control
in ContextMenu's trigger keeps it). An element that takes no focus
(ContextMenu's trigger wrapper has no tab stop) leaves focus where it was,
as before. The factory's doc says so.
…ynchronizes with (react-component-runtime INV3)

The effect hands requests from outside React (the element's own
listeners and the controller's open() and close()) to the menu's handle
once it has rendered; the menu reports onOpenChange from its own show and
hide (INV2).
…lement, and keeps an inline one's menu at the finger (spec:AST-094 FR33)

FR33 lifts a ContextMenu that a held finger opens on a block trigger.
Before this stack took ContextMenu built on the factory, the factory's
menu opened at the finger with no lift (on this PR's previous head, a
held finger on a block row opens the menu and nothing lifts: isOpen true,
lifted false). Now the factory's menu runs ContextMenu's touch model: a
block element lifts, and an inline one keeps its menu at the finger.
…s at a blur, keeps a kept press's touchmove to the press, and drives hover

Three cases from the maintainer's review of the every-pointer host
(spec:AST-094 FR24, FR43), each failing on this head:
- a window blur during a mouse press left the host's page listeners
  subscribed;
- a press tracker created with keepsTouch kept its blocking touchmove on
  its element while idle, where a fine pointer's element keeps one passive
  pointerdown;
- a mouse or a pen moving over an element never reached its hover
  recognizer, which was wired for a coarse pointer only.
…pec PR, as asked on meta#8693

architecture:layer-runtime INV13 (the exit copy), the three
springs.contextMenu* rows in spec:AST-094 FR4, the spec:AST-057 reword
and component:ContextMenu DEC-2 return to main's text here; a spec PR
carries them for the owner's exact-head approval, so this code can land
without waiting on them.
…extMenu's entries

Main's guard holds the root entry to no gesture code at all, which
ContextMenu's long press cannot meet once the root re-exports it. meta#8671's
guard checks each public import and lists ContextMenu as a gesture user,
so the root carries only what its touch components import.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…til spec:AST-094 FR4 names them

With FR4's three springs.contextMenu* rows moved to their own spec PR,
the motion registry here keeps exactly the roles main's FR4 names (its
own test reads the record): ContextMenu's lift, dismissal and morph
springs play as internal constants, as every spring FR4 does not name,
and the menu's close keeps springs.menuClose. The spec PR that adds the
rows registers them as roles again.
…del file, not a surface

The widened press-arm rule (a script that reads PRESSED_ATTRIBUTE) caught
hooks/actionGroupTracker.ts, which writes the press attribute on a group's
rows itself and reads it back only to finish its own fade. That is the
documented second writer beside the controller (the gestures README names
both writers), not a surface that paints from the controller's write, so
it joins the controller and the overlay utility in MODEL_FILES.

Co-authored-by: Christopher Chedeau <vjeux@users.noreply.github.com>
…ndle guard, and pressableCoverage's model files)
…int (spec:AST-107 FR6)

Red: AST-107's public contract imports useContextMenu from
@astryxdesign/core/hooks, which lacks it; asked on meta#8693.
…nt (spec:AST-107 FR6)

AST-107 imports useContextMenu from @astryxdesign/core/hooks, with the
other public gesture hooks; it was only in the root and ContextMenu's
entry. The hooks entry now exports it and its types, its doc page's import
path is the hooks entry, and the per-import touch bundle guard lists it as
a gesture user: its element opens on the context request's hold.
… at a blur, keeps a kept press's touchmove to the press, and drives hover

- A window blur during an armed press ends it as its lift does: the page
  listeners go once its contacts are cancelled.
- A press tracker created with keepsTouch gets its blocking touchmove
  where the primary pointer is coarse, before every touch, and where it is
  fine only during an armed press, whose pointerdown comes before the
  touch it brings; a claiming recognizer keeps its listener as before.
- Hover reaches a hover recognizer for every pointer: where the primary
  pointer is fine, a mouse or a pen hovering its element is heard through
  two passive listeners on that element, pointermove and pointerout, with
  no page listener (spec:AST-094 FR24, FR43).
…selection for that drag only

The engineering owner's consistency edit (on meta#8665): a mouse or pen drag
that a recognizer claims suppresses text selection for that drag only,
and an unclaimed mouse drag keeps today's behavior.
…s README says

The README's every-pointer section says a right click, a pen's barrel
button and a ctrl-click on macOS start no press, but the host excluded
only the mouse's: a pen's barrel press began and ended a press. Fails
until the host excludes it, under a fine and a coarse pointer.
The host starts no press for a non-primary button of any pointer but a
finger, so a pen's barrel button, like a right click, is a context click
(spec:AST-094 FR43), as the gestures README already says.
…tens under a fine pointer

A Pens entry says a pen pans, taps and long-presses as a finger does,
keeps text unselected where its hold acts, reaches a hover recognizer,
starts no press with its barrel button, never mixes with a finger in a
pinch, and does not drive the screen-edge pan. The fine-pointer entry and
the every-pointer changeset add the blur, the kept press's touchmove and
the hover listeners.
meta#8665 now carries meta#8360's new head and the maintainer's host review: a
blur ends an armed press, a kept press's touchmove follows the press,
hover reaches every pointer, and a pen's barrel button starts no press.
The one conflict, utils/pressFeedback.ts, takes meta#8665's text: both merges
of main resolved it the same way, and this pull request does not change
that file.
vjeux added 7 commits October 28, 2026 12:11
…peat control owns only its hold

- A pen held still requests through the hold, and the browser then fires
  the pen's own context menu event, during the hold or at its lift: a
  second request for one gesture. One request only, the hold's.
- The engineering owner's ruling on meta#8670: a hold-repeat control inside a
  row owns only its hold; its right click and Shift+F10 still reach the
  row's context request. Today both are refused there.
Both fail until the context request refuses the pen's echo and leaves a
kept press only its hold.
…eat control owns only its hold

- The context request refuses a pen's own menu event during its hold and
  within the menu echo after its lift, as it refuses a held finger's and a
  key's. (A pen's barrel button already starts no press, from meta#8665.)
- A hold-repeat control inside a row keeps only its hold: the row's right
  click and Shift+F10 reach the request through it, as the engineering
  owner ruled.
… with no options a record does not need

The engineering owner's ruling on meta#8670: cut the two-stage commitMs,
requestMs and requestOnLift, and the allowableMovement and isPerAxis
tolerance, until a record calls for them. The hold is UIKit's long press,
500 ms and 10 px as a distance, and requests as it begins; onHold reports
it committed, then ended or cancelled. LongPressGestureRecognizer loses
isPerAxis and geometry its axisTravel, with their tests.
…, with no stages (spec:AST-094 FR43)

Red: meta#8670 cuts the context request's two-stage hold (commitMs,
requestMs, requestOnLift) and its per-axis allowance, as asked there:
the hold is UIKit's long press and requests as it begins. ContextMenu
and the factory's menu still open a held finger at 735 ms, open at a
lift after 400 ms, and allow 11.5 pt on each axis. They should open at
500 ms under any primary pointer, open nothing for a lift before it, and
open nothing for a drift past 10 pt.
…press, with no options) into the headless context menu

packages/core/src/utils/pressFeedback.ts and its test: meta#8670's text,
with this stack's press-paint subscription (subscribePressPaint, which
ContextMenu's press feedback follows) kept beside it.
packages/core/src/hooks/useLongPress.ts, its test, its page and
useMenuPress.ts: meta#8670's text. This stack's useLongPress `stages` and
press start times served ContextMenu's two-stage hold, which no longer
exists (the context request opens ContextMenu, and its hold has no
stages), so they go, with their changeset and the hooks entry's
LongPressStage type.
… with none of the hold options meta#8670 cut (spec:AST-094 FR43)

meta#8670 cuts the context request's two-stage hold (commitMs, requestMs,
requestOnLift), allowableMovement and isPerAxis, as asked there: the hold
is UIKit's long press and requests as it begins. ContextMenu and the
factory passed them for UIContextMenuInteraction's 400 ms commit, 735 ms
menu, open at a lift and 11.5 pt on each axis. They pass none now: under
any primary pointer a held finger gets the menu at 500 ms, a lift before
it is a tap, and a drift past 10 pt (a distance) opens nothing. Under the
touch model the commit, now the long press, plays the haptic and the
press feedback plays UIKit's press curve until the menu shows.

The UIKit table's per-axis drift guess has no reader left and goes. The
tests, the interaction specs, the docs, the story and the changesets
that described the two-stage hold describe the long press; the tests of
a lift after the commit and of a drag between the commit and the menu
go with that window.
…ow, and Escape returns focus to it from another row's focus (spec:AST-094 FR33, spec:AST-107 FR8)

The story's Inbox row, with Drafts focused first: a held finger opens
its menu with the lift up beside it, and Escape closes the menu and
focuses Inbox, in Chromium and WebKit.
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, all in the latest push, on top of your two fixes:

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision on two spec points: the sentence this branch adds to spec:AST-094 FR41 (a mouse or pen drag that a recognizer claims suppresses text selection), which the current FR41 and its verification row do not allow, and ContextMenu's lift playing springs.contextMenuLift, springs.contextMenuDismiss and springs.contextMenuMorph as literals, which FR4 forbids until #8830 names them. An engineering owner, the author included, can record that decision by commenting /approve-spec <full head SHA>, or the FR41 edit can leave this pull request and the lift can wait for #8830. Whatever the decision, please land the stacked predecessors and rebase so this diff is the headless menu and ContextMenu's move onto it (about 46,000 changed lines today), and fix these:

  • ContextMenu.tsx:459 keeps the menu slot's DOM node in React state, which react-component-runtime INV1 forbids.
  • The every-commit effects at ContextMenu.tsx:503 and useContextMenu.tsx:151 should name the system they synchronize (INV3); ContextMenu's re-renders its menu whenever a caller passes a new items array.
  • ContextMenuContentRenderer and the dense docs say the render cleanup runs at close, but it runs at destroy.

[Automated review]

// content on scroll (#3465); the menu places it.
const cursorAnchorRef = useRef<HTMLSpanElement>(null);
// The menu's container lives here, beside the trigger.
const [menuSlot, setMenuSlot] = useState<HTMLSpanElement | null>(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.

react-component-runtime INV1 says "DOM nodes and whole DOM collections MUST NOT be stored in React state; store only the minimal rendering key or own the node through a ref". This slot node enters state through ref={setMenuSlot} (line 573), so every ContextMenu, each Table cell with context actions included, renders a second time when the ref lands and a third when the effect at line 520 attaches the factory, and the trigger has no context request until that passive effect runs. Owning the slot through a ref, or letting the factory place its container from the trigger, avoids both.

Comment on lines +503 to +516
useIsomorphicLayoutEffect(() => {
onOpenChangeRef.current = onOpenChange;
latestRef.current = {factoryOptions, view};
const controller = controllerRef.current;
if (controller == null) {
return;
}
updateContextMenu(controller, factoryOptions(slot));
viewContextMenu(controller, {
...view,
cursorAnchor: cursorAnchorRef.current,
containerParent: menuSlot,
});
});

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 every-commit layout effect names no system it synchronizes (INV3: "A new or changed Effect MUST name the external system it synchronizes"), and through updateContextMenu/viewContextMenu it sets this component's menu state whenever the options differ. Table's lazy cell menu passes items={options ?? []} (Table/tableContextMenu.tsx:113), a new array on every render, so each render of such a cell renders its ContextMenu twice, the second synchronously from here. Naming the factory as the synchronized system, and comparing items by content before re-rendering the portal, would fix both.

Comment on lines +151 to +166
useLayoutEffect(() => {
latestRef.current = {options, wrap};
const controller = controllerRef.current;
if (controller == null) {
return;
}
const portal = portalRef.current;
updateContextMenu(
controller,
factoryOptions(options, handleOpenChange, portal),
);
bridgeContextMenu(controller, wrap);
if (portal != null) {
portal.draw(options.menuContent, wrap);
}
});

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 every-commit layout effect passes options, providers and menuContent to the factory and to the hook's own root, but has no comment naming the system it synchronizes, which INV3 requires of a new Effect. A one-line comment naming the factory's menu and the hook's content root would settle it.

Comment on lines +73 to +79
/**
* Renders a menu's content into `container` without React; returns a cleanup
* that runs when the menu closes.
*/
export type ContextMenuContentRenderer = (
container: HTMLElement,
) => (() => void) | undefined;

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 exported comment says the cleanup "runs when the menu closes", and createContextMenu.doc.mjs:138 says render "fills the menu in plain DOM when it opens". The content mounts with the closed menu, so render runs once as the menu is attached and its cleanup runs at destroy (lines 315-326, 403-405), as createContextMenu.doc.mjs:38 says. A caller who frees resources or rebuilds rows per open, as this comment suggests, sees neither happen.

Comment on lines +471 to +473
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
suppresses text selection for that drag only; an unclaimed mouse drag keeps
today's behavior.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This sentence adds a requirement the current FR41 does not state: on main, a mouse "otherwise keeps its released behavior", and the unchanged verification row still fails "a change ... to mouse, keyboard, or assistive-technology selection". It also leaves open what happens to a selection made before the drag, which the lock clears at the claim (packages/core/src/utils/selectionLock.ts:148). An engineering owner's exact-head decision is needed for this edit.

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

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. 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