Repository navigation
feat(ContextMenu): ContextMenu built on createContextMenu's context request (spec:AST-107 FR8, FR7) - #8747
feat(ContextMenu): ContextMenu built on createContextMenu's context request (spec:AST-107 FR8, FR7)#8747vjeux wants to merge 1960 commits into
Conversation
… is down off the sheet (spec:AST-094 FR10) The lone-finger hold counts only the fingers the pan tracks, so a second finger on plain page content left the sheet's finger held, and the moves of a pinch across the sheet's edge were cancelled. This test fails until the host counts every finger on the screen.
…is down anywhere (spec:AST-094 FR10) The lone-finger hold counted only the fingers the pan tracks, so a second finger on plain page content left the sheet's finger held, and a pinch across the sheet's edge had its moves cancelled. The host now applies a recognizer's hold only while the move's event reports one finger on the screen; the pan's own count still covers two fingers on the sheet.
An attach reads the element's own will-change in a task of its own. A shared animator (animatorFor) is never detached, so its node can leave the document with that read still waiting, and the read then forces a style pass on a node with no style: a test run that mounts and unmounts many pagers piled up such reads until a later wait timed out. The read now skips a node out of the document; a promotion that needs it reads it then, as one on a node attached in the same task does. A detach and dispose already cancel it. The new test fails on the previous code (one read of a removed node); the in-task read test now puts its element in the document, where the read happens.
meta#8521) into the input hold
…ter the browser takes the touch A host that stopped feeding the finger at the takeover and still delivered the lift would have passed. Each transition now records the list's scrollTop, and the test expects a 'changed' while the list is scrolled (spec:AST-094 FR20: it follows the scroll to the lift).
…keover) into the host line
…he numbers after it as they were A lifted row's spot was held by a span, so a list held a span among its items and an ordered list numbered the items after it one higher while the row was lifted. Two browser proofs, red on the span: every child of the list is an li while lifted, at the row's size whatever the page styles an li with, and a later item's marker paints the same.
…mber In a ul, ol or menu the placeholder is an li, so the list keeps only li children, as a row menu attached to its list item needs. It is laid out as a block, never a list item, so the items after it keep their numbers, and its own minimum and maximum sizes, padding and border are reset, so the page's rules for an li never change the spot it holds.
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.
…ads on demand With the touch layer imported directly, it marks the trigger pressable from the first render under the touch model.
…of the document) into meta#8270
…sture system (spec:AST-094 FR24, FR43) utils/pressFeedback.ts reaches gestures/coarsePointer.ts, so a page whose only touch work is press feedback carries a gesture module, and a bundle check that counts gestures/ code finds it in the root and hooks entries. The coarse-pointer query belongs with the environment queries. This test walks the controller's import graph and fails until it reaches no module under gestures/.
…ries in utils/ (spec:AST-094 FR43) gestures/coarsePointer.ts becomes utils/coarsePointer.ts, beside utils/touchQueries.ts, with its test; the gesture host's selection lock, the press controller and the pointer test helper import it from there. The press controller's import graph now reaches no module under gestures/, so a page whose only touch work is press feedback carries none of the gesture system. No behavior changes.
CI's lint rejected the three Array<T> types the previous commit added (@typescript-eslint/array-type); the test now names its record once (WatchTransition) and reuses it.
…adow root Red: an element inside a shadow root gets its menu in the document's body, outside the element's own tree, where the shadow root's styles do not reach and the document judges presses on it as presses on the shadow host.
…dow root The menu's container now belongs to the element's own tree: the end of its nearest dialog or popover, or of its shadow root, or of the body. A shadow root's styles reach the menu there, and the menu's tree judges presses on it before the document does, as ContextMenu's own menu does.
…Escape before the menu Red: the hook's rows render in its own root, outside the menu's layer depth, so a dismissible layer inside them ranks below the menu and Escape closes the menu first; ContextMenu's own rows rank above it.
…scape before the menu The `render` slot's rows context now carries the layer depth inside the menu, and the hook's rows read it, so a dismissible layer in them ranks above the menu, as one in ContextMenu's own rows does: Escape dismisses that layer first, and the menu after.
internal/shadcn-registry/routes.lock.json: regenerated with UPDATE_SHADCN_ROUTE_LOCK=1 node apps/docsite/scripts/generate-data.mjs; against main it adds this PR's two hook routes.
…its menu in the element's dialog, popover or shadow root; its rows at the menu's layer depth; main dcdb356) into ContextMenu on the factory createContextMenu.tsx: meta#8693's container placement, rows context and wrapper access, with this branch's request core (attachContextMenuRequests) in place of the factory's own context request.
… whose menu runs ContextMenu's touch model (spec:AST-107 FR8, FR7) Red: ContextMenu shares the factory's request core but renders its own menu, so it never calls createContextMenu, which FR8 builds it on (its verification fails a ContextMenu that bypasses the factory): with `items` it should hand the factory its items, and with `menuContent` a `render`. The factory's menu under a coarse pointer opens a held finger at 500 ms, where ContextMenu's touch model opens it at UIKit's moment (FR8: the shared options mean what ContextMenu's mean). And `render` fills the menu once, as it is attached, keeping the rows in the closed menu as ContextMenu does; its cleanup runs when the menu is destroyed. Also guards that a row in ContextMenu's menuContent still reads the caller's own context (passes).
…T-107 FR8, FR7) ContextMenu renders its trigger and attaches the public createContextMenu to it. The factory renders ContextMenu's menu beside the trigger, in a slot ContextMenu gives it, so the menu takes the trigger's theme, direction, dialog, popover and shadow root as before; its cursor anchor stays inside the trigger, so the menu follows the content on scroll (meta#3465). `items` go to the factory as they are; `menuContent` renders in ContextMenu's own tree and is portaled into the menu (contextMenuRows.tsx, shared with useContextMenu), so the caller's contexts reach it and its rows read the menu's row context, open state, layer and layer depth. The factory's menu now runs ContextMenu's touch model on any element: the element's request takes UIKit's hold under the touch model from the start (the menu refines it, and plays the haptic at the commit), the element is a pressable surface for the press feedback, and a block element lifts. The factory's own open() and close() take the same path as the element's requests, and an open from a request commits in its own task (flushSync), inside a shadow root too. `render` fills the menu once, as it is attached; its cleanup runs when the menu is destroyed. Size, menu width, styles and the trigger's anchor reach the menu through an internal view setter beside the factory's public options.
… or finger (spec:AST-107 FR8) meta#8648's FR8 states that a press outside the menu closes it, skipping the compatibility mouse events a finger's press sends, and its verification names an outside press with a mouse and with a finger. A mouse press outside closes the factory's menu; the opening finger's own compatibility mousedown leaves it open, and a later press outside closes it. Both pass: they cover behavior the menu already has.
…pec; the factory closes on a press outside, mouse or finger) into ContextMenu on the factory createContextMenu.test.tsx: both sides add cases at its end; this keeps both, and the finger's outside-press cases take the file's shared clock epoch, since the touch-model cases before them have moved the gesture host's clock past a fresh 100 s.
…extMenu's source attaches no request (component:ContextMenu FR10) meta#8648 makes ContextMenu FR10 current: ContextMenu's invocation, open state and cleanup are the factory's. Its verification row names an unmount while open and a source check. Unmounting a ContextMenu while its menu is open removes the menu, the factory's container and the trigger's request (the browser keeps its own menu after); ContextMenu.tsx makes no request attachment call. The source check fails on this PR's first version (883bf8e), whose ContextMenu attached its trigger's request itself; both pass on the factory.
There was a problem hiding this comment.
This pull request is on hold for an engineering owner's decision on four spec changes it carries from the stack it depends on: architecture:layer-runtime INV13 (one exit copy per closing layer), the springs.contextMenuLift, springs.contextMenuDismiss and springs.contextMenuMorph roles in spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, and component:ContextMenu DEC-2. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:
- Land or retarget the stacked dependencies so the diff is only this pull request's own commits (47,023 authored lines today).
- ContextMenu's
itemsnow render in the factory's own React root, so an itemlabeloriconthat reads an app context loses it (ContextMenu.tsx:466-478); keep the item nodes in ContextMenu's tree, asmenuContentis. - Two repository tests fail at this head because of this diff: the root-entry case in
scripts/touch-support-bundle.test.mjsand theactionGroupTracker.tscase inpressableCoverage.test.ts. - Export
useContextMenufrom@astryxdesign/core/hooksasspec:AST-107documents, and open the menu from the request handler rather than the layout effect atcreateContextMenu.tsx:327-336.
[Automated review]
| }; | ||
| return rowsSlot != null | ||
| ? {...shared, render: rowsSlot.render} | ||
| : {...shared, items: items ?? []}; |
There was a problem hiding this comment.
items go to the factory here, and the factory renders them in its own React root with only Astryx's theme, layer, link and i18n providers (createContextMenu.tsx:416-431, contextMenuRows.tsx:120-136). An item label, icon, description or endContent that reads an app context (an i18n library's message component, a router link, a store hook) now gets its default or throws, where the base rendered the menu in ContextMenu's own tree. Rendering the item nodes from ContextMenu's tree, as menuContent is, would keep them, and a test whose item label reads a caller context would pin it.
| @@ -63,7 +66,8 @@ const source = new Map( | |||
| files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]), | |||
| ); | |||
|
|
|||
| const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./; | |||
| const PRESS_ARM = | |||
| /':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/; | |||
There was a problem hiding this comment.
Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this test flag hooks/actionGroupTracker.ts:271, which writes the pressed state without usePressFeedback(), and CI's test-ui 2/4 fails on it at this head (pressableCoverage.test.ts:156). The fix needs to ride with this change, or land before it.
| const hadFocus = host.contains(doc.activeElement); | ||
| isDestroyed = true; | ||
| requests.destroy(); | ||
| root.unmount(); |
There was a problem hiding this comment.
destroy() is called from ContextMenu's and useContextMenu's effect cleanups, where React 19.2's development build logs “Attempted to synchronously unmount a root while React was already rendering” for this call, because passive effects run in the commit context. Detaching the requests here and deferring root.unmount() past the commit avoids it.
|
|
||
| /** | ||
| * Renders a menu's content into `container` without React; returns a cleanup | ||
| * that runs when the menu closes. |
There was a problem hiding this comment.
The cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:351-353, and the doc page's option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.
| @@ -130,7 +130,11 @@ export type { | |||
| } from './useInteractiveRole'; | |||
|
|
|||
| export {useLongPress} from './useLongPress'; | |||
| export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress'; | |||
| export type { | |||
| LongPressStage, | |||
There was a problem hiding this comment.
This entry gains LongPressStage, which no current spec record states and no Core component uses at this head, but not useContextMenu, which spec:AST-107's public contract imports from @astryxdesign/core/hooks. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here, and dropping or specifying stages, would match the spec.
There was a problem hiding this comment.
This pull request is on hold for an engineering owner's decision on four spec changes it carries from the stack it depends on: architecture:layer-runtime INV13 (one exit copy per closing layer), the springs.contextMenuLift, springs.contextMenuDismiss and springs.contextMenuMorph roles in spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, and component:ContextMenu DEC-2. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:
- Land or retarget the stacked dependencies so the diff is only this pull request's own commits (47,149 authored lines today).
- ContextMenu's
itemsnow render in the factory's own React root, so an itemlabelthat reads an app context loses it (ContextMenu.tsx:477); keep the item rows in ContextMenu's tree, asmenuContentis. scripts/touch-support-bundle.test.mjs(the root-entry case) andpressableCoverage.test.ts(theactionGroupTracker.tscase) fail at this head because of this diff.- Export
useContextMenufrom@astryxdesign/core/hooksasspec:AST-107documents, and open the menu from the request handler rather than the layout effect atcreateContextMenu.tsx:327-336.
[Automated review]
| }; | ||
| return rowsSlot != null | ||
| ? {...shared, render: rowsSlot.render} | ||
| : {...shared, items: items ?? []}; |
There was a problem hiding this comment.
items go to the factory here, and the factory renders them in its own React root with only Astryx's theme, layer, link and i18n providers (createContextMenu.tsx:416-431, contextMenuRows.tsx:120-136). An item label that reads an app context (an i18n message component, a router-aware link, a store hook) gets the default or throws, where it used to render in ContextMenu's tree. Rendering the item rows through the same MenuRows slot menuContent uses would keep them in the caller's tree; a test whose item label reads a caller context would pin it.
| } else { | ||
| handleRef.current?.open(forwarded.request); | ||
| } | ||
| }, [forwarded]); |
There was a problem hiding this comment.
This layout effect opens or closes the menu, and so fires the caller's onOpenChange, in reaction to the forwarded prop rather than in the request handler that caused it, which architecture:react-component-runtime INV2 rules out, and it names no external system (INV3). Keeping the menu's handle where forward can reach it and calling it inside the existing flushSync would avoid the round trip.
| const hadFocus = host.contains(doc.activeElement); | ||
| isDestroyed = true; | ||
| requests.destroy(); | ||
| root.unmount(); |
There was a problem hiding this comment.
destroy() runs from ContextMenu's and useContextMenu's effect cleanups, where React DOM 19.2's development build logs “Attempted to synchronously unmount a root while React was already rendering” for this call, because passive effects run with the commit context set. Detaching the requests here and unmounting the root after the commit would avoid it.
|
|
||
| /** | ||
| * Renders a menu's content into `container` without React; returns a cleanup | ||
| * that runs when the menu closes. |
There was a problem hiding this comment.
The cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:351-353, and the option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.
| @@ -130,7 +130,11 @@ export type { | |||
| } from './useInteractiveRole'; | |||
|
|
|||
| export {useLongPress} from './useLongPress'; | |||
| export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress'; | |||
| export type { | |||
| LongPressStage, | |||
There was a problem hiding this comment.
This entry gains LongPressStage, which no current spec record states and no Core component uses at this head, but not useContextMenu, which spec:AST-107's public contract imports from @astryxdesign/core/hooks. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here, and correcting useContextMenu.doc.mjs:110's importPath, would match the contract.
| @@ -63,7 +66,8 @@ const source = new Map( | |||
| files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]), | |||
| ); | |||
|
|
|||
| const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./; | |||
| const PRESS_ARM = | |||
| /':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/; | |||
There was a problem hiding this comment.
Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this test flag hooks/actionGroupTracker.ts:271, which reads the pressed state without usePressFeedback(), and CI's test-ui 2/4 fails on it at this head (:156). The fix needs to ride with this change: list the tracker as a model file, mark it, or narrow the pattern.
…t a React error; the factory's menu opens at the pointer in a transformed dialog Red, three ways: - The hook reads its ref once, so a ref moved to another element keeps the menu on the old one: a right click on the new element opens nothing. - Unmounting the hook makes React log "Attempted to synchronously unmount a root while React was already rendering": the factory's root and the hook's rows root are unmounted inside React's own commit. - Inside a dialog that keeps a transform, as a Drawer does, the menu opens about 40 px from the pointer in Chromium and WebKit: the anchor is a fixed box at the client point, and the transformed dialog is its containing block.
… unmounting them inside React's commit Unmounting a component that called useContextMenu made React log "Attempted to synchronously unmount a root while React was already rendering": its effect cleanup destroyed the factory, which unmounted the menu's root, and unmounted the hook's rows root, both inside React's commit. From React, the factory now removes the element's request and the menu's node at once and has its root render nothing, an update React takes in its own time, instead of unmounting it (an internal destroy for React callers; the public destroy() still unmounts at once for a page without React). The rows root does the same.
…ints at now The hook read its ref once, when it mounted, so a ref moved to another element (a keyed swap, a conditional branch) kept the menu on the old element and a right click on the new one opened nothing. Each commit now checks the element the ref points at and, when it changed, detaches the menu from the old element and attaches it to the new one; options still reach an attached menu without recreating it.
…e a transformed dialog or popover The factory's anchor is a fixed box at the client point, in a container inside the element's dialog or popover. A fixed box's containing block is the viewport unless an ancestor sets one up: a Drawer's dialog keeps a transform while it is open, so the anchor, and the menu with it, landed offset by the dialog's position (about 40 px in the case, off-screen in a Drawer on the right). The anchor is now placed through its actual containing block: placed at two offsets, where it lands is measured, and the client point is mapped through that block's offset and scale.
The factory renders its menu in a root of its own as it attaches; each case attached it outside act, so React logged "An update to Root inside a test was not wrapped in act(...)" once per case (17 cases).
…ng its roots without unmounting them inside React's commit; the factory's anchor through a transformed dialog) into ContextMenu on the factory createContextMenu.tsx: the wrapper access carries both this branch's view setter and meta#8693's destroy for React callers; teardown destroys this branch's request core. useContextMenu.tsx: the rows slot's host goes at once and its root renders nothing.
…nmounting a root, and unmounting its whole React root throws nothing Red: unmounting a ContextMenu makes React log "Attempted to synchronously unmount a root while React was already rendering" (136 times across ContextMenu's suites): ContextMenu's effect cleanup destroys the factory, whose menu root then unmounts inside React's commit. Guards, passing now: unmounting the whole React root around an open ContextMenu throws nothing afterwards, in jsdom and, through Storybook's switch to another story, in Chromium and WebKit. The menu's layer leaves ContextMenu's span slot for the slot's parent, which that root owns and clears as it unmounts; a menu root that unmounted after that would find its layer gone.
Unmounting a ContextMenu destroyed the factory, whose menu root unmounted inside React's commit, and React logged an error each time. Unmounting that root later is no fix either: the menu's layer leaves ContextMenu's span slot for the slot's parent, which the caller's root owns, so a whole root unmounting around the menu clears the layer first, and the menu's own root would then find it gone. ContextMenu now hosts the factory's menu in its own tree: just before it calls createContextMenu on its trigger, it registers an internal host (hostContextMenuInReact), and the factory renders its menu through it, as a portal into its container, instead of in a root of its own. The factory still opens, closes and destroys the menu (component:ContextMenu FR10); ContextMenu's contexts reach the menu without a bridge, and the menu unmounts with ContextMenu. The factory renders nothing new for the same options or view (shallow-equal), so ContextMenu's per-render update does not loop. Plain elements and useContextMenu keep the factory's own root.
There was a problem hiding this comment.
This pull request is on hold for an engineering owner's decision on spec direction it carries from its stack: architecture:layer-runtime INV13 (one exit copy per closing layer), the three context-menu springs added to spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, component:ContextMenu DEC-2, whether spec:AST-094 FR33's no-row rule governs createContextMenu's held-finger open, and useLongPress's new stages option. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:
- Land or retarget the stacked dependencies so the diff is only this pull request's own change (47,504 authored lines today).
scripts/touch-support-bundle.test.mjs(the root-entry case) andpressableCoverage.test.ts(theactionGroupTracker.tscase) fail at this head because of this diff.- Export
useContextMenuand its two types from@astryxdesign/core/hooks, asspec:AST-107documents. - Open the menu and call
onOpenChangefrom the request handler rather than the layout effect atcreateContextMenu.tsx:377-386, keep the menu slot's node out of React state, and correctrender's documented cleanup.
[Automated review]
| export type { | ||
| LongPressStage, | ||
| UseLongPressOptions, | ||
| UseLongPressHandlers, | ||
| } from './useLongPress'; |
There was a problem hiding this comment.
spec:AST-107's public contract imports useContextMenu from @astryxdesign/core/hooks, but this entry gains only LongPressStage. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here (and pointing useContextMenu.doc.mjs:110 at this entry) makes the documented import resolve.
| useLayoutEffect(() => { | ||
| if (forwarded == null) { | ||
| return; | ||
| } | ||
| if (forwarded.request === 'close') { | ||
| handleRef.current?.close(); | ||
| } else { | ||
| handleRef.current?.open(forwarded.request); | ||
| } | ||
| }, [forwarded]); |
There was a problem hiding this comment.
This layout effect opens or closes the menu, and so fires the caller's onOpenChange (through layer.show() and onShow), in reaction to the forwarded prop rather than in the request handler that caused it, which architecture:react-component-runtime INV2 rules out, and it names no external system (INV3). Calling the menu's handle from the request handler inside its flushSync keeps the callback in the handler.
| // 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); |
There was a problem hiding this comment.
architecture:react-component-runtime INV1: DOM nodes MUST NOT be stored in React state. This keeps the slot span in state through ref={setMenuSlot}, and the factory attaches only after the re-render it causes; a ref for the slot, read in the attach effect, avoids both.
| const PRESS_ARM = | ||
| /':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/; |
There was a problem hiding this comment.
Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this guard flag hooks/actionGroupTracker.ts:271, which writes and reads the pressed state itself to fade an action group's press, and CI's test-ui 2/4 fails on it at this head (:156). Narrowing the pattern, or listing the tracker as a pressed-state writer, needs to ride with this change.
| /** | ||
| * Renders a menu's content into `container` without React; returns a cleanup | ||
| * that runs when the menu closes. | ||
| */ |
There was a problem hiding this comment.
The cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:318-329, :401-403, and the option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.
|
Merged into #8693, as asked there, so this one closes; the branch stays. |
Depends on #8693 (createContextMenu) and, through it, #8228 and #8670. Through #8201, it carries the static-import offer on #8125 (16dc55e) as a dependency; if #8125 lands differently, this stack follows her decision. Two commits change #8125's files and are offered there: vjeux@6193c2a803 and vjeux@883bf8ef2b. Own commits: vjeux@e1b7586d57, vjeux@500fd27a4d, vjeux@6193c2a803, vjeux@e6576c816c, vjeux@65fa39d232, vjeux@883bf8ef2b, vjeux@2d0fc1c323, vjeux@6d4267e634, vjeux@d4a15a4274, vjeux@69c07e2e96, vjeux@ad0a884ded
User need
Part of the UIKit-parity program.
spec:AST-107FR8 builds ContextMenu on the public headless context menu, so ContextMenu andcreateContextMenubehave alike and Core keeps one implementation.Problem and solution fit
ContextMenu attaches
createContextMenuto its trigger. The factory opens the menu, runs the touch model (UIKit's hold, the press feedback, the lift) and renders the menu in ContextMenu's own tree, as a portal into a container beside the trigger, so the caller's contexts reach it and it unmounts with ContextMenu.What led to this push:
Current specification
spec:AST-107FR8 and FR7;component:ContextMenuFR10;spec:AST-094FR43 (the context request), FR33 and FR35.Public delta and caller burden
No ContextMenu prop or type changes; a finger's hold yields to a gesture that claimed the finger first.
createContextMenugains ContextMenu's touch model, andrenderfills the menu once, cleaned up on destroy. Size (gzip): ContextMenu JS 118,491 → 177,185 B, CSS unchanged; this includesreact-dom/client, which the factory imports for the menu root it gives a page without React. Public-API gaps (FR7): the lift, its springs and UIKit tables, the input hold.Design and alternatives
useContextMenu,menuContentwould lose the caller's contexts; on the factory's internals, FR8 fails it. So ContextMenu uses the factory and portals its rows; size, width, styles and the trigger's anchor reach the menu through an internal setter.Evidence
MenuPressShadowRoot.a11y:76: 10 of 12 failed before, 12 of 12 after. ContextMenu's own suites pass unchanged on the factory; unit tests cover FR10 and an unmount that logs nothing.Scope
Testing
In the ContextMenu stories:
pnpm test:interaction ContextMenu createContextMenuandpnpm test:a11y-contractrun these in Chromium and WebKit, plus a story switch that unmounts the whole React root around an open menu and throws nothing. One existing test changed, to the new contract and no looser: createContextMenu's "fills the menu throughrenderin plain DOM once, and runs its cleanup when destroyed" (it said "on close"), per therenderdoc line "Fills the menu's container with custom content in plain DOM, once, as the menu is attached; return a cleanup, which runs as it is destroyed." React's act() warnings left in ContextMenu's unit tests come from main's right-click case and #8125's haptic cases. Unrelated failures: #8638, #8671; Vercel's deploy of this head failed on its side, and its build command passes locally.