Summary
Ship the marking menu as a custom element, so it can be used from plain HTML and from any framework without a wrapper. Items are declared as child elements, the same way <select> takes <option>: the children are the data, the parent owns the interaction and the rendering.
<marking-menu>
<marking-menu-item id="copy">Copy</marking-menu-item>
<marking-menu-item id="paste">Paste</marking-menu-item>
<marking-menu-item id="more">
More...
<marking-menu-item id="cut">Cut</marking-menu-item>
<marking-menu-item id="duplicate">Duplicate</marking-menu-item>
</marking-menu-item>
</marking-menu>
document.querySelector('marking-menu').addEventListener('select', (event) => {
console.log(event.detail.selection.label);
});
Everything below is a proposal. None of it is settled, and the parts that do not line up with the core as it stands today are called out as such.
Proposed strategy for updates
The element watches its children with a MutationObserver, and when they change it disposes its controller and creates a new one. It does not try to update a live controller in place.
This works because disposal is complete: dispose() removes the rendered DOM, drops the pointer listeners, releases the capture, and gives back the parent's touch-action claim. Nothing leaks, and the next controller starts from a clean slate. Mutation records arrive in a microtask, so a batch of changes costs one rebuild.
Two details make it behave well:
- Create the new controller before disposing the old one. They share the same parent element, in the same synchronous block, so no pointer event can land between the two. Doing it in this order also keeps the reference-counted
touch-action claim from dropping to zero, so the parent's inline value is never restored and immediately re-taken.
- Hold the rebuild back while a gesture is running. The element knows:
start opens a gesture, select or cancel closes it. Rebuilding at the end of the gesture rather than in the middle of it means the user never has the menu pulled out from under them. A mutation is a microtask and a gesture is human-scale, so the delay is invisible in practice.
If a rebuild has to happen during a gesture anyway, the element dispatches its own cancel first. The core does not emit one when it is disposed mid-gesture: the runtime unsubscribes before it tears the machine down, so nothing reaches a listener.
This is the reason #121 no longer depends on #122. Changing items on a live controller remains a separate feature: it is about preserving gesture state across a change, not about making declarative items work.
Where the proposal does not match the core
These are the gaps to resolve before writing any code. Some are element-side work, some need changes to the core, and some are probably separate issues.
Items only have an id and a label. MarkingMenuItemInput is { id?, label, items? }. There is no value, no disabled, and no shortcut. The natural mapping is text content to label and the id attribute to id, and anything else is a new feature in the core first. Ids must be unique across the whole menu; the model throws when they are not.
There is no way to ask for a direction. An item's angle comes from its index: the first item points right and the rest follow clockwise, in 90 degree steps for up to four items and 45 degree steps beyond that. Item three of four is always at the bottom. A direction attribute would need the model to accept explicit angles, or sparse slots, and it accepts neither. If a direction attribute is wanted, it needs its own issue.
Labels are written with innerHTML. src/layout/menu.ts interpolates the label straight into a template string. A label can already carry markup today, and taking labels from DOM text makes that far likelier to happen by accident. Fix it before the element ships.
Styles are global, and the menu renders in light DOM. The stylesheet is injected once into document.head, and the menu is appended to the parent element. That works for a plain container, but it rules out the obvious element design: put the menu in a shadow root and no injected style will reach it. Since ::part() needs a shadow root to mean anything, the styling hooks in this proposal and the current rendering cannot both stand. Either the renderer learns to attach its stylesheet to whichever root it renders into, or the element keeps its menu in light DOM and the documented hooks are the existing class names.
The child elements must not render themselves. The parent element is what the controller draws into, and it also gets touch-action: none !important for as long as a controller is attached. <marking-menu-item> needs display: none, and the element needs to define its own display.
There is no keyboard input in the core at all. The controller listens for pointer events and nothing else. Keyboard navigation has to be designed and built from scratch.
Packaging
The package exports one entry point today. Adding marking-menu/element means a new export path, and it means the element entry must not be pulled into the default one.
package.json also declares "sideEffects": false, which is a promise a module that calls customElements.define breaks. Either the element entry is listed as having side effects, or it exports a define() the caller invokes, or both: a defineMarkingMenu() export plus a thin auto-registering entry for people who want a single import.
Keep the split proposed as marking-menu/core and marking-menu/element. It says plainly that the interaction engine stays usable without the DOM wrapper.
Events
The controller emits start, open, move, change, select, and cancel. The element re-dispatches them as DOM events on itself, bubbling and composed, with the controller's payload on detail. select carries selection, the model leaf that was picked, which holds the id and label the item was declared with.
Open question: whether the element emits all six, or only select plus a smaller set, and whether the payload exposes model nodes or a flatter shape better suited to a DOM event.
Accessibility
To define: keyboard navigation, focus management, accessible names taken from item content, and what ARIA roles fit an interaction that is a menu but is driven by a stroke.
Acceptance criteria
- A menu can be declared entirely in HTML with
<marking-menu-item> children, nested to any depth the core supports.
- Adding, removing, reordering, or editing children updates the menu, and doing so during a gesture has documented, deterministic behavior.
- Repeated updates leak no listeners, canvases, menu DOM, or
touch-action claims.
- Selection emits a documented DOM event carrying the selected item.
- An
items property offers the same configuration for menus built in code.
- The element's styling hooks are documented and work, in whichever rendering model is chosen.
- The interaction engine stays usable without the element.
- Keyboard and screen reader behavior is documented and tested.
Summary
Ship the marking menu as a custom element, so it can be used from plain HTML and from any framework without a wrapper. Items are declared as child elements, the same way
<select>takes<option>: the children are the data, the parent owns the interaction and the rendering.Everything below is a proposal. None of it is settled, and the parts that do not line up with the core as it stands today are called out as such.
Proposed strategy for updates
The element watches its children with a
MutationObserver, and when they change it disposes its controller and creates a new one. It does not try to update a live controller in place.This works because disposal is complete:
dispose()removes the rendered DOM, drops the pointer listeners, releases the capture, and gives back the parent'stouch-actionclaim. Nothing leaks, and the next controller starts from a clean slate. Mutation records arrive in a microtask, so a batch of changes costs one rebuild.Two details make it behave well:
touch-actionclaim from dropping to zero, so the parent's inline value is never restored and immediately re-taken.startopens a gesture,selectorcancelcloses it. Rebuilding at the end of the gesture rather than in the middle of it means the user never has the menu pulled out from under them. A mutation is a microtask and a gesture is human-scale, so the delay is invisible in practice.If a rebuild has to happen during a gesture anyway, the element dispatches its own
cancelfirst. The core does not emit one when it is disposed mid-gesture: the runtime unsubscribes before it tears the machine down, so nothing reaches a listener.This is the reason #121 no longer depends on #122. Changing items on a live controller remains a separate feature: it is about preserving gesture state across a change, not about making declarative items work.
Where the proposal does not match the core
These are the gaps to resolve before writing any code. Some are element-side work, some need changes to the core, and some are probably separate issues.
Items only have an id and a label.
MarkingMenuItemInputis{ id?, label, items? }. There is novalue, nodisabled, and noshortcut. The natural mapping is text content tolabeland theidattribute toid, and anything else is a new feature in the core first. Ids must be unique across the whole menu; the model throws when they are not.There is no way to ask for a direction. An item's angle comes from its index: the first item points right and the rest follow clockwise, in 90 degree steps for up to four items and 45 degree steps beyond that. Item three of four is always at the bottom. A
directionattribute would need the model to accept explicit angles, or sparse slots, and it accepts neither. If a direction attribute is wanted, it needs its own issue.Labels are written with
innerHTML.src/layout/menu.tsinterpolates the label straight into a template string. A label can already carry markup today, and taking labels from DOM text makes that far likelier to happen by accident. Fix it before the element ships.Styles are global, and the menu renders in light DOM. The stylesheet is injected once into
document.head, and the menu is appended to the parent element. That works for a plain container, but it rules out the obvious element design: put the menu in a shadow root and no injected style will reach it. Since::part()needs a shadow root to mean anything, the styling hooks in this proposal and the current rendering cannot both stand. Either the renderer learns to attach its stylesheet to whichever root it renders into, or the element keeps its menu in light DOM and the documented hooks are the existing class names.The child elements must not render themselves. The parent element is what the controller draws into, and it also gets
touch-action: none !importantfor as long as a controller is attached.<marking-menu-item>needsdisplay: none, and the element needs to define its owndisplay.There is no keyboard input in the core at all. The controller listens for pointer events and nothing else. Keyboard navigation has to be designed and built from scratch.
Packaging
The package exports one entry point today. Adding
marking-menu/elementmeans a new export path, and it means the element entry must not be pulled into the default one.package.jsonalso declares"sideEffects": false, which is a promise a module that callscustomElements.definebreaks. Either the element entry is listed as having side effects, or it exports adefine()the caller invokes, or both: adefineMarkingMenu()export plus a thin auto-registering entry for people who want a single import.Keep the split proposed as
marking-menu/coreandmarking-menu/element. It says plainly that the interaction engine stays usable without the DOM wrapper.Events
The controller emits
start,open,move,change,select, andcancel. The element re-dispatches them as DOM events on itself, bubbling and composed, with the controller's payload ondetail.selectcarriesselection, the model leaf that was picked, which holds theidandlabelthe item was declared with.Open question: whether the element emits all six, or only
selectplus a smaller set, and whether the payload exposes model nodes or a flatter shape better suited to a DOM event.Accessibility
To define: keyboard navigation, focus management, accessible names taken from item content, and what ARIA roles fit an interaction that is a menu but is driven by a stroke.
Acceptance criteria
<marking-menu-item>children, nested to any depth the core supports.touch-actionclaims.itemsproperty offers the same configuration for menus built in code.