Skip to content

feat(react): add the Menu primitive#23

Open
ivanbanov wants to merge 2 commits into
feat/floatingfrom
feat/menu
Open

feat(react): add the Menu primitive#23
ivanbanov wants to merge 2 commits into
feat/floatingfrom
feat/menu

Conversation

@ivanbanov

Copy link
Copy Markdown
Member

Summary

Adds the Menu primitive as two packages:

  • @dunky.dev/menu (packages/core/menu) — the framework-free state machine: open/close (controlled and uncontrolled), item registry, single-value highlight, typeahead, and layer-stack-aware dismissal.
  • @dunky.dev/react-menu (packages/react/menu) — the thin React binding: compound parts, portal rendering, focus management, and data-state styling hooks. No positioning engine; anchoring is the consumer's concern.

A menu is a list of actions opened from a button: the user opens it, picks one action, and the menu goes away. It is non-modal and coexists with the page; every item is a command.

Note

This PR is stacked on the feat/floating extraction PR (shared overlay utilities) and targets that branch; it will retarget to main once feat/floating merges.

Anatomy

<Menu>                 - root; owns open/close state and the highlight
  |_ <Menu.Trigger>    - the button that opens the menu; focus returns here on close
  |_ <Menu.Portal>     - renders the content into a portal target
     |_ <Menu.Content> - the menu surface; holds DOM focus while open
        |_ <Menu.Item>       - one action; activating reports the selection and closes
        |_ <Menu.Group>      - groups related items under a name
        |  |_ <Menu.GroupLabel> - names its group
        |_ <Menu.Separator>  - divides sets of items

Behavior

  • WAI-ARIA menu-button pattern: trigger is a button with aria-haspopup="menu" + aria-expanded; content is role="menu" labelled by the trigger; items are role="menuitem"; groups/role="group" labelled by their rendered GroupLabel; separators are role="separator". aria-controls only references the content while it is rendered.
  • Keyboard, on the trigger: Enter/Space/ArrowDown open with the first enabled item highlighted; ArrowUp opens with the last. A pointer press opens with nothing highlighted.
  • Keyboard, while open: ArrowDown/ArrowUp move the highlight with wrap, Home/End jump to the ends, printable characters run typeahead (repeating a character cycles matches), Enter/Space activate the highlighted item, Escape closes and returns focus to the trigger, Tab closes and lets focus continue.
  • Highlight via aria-activedescendant: DOM focus stays on the content; the highlight is machine state, not roving focus. Disabled items are perceivable (aria-disabled) but never highlighted or activated.
  • Dismissal: Escape, Tab, and outside press/focus close the menu; Escape and outside interaction are veto-able through their callbacks, and every intent reports through onOpenChange. A controlled menu never self-transitions — the open prop drives it.
  • Layering: registers as a non-modal layer on the shared overlay stack — Escape/outside dismissal belong to the topmost layer only.

Test plan

  • pnpm test:ci — 13 files, 182 tests passing
  • pnpm typecheck
  • pnpm lint
  • pnpm format:check
  • pnpm build

🤖 Generated with Claude Code

ivanbanov and others added 2 commits July 19, 2026 00:29
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ivanbanov ivanbanov changed the title feat: add the Menu primitive feat(react): add the Menu primitive Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant