From b1465d66677f5788b5572956da7792b1844a1741 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 00:29:32 +0200 Subject: [PATCH 1/2] feat: add the Menu primitive Menu / Trigger / Portal / Content / Item / Group / GroupLabel / Separator, shipped as an agnostic core machine (@dunky.dev/menu) plus a thin React binding (@dunky.dev/react-menu). A WAI-ARIA menu-button: arrow-key highlight with wrap, Home/End, typeahead, activedescendant focus, select-then-close items, and layer-stack-aware Escape/outside dismissal. Co-Authored-By: Claude Fable 5 --- .changeset/menu.md | 53 ++ packages/core/menu/README.md | 31 ++ packages/core/menu/SPEC.md | 184 ++++++ packages/core/menu/package.json | 40 ++ packages/core/menu/src/connect.ts | 205 +++++++ packages/core/menu/src/index.ts | 22 + packages/core/menu/src/machine.ts | 319 +++++++++++ packages/core/menu/src/types.ts | 116 ++++ packages/core/menu/tests/machine.test.ts | 557 +++++++++++++++++++ packages/react/menu/README.md | 47 ++ packages/react/menu/SPEC.md | 135 +++++ packages/react/menu/package.json | 54 ++ packages/react/menu/src/context.ts | 36 ++ packages/react/menu/src/effects.ts | 39 ++ packages/react/menu/src/index.ts | 12 + packages/react/menu/src/menu.tsx | 297 ++++++++++ packages/react/menu/src/use-menu.ts | 17 + packages/react/menu/stories/menu.stories.tsx | 194 +++++++ packages/react/menu/tests/menu.test.tsx | 392 +++++++++++++ pnpm-lock.yaml | 43 ++ tsconfig.json | 2 + tsdown.config.ts | 2 + 22 files changed, 2797 insertions(+) create mode 100644 .changeset/menu.md create mode 100644 packages/core/menu/README.md create mode 100644 packages/core/menu/SPEC.md create mode 100644 packages/core/menu/package.json create mode 100644 packages/core/menu/src/connect.ts create mode 100644 packages/core/menu/src/index.ts create mode 100644 packages/core/menu/src/machine.ts create mode 100644 packages/core/menu/src/types.ts create mode 100644 packages/core/menu/tests/machine.test.ts create mode 100644 packages/react/menu/README.md create mode 100644 packages/react/menu/SPEC.md create mode 100644 packages/react/menu/package.json create mode 100644 packages/react/menu/src/context.ts create mode 100644 packages/react/menu/src/effects.ts create mode 100644 packages/react/menu/src/index.ts create mode 100644 packages/react/menu/src/menu.tsx create mode 100644 packages/react/menu/src/use-menu.ts create mode 100644 packages/react/menu/stories/menu.stories.tsx create mode 100644 packages/react/menu/tests/menu.test.tsx diff --git a/.changeset/menu.md b/.changeset/menu.md new file mode 100644 index 0000000..9cd9fdd --- /dev/null +++ b/.changeset/menu.md @@ -0,0 +1,53 @@ +--- +'@dunky.dev/menu': minor +'@dunky.dev/react-menu': minor +--- + +Add the Menu primitive — a dropdown menu following the WAI-ARIA APG menu-button +pattern, shipped as an agnostic core (`@dunky.dev/menu`) plus a React binding +(`@dunky.dev/react-menu`). + +Open it by press or keyboard (Enter/Space/ArrowDown highlight the first enabled +item, ArrowUp the last); while open, arrows move the highlight with wrap, +Home/End jump, printable characters run typeahead over the item labels, and +disabled items are skipped everywhere. Activating an item fires its `onSelect`, +then the menu closes; Escape (returning focus to the trigger), Tab, and any +outside interaction dismiss. The highlight is exposed through +`aria-activedescendant` — DOM focus stays on the menu surface — and styling +hooks are `data-state` on trigger/content plus `data-highlighted` / +`data-disabled` on items. The menu joins the shared overlay layer stack, so +Escape and outside presses unwind stacked overlays one layer at a time. + +Controlled `open` follows the dialog's contract: every open/close intent — +trigger press, keyboard open, Escape, Tab, outside interaction, item +activation — is reported through `onOpenChange`, and the menu moves only when +the prop does, so ignoring a report is a working veto. + +```tsx +import { Menu } from '@dunky.dev/react-menu' + +function App() { + return ( + + Actions + + + + Rename + + + Archive + + + + Danger zone + + Delete + + + + + + ) +} +``` diff --git a/packages/core/menu/README.md b/packages/core/menu/README.md new file mode 100644 index 0000000..c2bf8d0 --- /dev/null +++ b/packages/core/menu/README.md @@ -0,0 +1,31 @@ +# @dunky.dev/menu + +The framework-agnostic menu interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers +pair it with a substrate driver rather than driving the machine directly. + +The behavior contract — scenarios, guarantees, driver obligations — lives in +[SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/menu +``` + +## Usage + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { menuMachine, menuConnect } from '@dunky.dev/menu' + +// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids. +const service = machine(menuMachine({ ...options, id: 'my-menu' })) +connector(service, menuConnect, options) // wires the consumer callbacks +service.start() + +// The driver reports rendered items as data; navigation stays in the core. +service.send({ type: 'item.register', item: { value: 'copy', label: 'Copy', disabled: false } }) +service.send({ type: 'open', highlight: 'first' }) +service.send({ type: 'item.activate' }) +``` diff --git a/packages/core/menu/SPEC.md b/packages/core/menu/SPEC.md new file mode 100644 index 0000000..3574605 --- /dev/null +++ b/packages/core/menu/SPEC.md @@ -0,0 +1,184 @@ +# SPEC / Menu + +## Reference + +- **W3C pattern**: [APG Menu Button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) + and [APG Menu and Menubar](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/), + over the normative + [WAI-ARIA 1.2 `menu` / `menuitem`](https://www.w3.org/TR/wai-aria-1.2/#menu) + definitions. +- **State machine**: built on `@dunky.dev/state-machine`. +- **Prior art**: API shape modeled on the Radix DropdownMenu, Base UI Menu, and + Ark Menu. +- **Layering**: dismissal routing across stacked overlays follows the shared + layer-stack contract (`@dunky.dev/dom-layer-stack`). + +## Overview + +A menu is a list of actions opened from a button: the user opens it, picks one +action, and the menu goes away. It coexists with the page rather than +interrupting it — it is not modal, holds no form content, and every item is a +command, not a destination for focus to live in. + +## Anatomy + +``` + — root; owns open/close state and the highlight, renders nothing of its own + |_ — the button that opens the menu; focus returns here on close + |_ — the menu surface; holds DOM focus while open + |_ — one action; activating it reports the selection and closes the menu + |_ — groups related items under a name + | |_ — names its group + | |_ + |_ — divides sets of items +``` + +## Behavior + +Using the menu is a walkthrough of intent, not a prop list: + +- The **root** owns open/close state, exposed controlled and uncontrolled + exactly like the dialog: an uncontrolled menu can be seeded open, while a + controlled consumer drives it from outside. Every open/close intent — + trigger press, keyboard open, Escape, Tab, outside interaction, item + activation — is reported back through the open-change callback; a controlled + menu stops there and only moves when the `open` prop does, so ignoring a + reported intent is how the consumer vetoes it. Whether the menu is + controlled is fixed at mount. +- The **trigger** toggles the menu on press and carries the popup relationship + to assistive tech. Opening from the keyboard also aims the highlight: Enter, + Space, and ArrowDown open with the first enabled item highlighted; ArrowUp + opens with the last. A pointer press opens with nothing highlighted. +- The **content** is the menu surface. While open it holds real DOM focus and + exposes the highlighted item through `aria-activedescendant` — the highlight + is machine state, not roving DOM focus (see [Design](#design)). +- An **item** is one action, identified by a unique `value`. The highlight + moves over enabled items — by arrow keys with wrap, Home/End jumps, pointer + hover, or typeahead — and disabled items are always skipped. Activating an + item (Enter or Space on the highlighted item, or a press) reports the + selection to that item's consumer callback and then closes the menu. + Disabled items never highlight and never activate. +- **Group / GroupLabel** name a related run of items; the group's ARIA name + follows what is actually rendered — a group whose label is removed never + keeps a dangling reference. +- The **separator** divides sets of items, semantically and visually. + +Dismissal: Escape closes and is reported through a veto-able callback; Tab +closes and lets focus continue on its way; any interaction outside the menu — +press or focus landing elsewhere — closes it, also veto-able from its +callback. Every one of these still reports through the open-change callback. + +Items register with the machine as they appear and unregister as they +disappear — value, typeahead label, and disabled state — so the highlight, +typeahead, and activation always operate on what is actually rendered. The +registry keeps mount order — document order at mount time: a prop change on a +mounted item updates it in place and never moves it, but an item mounted +mid-list while the menu is open registers at the end. Re-ordering a live +registry to match the DOM is out of scope for v0. + +### Highlight + +The highlight is a single value: at most one item is highlighted at a time, +and only enabled, registered items can hold it. Arrow moves wrap from either +end. Pointer hover highlights the item under the pointer and leaving it clears +the highlight. Closing the menu always clears the highlight — the next open +starts fresh. + +A keyboard open aims the highlight before any item exists yet (items register +only once the menu shows), so the intent is held as pending and resolved +against the items as they register; any explicit highlight move cancels the +pending intent. + +### Typeahead + +While open, printable characters accumulate into a query (reset after a ~1s +pause) and the highlight jumps to the next enabled item whose label starts +with the query, searching forward from the current highlight and wrapping. +Repeating the same character cycles through the items starting with it. +Labels come from item registration. + +## States + +| State | Behavior | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `closed` | Only the trigger is shown. Open intents (trigger press, keyboard open with a highlight aim, imperative open) move to `open`. | +| `open` | The content is shown and owns the highlight. Close intents (close, toggle, Escape, Tab, outside interaction, item activation) move back to `closed`. | + +Controlled, the intents above only report through the open-change callback — +the `open` prop drives the actual transitions. + +## Accessibility + +Per APG Menu Button: + +- **Roles**: the trigger is a button with `aria-haspopup="menu"` and + `aria-expanded`; the content is `role="menu"` (vertical), labelled by the + trigger; items are `role="menuitem"`; groups are `role="group"`, labelled by + their rendered GroupLabel; separators are `role="separator"`. +- **Relationships**: the trigger's `aria-controls` references the content only + while it is rendered — never a dangling id. +- **Keyboard, on the trigger**: Enter, Space, ArrowDown open and highlight the + first enabled item; ArrowUp opens and highlights the last. +- **Keyboard, while open**: ArrowDown/ArrowUp move the highlight with wrap, + Home/End jump to the ends, printable characters run typeahead, Enter and + Space activate the highlighted item, Escape closes and returns focus to the + trigger, Tab closes. +- **Focus**: opening moves DOM focus to the content; the highlighted item is + exposed via `aria-activedescendant`; closing restores focus to the element + focused before opening (normally the trigger). +- **Disabled items**: perceivable (`aria-disabled`) but never highlighted, + never activated, and skipped by every navigation. +- **Layering**: the menu registers as a non-modal layer on the shared overlay + stack — Escape and outside interactions belong to the topmost layer only, + and a menu stacked above a modal layer stays reachable. + +## Constraints + +- At most one item is highlighted; `aria-activedescendant` references a + rendered, enabled item or is absent — never a stale or disabled one. +- Item values are unique within a menu; ids are derived from them, so they + must be usable as id fragments (no whitespace). +- Activation reports the selection before the close is observable, and the + menu always closes after activating an enabled item. +- Every open ⇄ close intent, whatever its source, is reported to the + consumer. A controlled menu never transitions on its own — it follows the + `open` prop alone, and a prop-driven transition is not echoed back. +- ARIA labelled-by / controls must only reference elements that are actually + rendered. +- Out of scope in v0: submenus, checkbox/radio items, context-menu + (right-click) triggering, keeping the menu open after a selection, and any + positioning engine — anchoring the content is the consumer's concern; + `data-state` on trigger/content and `data-highlighted` / `data-disabled` on + items are the styling hooks. + +## Design + +- **`aria-activedescendant` over roving DOM focus.** The highlight is machine + state: one context value that every substrate renders as one attribute plus + a `data-highlighted` hook. Roving focus would make each binding move real + focus item-by-item — imperative work, ordering decisions, and blur/focus + bookkeeping in every substrate, against "a binding adds no behavior". With + active-descendant the binding only ever renders state, DOM focus rests on + the content the whole time, and the core alone decides where the highlight + goes. This is the Zag/Ark model; the trade-off is that item styling keys off + `[data-highlighted]` rather than `:focus`. +- **Items registered as data.** Navigation, typeahead, and activation are core + decisions, so the core must know the items: each substrate reports + value/label/disabled as items mount and unmount, and the machine operates + only on that registry — never on the DOM. +- **A keyboard open holds a pending highlight.** Items mount after the open + transition, so "open with the first item highlighted" cannot resolve + immediately; the machine stores the aim and resolves it as registrations + arrive, keeping the decision in the core instead of leaking mount timing + into a substrate. +- **Selection is a mailbox.** An activation names an item that the consumer + listens to per-item, not per-root; the machine records the selection as a + fresh token in context for the substrate to deliver to that item's callback. + The machine never calls a consumer callback directly. +- **Typeahead cannot see keyboard modifiers.** The shared `KeyboardPayload` + vocabulary carries only `key`, so a modifier-chorded printable (Cmd+C, + Ctrl+F) is indistinguishable from a plain character and runs typeahead, + where the reference libraries skip it. Accepted for v0: the fix is extending + `KeyboardPayload` with modifier flags in + `@dunky.dev/state-machine-bindings` — a shared-vocabulary change, not a + menu-local workaround. diff --git a/packages/core/menu/package.json b/packages/core/menu/package.json new file mode 100644 index 0000000..01034fb --- /dev/null +++ b/packages/core/menu/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/menu", + "version": "0.0.0", + "description": "Framework-agnostic menu interaction, modeled as a state machine.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/core/menu" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "^0.1.0", + "@dunky.dev/state-machine-bindings": "^0.1.0" + } +} diff --git a/packages/core/menu/src/connect.ts b/packages/core/menu/src/connect.ts new file mode 100644 index 0000000..cdc0233 --- /dev/null +++ b/packages/core/menu/src/connect.ts @@ -0,0 +1,205 @@ +import { makeReaction, type Connect } from '@dunky.dev/state-machine' +import type { + AttrBindings, + EventBindings, + KeyboardPayload, + PointerPayload, +} from '@dunky.dev/state-machine-bindings' +import type { + MenuContext, + MenuHighlightAim, + MenuIds, + MenuMachineEvent, + MenuOptions, + MenuStateName, +} from './types' + +// The bindings a part carries, drawn from the shared agnostic vocabulary; the +// index signature keeps parts assignable to the loose shape each substrate's +// normalize() accepts. `data-state` is the styling/animation hook. +export type MenuPartBindings = EventBindings & + AttrBindings & { 'data-state'?: MenuStateName } & Record + +// The cross-part ids all derive from the one base id, so the trigger's +// aria-controls, Content's id/labelledby, and activedescendant always agree. +function menuIds(id: string): MenuIds { + return { trigger: `${id}-trigger`, content: `${id}-content` } +} + +// Item ids derive from the item's value — unique and id-safe by contract. +function menuItemId(id: string, value: string): string { + return `${id}-item-${value}` +} + +/** The per-item facts a substrate hands over when asking for item bindings. */ +export interface MenuItemBindingsProps { + value: string + disabled?: boolean +} + +/** The per-group identity (substrate-minted) for group/label bindings. */ +export interface MenuGroupBindingsProps { + id: string +} + +/** The view-facing surface a driver reads from the running menu machine. */ +export interface MenuApi { + open: boolean + highlightedValue: string | null + ids: MenuIds + setOpen: (open: boolean) => void + /** Routes a detected outside interaction as a veto-able dismissal intent. */ + interactOutside: (event?: PointerPayload) => void + parts: { + trigger: MenuPartBindings + content: MenuPartBindings + separator: MenuPartBindings + } + // Items and groups are many per menu, so their bindings are derived from the + // instance facts rather than carried as static parts. + getItemBindings: (props: MenuItemBindingsProps) => MenuPartBindings + getGroupBindings: (props: MenuGroupBindingsProps) => MenuPartBindings + getGroupLabelBindings: (props: MenuGroupBindingsProps) => MenuPartBindings +} + +export const menuConnect: Connect< + MenuStateName, + MenuContext, + MenuMachineEvent, + MenuOptions, + MenuApi +> = ({ state, context, props, send }) => { + const open = state === 'open' + const dataState: MenuStateName = open ? 'open' : 'closed' + const ids = menuIds(context.id) + + // A keyboard open aims the highlight; preventDefault suppresses the key's + // native action (synthetic click, page scroll) so the send is the only + // effect. Already open — reachable when focus stayed on the trigger, e.g. a + // controlled open — Enter/Space keep their native meaning and toggle-close + // instead of being swallowed by a re-open the open state ignores. + const onTriggerKeyDown = (event?: KeyboardPayload): void => { + const key = event?.key + let aim: MenuHighlightAim | undefined + if (key === 'Enter' || key === ' ' || key === 'ArrowDown') aim = 'first' + else if (key === 'ArrowUp') aim = 'last' + if (aim === undefined) return + event?.preventDefault?.() + if (open) { + if (key === 'Enter' || key === ' ') send({ type: 'toggle' }) + return + } + send({ type: 'open', highlight: aim }) + } + + // DOM focus rests on the content while open (see SPEC: Design), so every + // menu key arrives here. Tab keeps its default action — the close lets + // focus continue on its way. + const onContentKeyDown = (event?: KeyboardPayload): void => { + const key = event?.key + if (key === undefined) return + if (key === 'ArrowDown' || key === 'ArrowUp') { + event?.preventDefault?.() + send({ type: 'highlight.move', to: key === 'ArrowDown' ? 'next' : 'previous' }) + } else if (key === 'Home' || key === 'End') { + event?.preventDefault?.() + send({ type: 'highlight.move', to: key === 'Home' ? 'first' : 'last' }) + } else if (key === 'Enter' || key === ' ') { + event?.preventDefault?.() + send({ type: 'item.activate' }) + } else if (key === 'Tab') { + send({ type: 'tab' }) + } else if (key.length === 1) { + send({ type: 'typeahead', key }) + } + } + + // An outside interaction is an intent, not a close command: the consumer may + // veto it before the machine decides. + const interactOutside = (event?: PointerPayload): void => { + props.onInteractOutside?.(event) + if (event?.defaultPrevented !== true) send({ type: 'interact.outside' }) + } + + return { + open, + highlightedValue: context.highlightedValue, + ids, + setOpen(next) { + if (open === next) return + send({ type: next ? 'open' : 'close' }) + }, + interactOutside, + parts: { + trigger: { + id: ids.trigger, + hasPopup: 'menu', + expanded: open, + // A dangling aria-controls id is an a11y defect — only while open. + controls: open ? ids.content : undefined, + 'data-state': dataState, + onPress: () => send({ type: 'toggle' }), + onKeyDown: onTriggerKeyDown, + }, + content: { + role: 'menu', + id: ids.content, + labelledBy: context.parts.trigger ? ids.trigger : undefined, + activeDescendant: + context.highlightedValue === null + ? undefined + : menuItemId(context.id, context.highlightedValue), + orientation: 'vertical', + // The focus surface while open: focusable in script, not in the tab order. + focusable: false, + 'data-state': dataState, + onKeyDown: onContentKeyDown, + }, + separator: { + role: 'separator', + orientation: 'horizontal', + }, + }, + getItemBindings({ value, disabled = false }) { + const highlighted = context.highlightedValue === value + return { + role: 'menuitem', + id: menuItemId(context.id, value), + disabled: disabled || undefined, + 'data-highlighted': highlighted ? '' : undefined, + 'data-disabled': disabled ? '' : undefined, + onPress: () => send({ type: 'item.activate', value }), + onPointerEnter: () => send({ type: 'highlight.set', value }), + onPointerLeave: () => send({ type: 'highlight.set', value: null }), + } + }, + getGroupBindings({ id }) { + return { + role: 'group', + // Only reference a label that is actually rendered. + labelledBy: context.groupLabels[id] === true ? `${id}-label` : undefined, + } + }, + getGroupLabelBindings({ id }) { + return { id: `${id}-label` } + }, + } +} + +const reaction = makeReaction() + +// One reaction per consumer callback. Reactions fire in registration order within +// a single setContext — that order is the callback-order contract. The per-item +// selection callback rides the mailbox instead (see SPEC: Design), delivered by +// the substrate before the close is observable. +// onOpenChange reads the intent mailbox, not the state: a controlled machine +// reports intents without moving, and the prop-driven `controlled.sync` +// transition never writes the mailbox — the consumer's own change isn't echoed. +menuConnect.reactions = [ + reaction( + m => m.context.openIntent, + (intent, props) => { + if (intent !== null) props.onOpenChange?.(intent.open) + }, + ), +] diff --git a/packages/core/menu/src/index.ts b/packages/core/menu/src/index.ts new file mode 100644 index 0000000..1d1093c --- /dev/null +++ b/packages/core/menu/src/index.ts @@ -0,0 +1,22 @@ +export { menuMachine, type MenuMachine } from './machine' +export { + menuConnect, + type MenuApi, + type MenuGroupBindingsProps, + type MenuItemBindingsProps, + type MenuPartBindings, +} from './connect' +export type { + MenuCallbacks, + MenuContext, + MenuHighlightAim, + MenuHighlightMove, + MenuIds, + MenuItem, + MenuMachineEvent, + MenuOptions, + MenuPart, + MenuSelection, + MenuStateName, +} from './types' +export type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings' diff --git a/packages/core/menu/src/machine.ts b/packages/core/menu/src/machine.ts new file mode 100644 index 0000000..3559b60 --- /dev/null +++ b/packages/core/menu/src/machine.ts @@ -0,0 +1,319 @@ +import { + and, + setup, + type Action, + type Guard, + type Machine, + type TransitionConfig, +} from '@dunky.dev/state-machine' +import type { MenuContext, MenuItem, MenuMachineEvent, MenuOptions, MenuStateName } from './types' + +/** The running menu machine — what a substrate holds and sends events to. */ +export type MenuMachine = Machine + +type MenuAction = Action +type MenuGuard = Guard + +/** Pause after which the typeahead query starts over. */ +const TYPEAHEAD_RESET_MS = 1000 + +// Registry lookups +// ----------------------------------------------------------------------------- + +function indexOfValue(items: MenuItem[], value: string | null): number { + if (value !== null) { + for (let index = 0; index < items.length; index++) { + if (items[index].value === value) return index + } + } + return -1 +} + +function findItem(items: MenuItem[], value: string | null): MenuItem | undefined { + const index = indexOfValue(items, value) + return index === -1 ? undefined : items[index] +} + +function firstEnabled(items: MenuItem[]): string | null { + for (let index = 0; index < items.length; index++) { + if (!items[index].disabled) return items[index].value + } + return null +} + +function lastEnabled(items: MenuItem[]): string | null { + for (let index = items.length - 1; index >= 0; index--) { + if (!items[index].disabled) return items[index].value + } + return null +} + +/** The next/previous enabled item from `from`, wrapping past either end. */ +function stepEnabled(items: MenuItem[], from: number, delta: 1 | -1): string | null { + const count = items.length + for (let step = 1; step <= count; step++) { + const index = (((from + delta * step) % count) + count) % count + if (!items[index].disabled) return items[index].value + } + return null +} + +/** + * The next enabled typeahead match: forward from the item after the current + * highlight, wrapping. A repeated character collapses to its first character + * and excludes the current item, so it cycles; a growing query keeps the + * current item eligible so extending the match never jumps away. + */ +function findTypeaheadMatch( + items: MenuItem[], + highlightedValue: string | null, + query: string, +): string | null { + const lowered = query.toLowerCase() + let repeated = lowered.length > 1 + for (let index = 1; index < lowered.length; index++) { + if (lowered[index] !== lowered[0]) { + repeated = false + break + } + } + const search = repeated ? lowered[0] : lowered + + const count = items.length + const start = indexOfValue(items, highlightedValue) + for (let step = 1; step <= count; step++) { + // With no highlight the scan is plain document order; step==count lands + // back on the current item — allowed only while the query is extending. + const index = start === -1 ? step - 1 : (start + step) % count + if (start !== -1 && step === count && search.length === 1) break + const item = items[index] + if (!item.disabled && item.label.toLowerCase().startsWith(search)) return item.value + } + return null +} + +// Actions +// ----------------------------------------------------------------------------- + +/** + * Re-derive the highlight after a registry change: a pending keyboard aim + * resolves against what is registered so far, and a highlight whose item + * disappeared or became disabled is dropped — activedescendant never goes + * stale (see SPEC constraints). + */ +function reconcileHighlight(items: MenuItem[], context: MenuContext): Partial { + if (context.pendingHighlight !== null) { + const aimed = context.pendingHighlight === 'first' ? firstEnabled(items) : lastEnabled(items) + if (aimed !== null) return { highlightedValue: aimed } + // No enabled item to aim at — fall through so a highlight whose item + // disappeared or got disabled is still dropped rather than left stale. + } + const current = findItem(items, context.highlightedValue) + if (context.highlightedValue !== null && (current === undefined || current.disabled)) { + return { highlightedValue: null } + } + return {} +} + +// Re-registering a value updates the item in place so a prop change (e.g. +// disabled flipping) never moves the item to the end of the order. +const registerItem: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'item.register') return + const items = [...context.items] + const index = indexOfValue(items, event.item.value) + if (index === -1) items.push(event.item) + else items[index] = event.item + setContext({ items, ...reconcileHighlight(items, context) }) +} + +const unregisterItem: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'item.unregister') return + const index = indexOfValue(context.items, event.value) + if (index === -1) return + const items = [...context.items] + items.splice(index, 1) + setContext({ items, ...reconcileHighlight(items, context) }) +} + +// An explicit highlight change cancels a pending keyboard aim. +const setHighlight: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'highlight.set') return + if (event.value === null) { + setContext({ highlightedValue: null, pendingHighlight: null }) + return + } + const item = findItem(context.items, event.value) + if (item === undefined || item.disabled) return + setContext({ highlightedValue: event.value, pendingHighlight: null }) +} + +const moveHighlight: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'highlight.move') return + const items = context.items + const current = indexOfValue(items, context.highlightedValue) + let next: string | null + if (event.to === 'first') next = firstEnabled(items) + else if (event.to === 'last') next = lastEnabled(items) + else if (event.to === 'next') { + next = current === -1 ? firstEnabled(items) : stepEnabled(items, current, 1) + } else { + next = current === -1 ? lastEnabled(items) : stepEnabled(items, current, -1) + } + setContext({ highlightedValue: next ?? context.highlightedValue, pendingHighlight: null }) +} + +const runTypeahead: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'typeahead') return + const now = Date.now() + const query = + now - context.typeaheadAt > TYPEAHEAD_RESET_MS ? event.key : context.typeaheadQuery + event.key + const match = findTypeaheadMatch(context.items, context.highlightedValue, query) + if (match === null) { + setContext({ typeaheadQuery: query, typeaheadAt: now }) + return + } + setContext({ + typeaheadQuery: query, + typeaheadAt: now, + highlightedValue: match, + pendingHighlight: null, + }) +} + +// The aim belongs to the latest open intent: a keyboard open carries one, a +// press carries none. It is written even when a controlled machine stays put, +// so the aim survives until the prop echo opens — and a press overwrites a +// vetoed keyboard aim instead of inheriting it. +const aimHighlight: MenuAction = ({ event, setContext }) => { + if (event.type === 'open') setContext({ pendingHighlight: event.highlight ?? null }) + else if (event.type === 'toggle') setContext({ pendingHighlight: null }) +} + +// Runs before the state switches to closed, so the selection is written — and +// observable through the mailbox — while the menu still reads as open. +const recordSelection: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'item.activate') return + const value = event.value ?? context.highlightedValue + if (value === null) return + setContext({ selection: { value } }) +} + +// Every close starts the next open fresh (see SPEC: Highlight). +const resetHighlight: MenuAction = ({ setContext }) => { + setContext({ highlightedValue: null, pendingHighlight: null, typeaheadQuery: '' }) +} + +const setPartPresence: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'part.presence') return + setContext({ parts: { ...context.parts, [event.part]: event.present } }) +} + +const setGroupLabelPresence: MenuAction = ({ event, context, setContext }) => { + if (event.type !== 'group.label.presence') return + setContext({ groupLabels: { ...context.groupLabels, [event.group]: event.present } }) +} + +// Every open/close intent lands in the mailbox; the connect's reaction turns +// it into onOpenChange. Uncontrolled, the intent rides along with the +// transition; controlled, the intent IS the outcome — the machine stays put +// until `controlled.sync` echoes the prop back. +const requestOpen: MenuAction = ({ setContext }) => setContext({ openIntent: { open: true } }) +const requestClose: MenuAction = ({ setContext }) => setContext({ openIntent: { open: false } }) + +// Guards +// ----------------------------------------------------------------------------- + +const isControlled: MenuGuard = ({ context }) => context.controlled +const syncOpens: MenuGuard = ({ event }) => event.type === 'controlled.sync' && event.open +const syncCloses: MenuGuard = ({ event }) => event.type === 'controlled.sync' && !event.open + +// Only an enabled, registered item activates — a disabled press or an empty +// highlight leaves the menu open and the mailbox untouched. +const canActivate: MenuGuard = ({ context, event }) => { + if (event.type !== 'item.activate') return false + const item = findItem(context.items, event.value ?? context.highlightedValue) + return item !== undefined && !item.disabled +} + +export function menuMachine( + options: MenuOptions, +): TransitionConfig { + // Annotated so createMachine infers Context as MenuContext, not the narrowed literal. + const context: MenuContext = { + // The substrate supplies a unique id; `menu` is only a bare fallback. + id: options.id ?? 'menu', + controlled: options.open !== undefined, + openIntent: null, + items: [], + highlightedValue: null, + pendingHighlight: null, + typeaheadQuery: '', + typeaheadAt: 0, + selection: null, + parts: { trigger: false }, + groupLabels: {}, + } + + return setup.as().createMachine({ + initial: (options.open ?? options.defaultOpen) === true ? 'open' : 'closed', + context, + // Top-level: parts and items report their presence from any state — close + // unmounts the items, so their unregistrations arrive while closed. + on: { + 'item.register': { actions: registerItem }, + 'item.unregister': { actions: unregisterItem }, + 'part.presence': { actions: setPartPresence }, + 'group.label.presence': { actions: setGroupLabelPresence }, + }, + // Each open/close intent lists two candidates — first guard wins: + // controlled only writes the mailbox; uncontrolled also takes the transition. + states: { + closed: { + entry: [resetHighlight], + on: { + open: [ + { guard: isControlled, actions: [aimHighlight, requestOpen] }, + { target: 'open', actions: [aimHighlight, requestOpen] }, + ], + toggle: [ + { guard: isControlled, actions: [aimHighlight, requestOpen] }, + { target: 'open', actions: [aimHighlight, requestOpen] }, + ], + 'controlled.sync': { target: 'open', guard: syncOpens }, + }, + }, + open: { + on: { + close: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + toggle: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + escape: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + tab: [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + 'interact.outside': [ + { guard: isControlled, actions: requestClose }, + { target: 'closed', actions: requestClose }, + ], + 'item.activate': [ + { guard: and(canActivate, isControlled), actions: [recordSelection, requestClose] }, + { guard: canActivate, target: 'closed', actions: [recordSelection, requestClose] }, + ], + 'controlled.sync': { target: 'closed', guard: syncCloses }, + 'highlight.set': { actions: setHighlight }, + 'highlight.move': { actions: moveHighlight }, + typeahead: { actions: runTypeahead }, + }, + }, + }, + }) +} diff --git a/packages/core/menu/src/types.ts b/packages/core/menu/src/types.ts new file mode 100644 index 0000000..7624f29 --- /dev/null +++ b/packages/core/menu/src/types.ts @@ -0,0 +1,116 @@ +// Public + machine-facing types for the framework-agnostic menu primitive. +// The state machine is substrate-free: all event reading lives in a +// per-substrate driver. +import type { KeyboardPayload, PointerPayload } from '@dunky.dev/state-machine-bindings' + +export type MenuStateName = 'closed' | 'open' + +/** Where a keyboard open aims the highlight before any item registers. */ +export type MenuHighlightAim = 'first' | 'last' + +/** A highlight move over the registered, enabled items. */ +export type MenuHighlightMove = 'first' | 'last' | 'next' | 'previous' + +/** The parts whose presence drives cross-part ARIA references. */ +export type MenuPart = 'trigger' + +/** One registered item — the data navigation, typeahead, and activation + * operate on. Substrates report it as items mount and unmount. */ +export interface MenuItem { + /** Unique, id-safe identity of the item within the menu. */ + value: string + /** The typeahead label. */ + label: string + disabled: boolean +} + +/** The selection mailbox token: a fresh object per activation, so the same + * item selected twice still reads as a change. */ +export interface MenuSelection { + value: string +} + +/** + * The cross-part ids, derived from the one `id` on context: each renders as + * `id` on one element and as an ARIA reference (aria-controls / labelledby / + * activedescendant) on another, and the connect wires both sides. + */ +export interface MenuIds { + trigger: string + content: string +} + +export interface MenuContext { + // The base id (substrate-minted, SSR-safe); the connect derives the per-part + // ids from it. + id: string + // Whether the consumer controls `open`. Fixed at build time: a controlled + // machine never moves on its own — intents go out through `openIntent` and + // only `controlled.sync` (the prop echo) transitions it. + controlled: boolean + // Emission mailbox for onOpenChange: a fresh token per intent so the + // reaction fires even when the intended value repeats. + openIntent: { open: boolean } | null + /** Registered items, in mount order — document order at mount time. An item + * mounted mid-list while the menu is open registers at the end (see SPEC). */ + items: MenuItem[] + /** The single highlight; only an enabled, registered item can hold it. */ + highlightedValue: string | null + /** A keyboard open's aim, held until items register to resolve it. */ + pendingHighlight: MenuHighlightAim | null + typeaheadQuery: string + /** Timestamp of the last typeahead character, for the reset pause. */ + typeaheadAt: number + /** The selection mailbox the substrate delivers to the item's callback. */ + selection: MenuSelection | null + // Which optional parts are currently present, so Content only references the + // ones actually rendered. + parts: Record + /** Group ids whose label is currently rendered. */ + groupLabels: Record +} + +// Dismissal intents (`escape` / `tab` / `interact.outside`) are distinct from +// `close` so each stays attributable to its cause. `controlled.sync` is the +// controlled driver: the substrate sends it when the `open` prop changes, and +// it is the only event that moves a controlled machine. +export type MenuMachineEvent = + | { type: 'open'; highlight?: MenuHighlightAim } + | { type: 'close' } + | { type: 'toggle' } + | { type: 'escape' } + | { type: 'tab' } + | { type: 'interact.outside' } + | { type: 'controlled.sync'; open: boolean } + | { type: 'item.register'; item: MenuItem } + | { type: 'item.unregister'; value: string } + | { type: 'item.activate'; value?: string } + | { type: 'highlight.set'; value: string | null } + | { type: 'highlight.move'; to: MenuHighlightMove } + | { type: 'typeahead'; key: string } + | { type: 'part.presence'; part: MenuPart; present: boolean } + | { type: 'group.label.presence'; group: string; present: boolean } + +export interface MenuCallbacks { + /** Fired with every open/close intent. Uncontrolled, the menu also moves; + * controlled, it moves only when the `open` prop follows. */ + onOpenChange?: (open: boolean) => void + /** Fired before an Escape dismissal; `preventDefault()` vetoes it. */ + onEscapeKeyDown?: (event: KeyboardPayload) => void + /** Fired before an outside-interaction dismissal; `preventDefault()` vetoes it. */ + onInteractOutside?: (event?: PointerPayload) => void +} + +/** + * The agnostic menu options — the behavior a consumer configures. A + * substrate's props extend this with its own concerns (e.g. `children`). + */ +export interface MenuOptions extends MenuCallbacks { + /** Base id for the menu's parts; the substrate supplies a unique, SSR-safe + * one. The per-part ids (trigger/content/items) are derived from it. */ + id?: string + /** Controlled open state; every open/close intent is reported through `onOpenChange`. */ + open?: boolean + /** Initial open state for the uncontrolled menu. @default false */ + defaultOpen?: boolean +} diff --git a/packages/core/menu/tests/machine.test.ts b/packages/core/menu/tests/machine.test.ts new file mode 100644 index 0000000..5b8c7d7 --- /dev/null +++ b/packages/core/menu/tests/machine.test.ts @@ -0,0 +1,557 @@ +// The agnostic core of the Menu — machine + connect, no DOM, no framework. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connector, machine, type Connector, type Machine } from '@dunky.dev/state-machine' +import { menuMachine, menuConnect } from '@dunky.dev/menu' +import type { + KeyboardPayload, + MenuApi, + MenuContext, + MenuIds, + MenuMachineEvent, + MenuOptions, + MenuStateName, + PointerPayload, +} from '@dunky.dev/menu' + +// The per-part ids the connect derives from a base id of `menu`. +const ids: MenuIds = { trigger: 'menu-trigger', content: 'menu-content' } +const itemId = (value: string): string => `menu-item-${value}` + +interface Harness { + service: Machine + connection: Connector +} + +const build = (options: MenuOptions = {}): Harness => { + const service = machine(menuMachine({ id: 'menu', ...options })) + const connection = connector(service, menuConnect, options) + service.start() + return { service, connection } +} + +const registerItems = ( + service: Harness['service'], + items: Array<{ value: string; label?: string; disabled?: boolean }>, +): void => { + for (const item of items) { + service.send({ + type: 'item.register', + item: { + value: item.value, + label: item.label ?? item.value, + disabled: item.disabled ?? false, + }, + }) + } +} + +// A registered anatomy shared by most navigation tests: b is disabled. +const openWithItems = (options: MenuOptions = {}): Harness => { + const harness = build({ defaultOpen: true, ...options }) + registerItems(harness.service, [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Beta', disabled: true }, + { value: 'c', label: 'Caramel' }, + { value: 'd', label: 'Cocoa' }, + ]) + return harness +} + +const keyboardPayload = (key: string): KeyboardPayload => ({ + key, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, +}) + +describe('menu machine — open/close', () => { + it('starts closed by default', () => { + const { service } = build() + expect(service.state).toBe('closed') + }) + + it('starts open when defaultOpen', () => { + const { service } = build({ defaultOpen: true }) + expect(service.state).toBe('open') + }) + + it('starts open when controlled open=true', () => { + const { service } = build({ open: true }) + expect(service.state).toBe('open') + }) + + it('toggle flips closed -> open -> closed', () => { + const { service } = build() + service.send({ type: 'toggle' }) + expect(service.state).toBe('open') + service.send({ type: 'toggle' }) + expect(service.state).toBe('closed') + }) + + it('escape, tab, and outside interaction each close', () => { + for (const type of ['escape', 'tab', 'interact.outside'] as const) { + const { service } = build({ defaultOpen: true }) + service.send({ type }) + expect(service.state).toBe('closed') + } + }) +}) + +describe('menu machine — controlled', () => { + it('reports dismissal intents without moving the machine', () => { + const onOpenChange = vi.fn() + const { service } = build({ open: true, onOpenChange }) + service.send({ type: 'escape' }) + expect(service.state).toBe('open') + expect(onOpenChange).toHaveBeenLastCalledWith(false) + }) + + it('moves only on controlled.sync, and the prop echo is not reported back', () => { + const onOpenChange = vi.fn() + const { service } = build({ open: true, onOpenChange }) + service.send({ type: 'controlled.sync', open: false }) + expect(service.state).toBe('closed') + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('holds a keyboard aim through the veto window until the prop opens', () => { + const { service } = build({ open: false }) + service.send({ type: 'open', highlight: 'first' }) + expect(service.state).toBe('closed') + + service.send({ type: 'controlled.sync', open: true }) + registerItems(service, [{ value: 'a' }]) + expect(service.context.highlightedValue).toBe('a') + }) +}) + +describe('menu machine — item registry', () => { + it('registers and unregisters items in order', () => { + const { service } = build({ defaultOpen: true }) + registerItems(service, [{ value: 'a' }, { value: 'b' }]) + expect(service.context.items.map(item => item.value)).toEqual(['a', 'b']) + + service.send({ type: 'item.unregister', value: 'a' }) + expect(service.context.items.map(item => item.value)).toEqual(['b']) + }) + + it('re-registering a value updates the item in place, keeping its order', () => { + const { service } = build({ defaultOpen: true }) + registerItems(service, [{ value: 'a' }, { value: 'b' }]) + service.send({ type: 'item.register', item: { value: 'a', label: 'Alpha', disabled: true } }) + expect(service.context.items.map(item => item.value)).toEqual(['a', 'b']) + expect(service.context.items[0]).toMatchObject({ label: 'Alpha', disabled: true }) + }) + + it('clears the highlight when the highlighted item unregisters', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + service.send({ type: 'item.unregister', value: 'a' }) + expect(service.context.highlightedValue).toBeNull() + }) + + it('clears the highlight when the highlighted item re-registers disabled', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + service.send({ type: 'item.register', item: { value: 'a', label: 'Alpha', disabled: true } }) + expect(service.context.highlightedValue).toBeNull() + }) + + it('drops the highlight when a pending aim has no enabled target left', () => { + const { service } = build() + service.send({ type: 'open', highlight: 'first' }) + registerItems(service, [{ value: 'a' }]) + expect(service.context.highlightedValue).toBe('a') + + service.send({ type: 'item.unregister', value: 'a' }) + expect(service.context.highlightedValue).toBeNull() + }) +}) + +describe('menu machine — highlight', () => { + it('highlights an enabled item and ignores a disabled one', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + expect(service.context.highlightedValue).toBe('a') + + service.send({ type: 'highlight.set', value: 'b' }) + expect(service.context.highlightedValue).toBe('a') + }) + + it('clears on highlight.set null (pointer leave)', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + service.send({ type: 'highlight.set', value: null }) + expect(service.context.highlightedValue).toBeNull() + }) + + it('moves next/previous over enabled items, skipping disabled, wrapping at the ends', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.move', to: 'next' }) // none -> first enabled + expect(service.context.highlightedValue).toBe('a') + service.send({ type: 'highlight.move', to: 'next' }) // skips disabled b + expect(service.context.highlightedValue).toBe('c') + service.send({ type: 'highlight.move', to: 'next' }) + expect(service.context.highlightedValue).toBe('d') + service.send({ type: 'highlight.move', to: 'next' }) // wraps + expect(service.context.highlightedValue).toBe('a') + service.send({ type: 'highlight.move', to: 'previous' }) // wraps back + expect(service.context.highlightedValue).toBe('d') + }) + + it('jumps to first/last enabled item', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.move', to: 'last' }) + expect(service.context.highlightedValue).toBe('d') + service.send({ type: 'highlight.move', to: 'first' }) + expect(service.context.highlightedValue).toBe('a') + }) + + it('moves are a no-op when no item is enabled', () => { + const { service } = build({ defaultOpen: true }) + registerItems(service, [{ value: 'a', disabled: true }]) + service.send({ type: 'highlight.move', to: 'next' }) + expect(service.context.highlightedValue).toBeNull() + }) + + it('clears the highlight on every close so the next open starts fresh', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + service.send({ type: 'escape' }) + expect(service.context.highlightedValue).toBeNull() + }) +}) + +describe('menu machine — pending highlight (keyboard open)', () => { + it('resolves "first" to the first enabled item as items register', () => { + const { service } = build() + service.send({ type: 'open', highlight: 'first' }) + expect(service.state).toBe('open') + expect(service.context.highlightedValue).toBeNull() // nothing registered yet + + registerItems(service, [{ value: 'a', disabled: true }, { value: 'b' }, { value: 'c' }]) + expect(service.context.highlightedValue).toBe('b') + }) + + it('resolves "last" against every registration, ending at the last enabled item', () => { + const { service } = build() + service.send({ type: 'open', highlight: 'last' }) + registerItems(service, [{ value: 'a' }, { value: 'b' }, { value: 'c', disabled: true }]) + expect(service.context.highlightedValue).toBe('b') + }) + + it('an explicit highlight move cancels the pending aim', () => { + const { service } = build() + service.send({ type: 'open', highlight: 'last' }) + registerItems(service, [{ value: 'a' }]) + service.send({ type: 'highlight.set', value: 'a' }) + + registerItems(service, [{ value: 'b' }]) // would re-resolve "last" if still pending + expect(service.context.highlightedValue).toBe('a') + }) + + it('a pointer open aims nothing', () => { + const { service } = build() + service.send({ type: 'toggle' }) + registerItems(service, [{ value: 'a' }]) + expect(service.context.highlightedValue).toBeNull() + }) +}) + +describe('menu machine — typeahead', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('jumps to the next enabled item whose label starts with the query', () => { + const { service } = openWithItems() + service.send({ type: 'typeahead', key: 'c' }) + expect(service.context.highlightedValue).toBe('c') + }) + + it('accumulates characters into one query', () => { + const { service } = openWithItems() + service.send({ type: 'typeahead', key: 'c' }) + service.send({ type: 'typeahead', key: 'o' }) + expect(service.context.highlightedValue).toBe('d') // "co" -> Cocoa + }) + + it('repeating the same character cycles through items starting with it', () => { + const { service } = openWithItems() + service.send({ type: 'typeahead', key: 'c' }) + expect(service.context.highlightedValue).toBe('c') + service.send({ type: 'typeahead', key: 'c' }) + expect(service.context.highlightedValue).toBe('d') + service.send({ type: 'typeahead', key: 'c' }) + expect(service.context.highlightedValue).toBe('c') // wraps + }) + + it('resets the query after a pause', () => { + const { service } = openWithItems() + service.send({ type: 'typeahead', key: 'a' }) + expect(service.context.highlightedValue).toBe('a') + + vi.advanceTimersByTime(1100) + service.send({ type: 'typeahead', key: 'c' }) // fresh query, not "ac" + expect(service.context.highlightedValue).toBe('c') + }) + + it('matches case-insensitively and skips disabled items', () => { + const { service } = openWithItems() + service.send({ type: 'typeahead', key: 'B' }) // Beta is disabled + expect(service.context.highlightedValue).toBeNull() + }) + + it('keeps the highlight when nothing matches', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + service.send({ type: 'typeahead', key: 'z' }) + expect(service.context.highlightedValue).toBe('a') + }) +}) + +describe('menu machine — activation', () => { + it('records the selection and closes on an enabled item', () => { + const { service } = openWithItems() + service.send({ type: 'item.activate', value: 'a' }) + expect(service.context.selection).toEqual({ value: 'a' }) + expect(service.state).toBe('closed') + }) + + it('activates the highlighted item when no value is given', () => { + const { service } = openWithItems() + service.send({ type: 'highlight.set', value: 'c' }) + service.send({ type: 'item.activate' }) + expect(service.context.selection).toEqual({ value: 'c' }) + expect(service.state).toBe('closed') + }) + + it('ignores a disabled item and an empty highlight', () => { + const { service } = openWithItems() + service.send({ type: 'item.activate', value: 'b' }) + expect(service.state).toBe('open') + + service.send({ type: 'item.activate' }) // nothing highlighted + expect(service.state).toBe('open') + expect(service.context.selection).toBeNull() + }) + + it('mints a fresh selection token per activation, even for the same value', () => { + const { service } = openWithItems() + service.send({ type: 'item.activate', value: 'a' }) + const first = service.context.selection + + service.send({ type: 'open' }) + service.send({ type: 'item.activate', value: 'a' }) + expect(service.context.selection).toEqual(first) + expect(service.context.selection).not.toBe(first) + }) +}) + +describe('menu connect — logical bindings', () => { + it('trigger carries the popup relationship and toggles', () => { + const { service, connection } = build() + const trigger = connection.snapshot.parts.trigger + expect(trigger.id).toBe(ids.trigger) + expect(trigger.hasPopup).toBe('menu') + expect(trigger.expanded).toBe(false) + expect(trigger.controls).toBeUndefined() // nothing to control while closed + + trigger.onPress?.() + expect(service.state).toBe('open') + + const openTrigger = connection.snapshot.parts.trigger + expect(openTrigger.expanded).toBe(true) + expect(openTrigger.controls).toBe(ids.content) + + openTrigger.onPress?.() + expect(service.state).toBe('closed') + }) + + it('trigger keyboard opens aim the highlight and suppress the default action', () => { + const aims: Array<[string, 'first' | 'last']> = [ + ['Enter', 'first'], + [' ', 'first'], + ['ArrowDown', 'first'], + ['ArrowUp', 'last'], + ] + for (const [key, aim] of aims) { + const { service, connection } = build() + const payload = keyboardPayload(key) + connection.snapshot.parts.trigger.onKeyDown?.(payload) + expect(service.state).toBe('open') + expect(service.context.pendingHighlight).toBe(aim) + expect(payload.defaultPrevented).toBe(true) + } + }) + + it('trigger Enter while open toggles closed instead of being swallowed', () => { + const { service, connection } = build({ defaultOpen: true }) + const payload = keyboardPayload('Enter') + connection.snapshot.parts.trigger.onKeyDown?.(payload) + expect(service.state).toBe('closed') + expect(payload.defaultPrevented).toBe(true) + }) + + it('content carries the menu role, identity, and focus surface', () => { + const { service, connection } = build({ defaultOpen: true }) + service.send({ type: 'part.presence', part: 'trigger', present: true }) + const content = connection.snapshot.parts.content + expect(content.role).toBe('menu') + expect(content.id).toBe(ids.content) + expect(content.labelledBy).toBe(ids.trigger) + expect(content.orientation).toBe('vertical') + expect(content.focusable).toBe(false) // the focus surface: tabIndex -1 + }) + + it('content omits labelledBy while no trigger is rendered', () => { + const { connection } = build({ defaultOpen: true }) + expect(connection.snapshot.parts.content.labelledBy).toBeUndefined() + }) + + it('content exposes the highlighted item through activeDescendant', () => { + const { service, connection } = openWithItems() + expect(connection.snapshot.parts.content.activeDescendant).toBeUndefined() + + service.send({ type: 'highlight.set', value: 'a' }) + expect(connection.snapshot.parts.content.activeDescendant).toBe(itemId('a')) + }) + + it('content keydown drives navigation, typeahead, and activation', () => { + const { service, connection } = openWithItems() + const content = connection.snapshot.parts.content + + content.onKeyDown?.(keyboardPayload('ArrowDown')) + expect(service.context.highlightedValue).toBe('a') + content.onKeyDown?.(keyboardPayload('End')) + expect(service.context.highlightedValue).toBe('d') + content.onKeyDown?.(keyboardPayload('Home')) + expect(service.context.highlightedValue).toBe('a') + content.onKeyDown?.(keyboardPayload('ArrowUp')) + expect(service.context.highlightedValue).toBe('d') + + content.onKeyDown?.(keyboardPayload('c')) + expect(service.context.highlightedValue).toBe('c') + + content.onKeyDown?.(keyboardPayload('Enter')) + expect(service.context.selection).toEqual({ value: 'c' }) + expect(service.state).toBe('closed') + }) + + it('content keydown Tab closes without suppressing the default focus move', () => { + const { service, connection } = openWithItems() + const payload = keyboardPayload('Tab') + connection.snapshot.parts.content.onKeyDown?.(payload) + expect(service.state).toBe('closed') + expect(payload.defaultPrevented).toBe(false) + }) + + it('item bindings carry role, identity, and the styling hooks', () => { + const { service, connection } = openWithItems() + service.send({ type: 'highlight.set', value: 'a' }) + + const highlighted = connection.snapshot.getItemBindings({ value: 'a' }) + expect(highlighted.role).toBe('menuitem') + expect(highlighted.id).toBe(itemId('a')) + expect(highlighted['data-highlighted']).toBe('') + expect(highlighted.disabled).toBeUndefined() + + const disabled = connection.snapshot.getItemBindings({ value: 'b', disabled: true }) + expect(disabled.disabled).toBe(true) + expect(disabled['data-disabled']).toBe('') + expect(disabled['data-highlighted']).toBeUndefined() + }) + + it('item press activates; pointer enter/leave move the highlight', () => { + const { service, connection } = openWithItems() + const item = connection.snapshot.getItemBindings({ value: 'a' }) + + item.onPointerEnter?.() + expect(service.context.highlightedValue).toBe('a') + item.onPointerLeave?.() + expect(service.context.highlightedValue).toBeNull() + + item.onPress?.() + expect(service.context.selection).toEqual({ value: 'a' }) + expect(service.state).toBe('closed') + }) + + it('group is labelled by its label only while the label is rendered', () => { + const { service, connection } = build({ defaultOpen: true }) + expect(connection.snapshot.getGroupBindings({ id: 'g1' }).role).toBe('group') + expect(connection.snapshot.getGroupBindings({ id: 'g1' }).labelledBy).toBeUndefined() + + service.send({ type: 'group.label.presence', group: 'g1', present: true }) + expect(connection.snapshot.getGroupBindings({ id: 'g1' }).labelledBy).toBe('g1-label') + expect(connection.snapshot.getGroupLabelBindings({ id: 'g1' }).id).toBe('g1-label') + + service.send({ type: 'group.label.presence', group: 'g1', present: false }) + expect(connection.snapshot.getGroupBindings({ id: 'g1' }).labelledBy).toBeUndefined() + }) + + it('separator carries the separator role', () => { + const { connection } = build() + expect(connection.snapshot.parts.separator.role).toBe('separator') + expect(connection.snapshot.parts.separator.orientation).toBe('horizontal') + }) + + it('parts expose data-state; setOpen drives both directions', () => { + const { connection } = build({ defaultOpen: true }) + expect(connection.snapshot.parts.trigger['data-state']).toBe('open') + expect(connection.snapshot.parts.content['data-state']).toBe('open') + + connection.snapshot.setOpen(false) + expect(connection.snapshot.parts.trigger['data-state']).toBe('closed') + connection.snapshot.setOpen(true) + expect(connection.snapshot.open).toBe(true) + }) + + it('interactOutside honors the consumer veto', () => { + const onInteractOutside = vi.fn((event?: PointerPayload) => event?.preventDefault?.()) + const { service, connection } = build({ defaultOpen: true, onInteractOutside }) + + const payload: PointerPayload = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + } + connection.snapshot.interactOutside(payload) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(service.state).toBe('open') // vetoed + + connection.snapshot.interactOutside() + expect(service.state).toBe('closed') + }) +}) + +describe('menu connect — reactions', () => { + it('fires onOpenChange on every flip, not on subscribe', () => { + const onOpenChange = vi.fn() + const { service } = build({ onOpenChange }) + expect(onOpenChange).not.toHaveBeenCalled() + + service.send({ type: 'toggle' }) + expect(onOpenChange).toHaveBeenLastCalledWith(true) + service.send({ type: 'escape' }) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + expect(onOpenChange).toHaveBeenCalledTimes(2) + }) + + it('the selection is observable before the close, per the mailbox contract', () => { + const order: string[] = [] + const { service } = openWithItems({ onOpenChange: open => order.push(`open:${open}`) }) + service + .select(() => service.context.selection) + .subscribe(selection => { + if (selection !== null) order.push(`select:${selection.value}`) + }) + + service.send({ type: 'item.activate', value: 'a' }) + expect(order).toEqual(['select:a', 'open:false']) + }) +}) diff --git a/packages/react/menu/README.md b/packages/react/menu/README.md new file mode 100644 index 0000000..c3c6697 --- /dev/null +++ b/packages/react/menu/README.md @@ -0,0 +1,47 @@ +# @dunky.dev/react-menu + +React binding for [`@dunky.dev/menu`](../../core/menu): a compound component — +`Menu` plus its parts — that drives the framework-free menu machine. The root +owns the machine; parts translate the core's logical bindings into DOM +attributes and handlers, and wire the DOM-only concerns (portal, focus, +outside-interaction detection, layer stack). + +Behavior contract: [`../../core/menu/SPEC.md`](../../core/menu/SPEC.md). +React-specific surface: [SPEC.md](./SPEC.md). + +## Install + +```sh +npm install @dunky.dev/react-menu +``` + +## Usage + +```tsx +import { Menu } from '@dunky.dev/react-menu' + +function BoardActions() { + return ( + + Actions + + + + Rename + + + Duplicate + + + + Danger zone + + Delete + + + + + + ) +} +``` diff --git a/packages/react/menu/SPEC.md b/packages/react/menu/SPEC.md new file mode 100644 index 0000000..3b90d7d --- /dev/null +++ b/packages/react/menu/SPEC.md @@ -0,0 +1,135 @@ +# SPEC / React / Menu + +The React implementation of the [core spec](../../core/menu/SPEC.md). + +## Docs + +🔗 [`dunky.dev/ui/components/menu`](https://dunky.dev/ui/components/menu). + +## Install + +```sh +npm install @dunky.dev/react-menu +``` + +## Usage + +```tsx +import { Menu } from '@dunky.dev/react-menu' +; + Actions + + + + Rename + + + Duplicate + + + + Danger zone + + Delete + + + + + +``` + +React-specific notes on top of the core contract: + +- **`Portal`** teleports the content to `document.body`, or to a `container` + you supply. Nothing is kept mounted while closed. +- **`Content`** renders a `
` that holds real DOM focus while + open (`tabIndex={-1}`); the highlighted item is exposed through + `aria-activedescendant`, per the core focus model. There is no positioning + engine — anchoring the content next to the trigger is the consumer's + concern. +- **`Item`** renders a `
`. Its typeahead label comes from + `textValue` when given, otherwise from the rendered text content. +- **`Group` / `GroupLabel`** wire `role="group"` and its `aria-labelledby` + automatically — the group id is minted internally and the reference follows + the rendered label. +- Everything ships headless — parts carry behavior, ARIA wiring, and the + styling hooks: `data-state` (`open` / `closed`) on Trigger and Content, + `data-highlighted` / `data-disabled` on items. + +## API + +### `Menu` + +The root: owns open/close state and the highlight, renders no DOM. Accepts the +core `MenuOptions`. + +| Prop | Type | Default | Description | +| ------------------- | ------------------------- | -------------- | ------------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state; the menu then moves only when this prop does. | +| `defaultOpen` | `boolean` | `false` | Initial open state for the uncontrolled menu. | +| `onOpenChange` | `(open: boolean) => void` | — | Fired with every open/close intent. Controlled, ignoring it is the veto. | +| `onEscapeKeyDown` | `(event) => void` | — | Fired before an Escape dismissal; `preventDefault()` vetoes. | +| `onInteractOutside` | `(event?) => void` | — | Fired before an outside-interaction dismissal; `preventDefault()` vetoes. | +| `id` | `string` | auto (`useId`) | Base id for the parts; per-part ids are derived from it. | +| `children` | `ReactNode` | — | The menu's parts. | + +### `Menu.Trigger` + +The button that opens the menu; focus returns here on close. + +| Prop | Type | Default | Description | +| ---------- | -------------------------- | ------- | ------------------------------------- | +| `...props` | `ComponentProps<'button'>` | — | Forwarded to the rendered ` + + , + ) + fireEvent.focusIn(screen.getByTestId('outside')) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('stays open when onInteractOutside prevents default', () => { + const onInteractOutside = vi.fn(event => event?.preventDefault()) + render() + fireEvent.pointerDown(document.body) + expect(onInteractOutside).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menu')).not.toBeNull() + }) + + it('a press on the trigger is not an outside interaction — it toggles instead', () => { + render() + fireEvent.pointerDown(trigger()) + expect(screen.queryByRole('menu')).not.toBeNull() + + act(() => trigger().click()) + expect(screen.queryByRole('menu')).toBeNull() + }) + }) + + describe('controlled open', () => { + it('follows the open prop in both directions', () => { + const { rerender } = render() + expect(screen.queryByRole('menu')).toBeNull() + + rerender() + expect(screen.queryByRole('menu')).not.toBeNull() + + rerender() + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('reports a dismissal intent but stays open until the prop closes it', () => { + const onOpenChange = vi.fn() + render() + act(pressEscape) + expect(onOpenChange).toHaveBeenLastCalledWith(false) + // The consumer didn't update `open` — that's the veto. + expect(screen.queryByRole('menu')).not.toBeNull() + }) + + it('reports a trigger press without opening until the prop does', () => { + const onOpenChange = vi.fn() + render() + openMenu() + expect(onOpenChange).toHaveBeenLastCalledWith(true) + expect(screen.queryByRole('menu')).toBeNull() + }) + }) + + describe('aria wiring', () => { + it('trigger exposes the popup relationship', () => { + render() + expect(trigger().getAttribute('aria-haspopup')).toBe('menu') + expect(trigger().getAttribute('aria-expanded')).toBe('false') + expect(trigger().hasAttribute('aria-controls')).toBe(false) + + openMenu() + expect(trigger().getAttribute('aria-expanded')).toBe('true') + expect(trigger().getAttribute('aria-controls')).toBe(screen.getByRole('menu').id) + }) + + it('content is a vertical menu labelled by the trigger, focusable in script only', () => { + render() + const menu = screen.getByRole('menu') + expect(menu.getAttribute('aria-labelledby')).toBe(trigger().id) + expect(menu.getAttribute('aria-orientation')).toBe('vertical') + expect(menu.tabIndex).toBe(-1) + }) + + it('items are menuitems; a disabled one is perceivable through aria-disabled', () => { + render() + expect(screen.getAllByRole('menuitem')).toHaveLength(5) + + const disabled = screen.getByText('Duplicate') + expect(disabled.getAttribute('aria-disabled')).toBe('true') + expect(disabled.hasAttribute('data-disabled')).toBe(true) + expect(screen.getByText('Rename').hasAttribute('aria-disabled')).toBe(false) + }) + + it('the group is labelled by its rendered label; the reference follows removal', () => { + const WithLabel = ({ labelled }: { labelled: boolean }) => ( + + + + + {labelled && Danger} + Delete + + + + + ) + const { rerender } = render() + const group = screen.getByTestId('group') + expect(group.getAttribute('role')).toBe('group') + expect(group.getAttribute('aria-labelledby')).toBe(screen.getByText('Danger').id) + + rerender() + expect(group.hasAttribute('aria-labelledby')).toBe(false) + }) + + it('the separator carries the separator role', () => { + render() + expect(screen.getByTestId('separator').getAttribute('role')).toBe('separator') + }) + + it('trigger and content expose data-state for styling', () => { + render() + expect(trigger().getAttribute('data-state')).toBe('closed') + + openMenu() + expect(trigger().getAttribute('data-state')).toBe('open') + expect(content().getAttribute('data-state')).toBe('open') + }) + }) + + describe('strict mode', () => { + it('item registration and selection delivery stay idempotent', () => { + const onRename = vi.fn() + render( + + + , + ) + // A doubled registry would break the c -> c cycle (Copy, Cut, Copy...). + fireEvent.keyDown(content(), { key: 'c' }) + fireEvent.keyDown(content(), { key: 'c' }) + expect(highlightOf()).toBe(screen.getByText('Cut').id) + + fireEvent.keyDown(content(), { key: 'Enter' }) + expect(onRename).not.toHaveBeenCalled() + + act(() => trigger().click()) + act(() => screen.getByText('Rename').click()) + expect(onRename).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b487891..013c29d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,15 @@ importers: specifier: ^0.1.0 version: 0.1.0 + packages/core/menu: + dependencies: + '@dunky.dev/state-machine': + specifier: ^0.1.0 + version: 0.1.0 + '@dunky.dev/state-machine-bindings': + specifier: ^0.1.0 + version: 0.1.0 + packages/dom/utils/focus-trap: {} packages/dom/utils/interact-outside: {} @@ -212,6 +221,40 @@ importers: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + packages/react/menu: + dependencies: + '@dunky.dev/dom-layer-stack': + specifier: workspace:^ + version: link:../../dom/utils/layer-stack + '@dunky.dev/menu': + specifier: workspace:^ + version: link:../../core/menu + '@dunky.dev/react-state-machine': + specifier: ^0.1.0 + version: 0.1.0(react@19.2.7) + '@dunky.dev/react-use-interact-outside': + specifier: workspace:^ + version: link:../hooks/use-interact-outside + '@dunky.dev/react-use-layer-stack': + specifier: workspace:^ + version: link:../hooks/use-layer-stack + devDependencies: + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.2.15 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + packages: '@adobe/css-tools@4.5.0': diff --git a/tsconfig.json b/tsconfig.json index c3956bf..75bfdf2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "skipLibCheck": true, "strict": true, "paths": { + "@dunky.dev/menu": ["./packages/core/menu/src"], + "@dunky.dev/react-menu": ["./packages/react/menu/src"], "@dunky.dev/dialog": ["./packages/core/dialog/src"], "@dunky.dev/react-dialog": ["./packages/react/dialog/src"], "@dunky.dev/dom-focus-trap": ["./packages/dom/utils/focus-trap/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 1e327f2..f25b7ff 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ workspace: [ 'packages/core', 'packages/core/dialog', + 'packages/core/menu', 'packages/dom/utils/focus-trap', 'packages/dom/utils/interact-outside', 'packages/dom/utils/layer-stack', @@ -22,6 +23,7 @@ export default defineConfig({ 'packages/react/hooks/use-interact-outside', 'packages/react/hooks/use-layer-stack', 'packages/react/hooks/use-scroll-lock', + 'packages/react/menu', ], entry: ['src/index.ts'], format: ['esm'], From 5f2e8fd2da4b5d6e08bb40e8712ec4ad4b19f4be Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Sun, 19 Jul 2026 01:21:40 +0200 Subject: [PATCH 2/2] docs(spec): rename the Design section to Internals Co-Authored-By: Claude Fable 5 --- packages/core/menu/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/menu/SPEC.md b/packages/core/menu/SPEC.md index 3574605..f507143 100644 --- a/packages/core/menu/SPEC.md +++ b/packages/core/menu/SPEC.md @@ -151,7 +151,7 @@ Per APG Menu Button: `data-state` on trigger/content and `data-highlighted` / `data-disabled` on items are the styling hooks. -## Design +## Internals - **`aria-activedescendant` over roving DOM focus.** The highlight is machine state: one context value that every substrate renders as one attribute plus