`. |
+
+### `Menu.Separator`
+
+Divides sets of items.
+
+| Prop | Type | Default | Description |
+| ---------- | ----------------------- | ------- | ---------------------------------- |
+| `...props` | `ComponentProps<'div'>` | — | Forwarded to the rendered `
`. |
diff --git a/packages/react/menu/package.json b/packages/react/menu/package.json
new file mode 100644
index 0000000..c878ec8
--- /dev/null
+++ b/packages/react/menu/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@dunky.dev/react-menu",
+ "version": "0.0.0",
+ "description": "React binding for @dunky.dev/menu.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/dunky-dev/ui.git",
+ "directory": "packages/react/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/dom-layer-stack": "workspace:^",
+ "@dunky.dev/menu": "workspace:^",
+ "@dunky.dev/react-state-machine": "^0.1.0",
+ "@dunky.dev/react-use-interact-outside": "workspace:^",
+ "@dunky.dev/react-use-layer-stack": "workspace:^"
+ },
+ "devDependencies": {
+ "@testing-library/react": "^16.1.0",
+ "@types/react": "^19.2.15",
+ "@types/react-dom": "^19.2.3",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+}
diff --git a/packages/react/menu/src/context.ts b/packages/react/menu/src/context.ts
new file mode 100644
index 0000000..af8bfd5
--- /dev/null
+++ b/packages/react/menu/src/context.ts
@@ -0,0 +1,36 @@
+import { createContext, useContext, type Context, type RefObject } from 'react'
+import type { MenuApi, MenuMachine } from '@dunky.dev/menu'
+
+export interface MenuContextValue {
+ api: MenuApi
+ machine: MenuMachine
+ /** The trigger element, excused from outside-press detection so a press on
+ * it toggles instead of dismissing and immediately reopening. */
+ triggerRef: RefObject
+}
+
+export const MenuContext: Context = createContext<
+ MenuContextValue | undefined
+>(undefined)
+
+export const useMenuContext = (): MenuContextValue => {
+ const context = useContext(MenuContext)
+ if (context === undefined) {
+ throw new Error('Menu parts must be rendered within a root')
+ }
+ return context
+}
+
+// The group id minted by ; its GroupLabel reads it to wire the
+// group's aria-labelledby through the core connect.
+export const MenuGroupContext: Context = createContext(
+ undefined,
+)
+
+export const useMenuGroupContext = (): string => {
+ const groupId = useContext(MenuGroupContext)
+ if (groupId === undefined) {
+ throw new Error('Menu.GroupLabel must be rendered within a ')
+ }
+ return groupId
+}
diff --git a/packages/react/menu/src/effects.ts b/packages/react/menu/src/effects.ts
new file mode 100644
index 0000000..5a62d3d
--- /dev/null
+++ b/packages/react/menu/src/effects.ts
@@ -0,0 +1,39 @@
+import { isTopmostLayer } from '@dunky.dev/dom-layer-stack'
+import type { ComponentEffect } from '@dunky.dev/react-state-machine'
+import type { MenuMachine, MenuOptions } from '@dunky.dev/menu'
+
+// Substrate effects: prop-driven or document-level work the machine can't own.
+// useMachine runs one useEffect per entry, keyed on the listed prop deps.
+type MenuEffect = ComponentEffect
+
+// Controlled open: the machine never moves on its own when controlled — this
+// echo of the `open` prop is the only thing that transitions it.
+const syncControlledOpen: MenuEffect = [
+ (machine, props) => {
+ if (props.open === undefined) return
+ if (props.open !== machine.matches('open')) {
+ machine.send({ type: 'controlled.sync', open: props.open })
+ }
+ },
+ ['open'],
+]
+
+// Escape is a document-level concern, not a part's — it must work wherever
+// focus is.
+const trackEscape: MenuEffect = [
+ (machine, props) => {
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape' || !machine.matches('open')) return
+ // Only the topmost layer answers Escape — a nested stack closes one
+ // layer at a time.
+ if (!isTopmostLayer(machine.context.id)) return
+ props.onEscapeKeyDown?.(event)
+ if (!event.defaultPrevented) machine.send({ type: 'escape' })
+ }
+ document.addEventListener('keydown', onKeyDown, true)
+ return () => document.removeEventListener('keydown', onKeyDown, true)
+ },
+ ['onEscapeKeyDown'],
+]
+
+export const menuEffects: MenuEffect[] = [syncControlledOpen, trackEscape]
diff --git a/packages/react/menu/src/index.ts b/packages/react/menu/src/index.ts
new file mode 100644
index 0000000..9adee48
--- /dev/null
+++ b/packages/react/menu/src/index.ts
@@ -0,0 +1,12 @@
+export {
+ Menu,
+ type MenuProps,
+ type MenuTriggerProps,
+ type MenuPortalProps,
+ type MenuContentProps,
+ type MenuItemProps,
+ type MenuGroupProps,
+ type MenuGroupLabelProps,
+ type MenuSeparatorProps,
+} from './menu'
+export type { KeyboardPayload, MenuCallbacks, MenuOptions, PointerPayload } from '@dunky.dev/menu'
diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx
new file mode 100644
index 0000000..08cf442
--- /dev/null
+++ b/packages/react/menu/src/menu.tsx
@@ -0,0 +1,297 @@
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useRef,
+ type ComponentPropsWithoutRef,
+ type ForwardRefExoticComponent,
+ type ReactNode,
+ type RefAttributes,
+} from 'react'
+import { createPortal } from 'react-dom'
+import { isTopmostLayer, layerContainsTarget, registerLayer } from '@dunky.dev/dom-layer-stack'
+import { useInteractOutside } from '@dunky.dev/react-use-interact-outside'
+import { LayerDepthContext, useLayerDepth } from '@dunky.dev/react-use-layer-stack'
+import type { MenuOptions, PointerPayload } from '@dunky.dev/menu'
+
+import { mergeProps, normalize } from '@dunky.dev/react-state-machine'
+import { MenuContext, MenuGroupContext, useMenuContext, useMenuGroupContext } from './context'
+import { useMenu } from './use-menu'
+
+// Explicit so the exports satisfy --isolatedDeclarations (a bare forwardRef
+// call gives the variable no annotatable type).
+type PartComponent = ForwardRefExoticComponent>
+
+// =============================================================================
+// — root, owns the machine and renders no DOM
+// =============================================================================
+
+export interface MenuProps extends MenuOptions {
+ children?: ReactNode
+}
+
+export const Menu: ((props: MenuProps) => ReactNode) & Parts = ({ children, ...options }) => {
+ const depth = useLayerDepth() + 1
+ const { api, machine } = useMenu(options)
+ const triggerRef = useRef(null)
+ return (
+
+ {children}
+
+ )
+}
+
+// =============================================================================
+// — toggles the menu; focus returns here on close
+// =============================================================================
+
+export interface MenuTriggerProps extends ComponentPropsWithoutRef<'button'> {}
+
+export const Trigger: PartComponent = forwardRef<
+ HTMLButtonElement,
+ MenuTriggerProps
+>((props, forwardedRef) => {
+ const { api, machine, triggerRef } = useMenuContext()
+ useImperativeHandle(forwardedRef, () => triggerRef.current as HTMLButtonElement)
+
+ useEffect(() => {
+ machine.send({ type: 'part.presence', part: 'trigger', present: true })
+ return () => machine.send({ type: 'part.presence', part: 'trigger', present: false })
+ }, [machine])
+
+ const merged = mergeProps({ type: 'button' as const, ...props }, normalize(api.parts.trigger))
+ return
+})
+
+// =============================================================================
+// — teleports the content out of the tree while open
+// =============================================================================
+
+export interface MenuPortalProps {
+ children?: ReactNode
+ /** The element to portal into. @default document.body */
+ container?: HTMLElement | null
+}
+
+export const Portal = ({ children, container }: MenuPortalProps): ReactNode => {
+ const { api } = useMenuContext()
+ if (!api.open || typeof document === 'undefined') return null
+ return createPortal(children, container ?? document.body)
+}
+
+// =============================================================================
+// — the menu surface: holds DOM focus while open, restores it
+// on close, dismisses on outside interaction
+// =============================================================================
+
+export interface MenuContentProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const Content: PartComponent = forwardRef<
+ HTMLDivElement,
+ MenuContentProps
+>((props, forwardedRef) => {
+ const { api, machine, triggerRef } = useMenuContext()
+ const depth = useLayerDepth()
+ const contentRef = useRef(null)
+ useImperativeHandle(forwardedRef, () => contentRef.current as HTMLDivElement)
+
+ // Content only mounts while open, so mount/unmount ARE the open/close edges.
+ // One effect keeps the ordering right both ways: the stack joins before focus
+ // moves in, and on close it must release the layers beneath (un-inert them)
+ // before focus can move back out to one of them.
+ useEffect(() => {
+ const content = contentRef.current
+ if (content === null) return
+
+ const previous = document.activeElement
+ const unregister = registerLayer({
+ id: machine.context.id,
+ depth,
+ element: content,
+ // Never modal: the menu coexists with the page (see the core SPEC).
+ modal: false,
+ })
+
+ // preventScroll everywhere: the portaled content sits wherever the
+ // consumer positions it — moving focus must not scroll it into view.
+ content.focus({ preventScroll: true })
+
+ return () => {
+ unregister()
+ if (previous instanceof HTMLElement) previous.focus({ preventScroll: true })
+ }
+ }, [machine, depth])
+
+ useInteractOutside(contentRef, {
+ onInteractOutside: event => {
+ // Only the topmost layer of a stack answers an outside interaction.
+ if (!isTopmostLayer(machine.context.id)) return
+ api.interactOutside(event as unknown as PointerPayload)
+ },
+ // A nested layer is not outside; neither is the trigger — its own press
+ // toggles, and dismissing first would reopen instead of closing.
+ ignore: target =>
+ layerContainsTarget(machine.context.id, target) ||
+ triggerRef.current?.contains(target) === true,
+ })
+
+ const merged = mergeProps(props as Record, normalize(api.parts.content))
+ return
+})
+
+// =============================================================================
+// — one action: activating it reports the selection, then closes
+// =============================================================================
+
+export interface MenuItemProps extends ComponentPropsWithoutRef<'div'> {
+ /** Unique, id-safe identity of the item within the menu. */
+ value: string
+ /** Perceivable but never highlighted or activated. @default false */
+ disabled?: boolean
+ /** The typeahead label, for items whose content isn't plain text — or whose
+ * text changes at runtime (the fallback is read from the DOM only when
+ * `value`/`textValue`/`disabled` change, not on every render).
+ * @default the rendered text content */
+ textValue?: string
+ /** Fired when the item is activated; the menu then closes. */
+ onSelect?: () => void
+}
+
+export const Item: PartComponent = forwardRef<
+ HTMLDivElement,
+ MenuItemProps
+>(({ value, disabled = false, textValue, onSelect, ...props }, forwardedRef) => {
+ const { api, machine } = useMenuContext()
+ const itemRef = useRef(null)
+ useImperativeHandle(forwardedRef, () => itemRef.current as HTMLDivElement)
+
+ // Items register as data — value, typeahead label, disabled — so the core
+ // navigates what is actually rendered. The label falls back to the rendered
+ // text, read once the element exists. Registration is split in two: the
+ // mount effect owns register/unregister (registry order = mount order), and
+ // the update effect re-registers on a prop change WITHOUT unregistering
+ // first — the core's in-place update — so a `disabled`/`textValue` flip
+ // never moves the item to the end of the navigation order.
+ const textValueRef = useRef(textValue)
+ textValueRef.current = textValue
+ const disabledRef = useRef(disabled)
+ disabledRef.current = disabled
+ const valueRef = useRef(value)
+ valueRef.current = value
+ useEffect(() => {
+ const label = textValueRef.current ?? itemRef.current?.textContent ?? ''
+ machine.send({ type: 'item.register', item: { value, label, disabled: disabledRef.current } })
+ return () => machine.send({ type: 'item.unregister', value })
+ }, [machine, value])
+ useEffect(() => {
+ const label = textValue ?? itemRef.current?.textContent ?? ''
+ machine.send({ type: 'item.register', item: { value: valueRef.current, label, disabled } })
+ }, [machine, textValue, disabled])
+
+ // The selection mailbox: the machine records the activation as a fresh
+ // token; this item delivers it to its own callback. Reading through refs
+ // keeps one subscription alive across re-renders — the token fires before
+ // the close unmounts the item (see the core SPEC: Design).
+ const onSelectRef = useRef(onSelect)
+ onSelectRef.current = onSelect
+ useEffect(
+ () =>
+ machine
+ .select(() => machine.context.selection)
+ .subscribe(selection => {
+ if (selection !== null && selection.value === valueRef.current) onSelectRef.current?.()
+ }),
+ [machine],
+ )
+
+ const merged = mergeProps(
+ props as Record,
+ normalize(api.getItemBindings({ value, disabled })),
+ )
+ return
+})
+
+// =============================================================================
+// — groups related items under a name
+// =============================================================================
+
+export interface MenuGroupProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const Group: PartComponent = forwardRef<
+ HTMLDivElement,
+ MenuGroupProps
+>((props, forwardedRef) => {
+ const { api } = useMenuContext()
+ const groupId = useId()
+ const merged = mergeProps(
+ props as Record,
+ normalize(api.getGroupBindings({ id: groupId })),
+ )
+ return (
+
+
+
+ )
+})
+
+// =============================================================================
+// — names its enclosing group
+// =============================================================================
+
+export interface MenuGroupLabelProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const GroupLabel: PartComponent = forwardRef<
+ HTMLDivElement,
+ MenuGroupLabelProps
+>((props, forwardedRef) => {
+ const { api, machine } = useMenuContext()
+ const groupId = useMenuGroupContext()
+
+ useEffect(() => {
+ machine.send({ type: 'group.label.presence', group: groupId, present: true })
+ return () => machine.send({ type: 'group.label.presence', group: groupId, present: false })
+ }, [machine, groupId])
+
+ const merged = mergeProps(
+ props as Record,
+ normalize(api.getGroupLabelBindings({ id: groupId })),
+ )
+ return
+})
+
+// =============================================================================
+// — divides sets of items
+// =============================================================================
+
+export interface MenuSeparatorProps extends ComponentPropsWithoutRef<'div'> {}
+
+export const Separator: PartComponent = forwardRef<
+ HTMLDivElement,
+ MenuSeparatorProps
+>((props, forwardedRef) => {
+ const { api } = useMenuContext()
+ const merged = mergeProps(props as Record, normalize(api.parts.separator))
+ return
+})
+
+// Parts
+// -----------------------------------------------------------------------------
+
+export interface Parts {
+ Trigger: typeof Trigger
+ Portal: typeof Portal
+ Content: typeof Content
+ Item: typeof Item
+ Group: typeof Group
+ GroupLabel: typeof GroupLabel
+ Separator: typeof Separator
+}
+
+Menu.Trigger = Trigger
+Menu.Portal = Portal
+Menu.Content = Content
+Menu.Item = Item
+Menu.Group = Group
+Menu.GroupLabel = GroupLabel
+Menu.Separator = Separator
diff --git a/packages/react/menu/src/use-menu.ts b/packages/react/menu/src/use-menu.ts
new file mode 100644
index 0000000..a55530f
--- /dev/null
+++ b/packages/react/menu/src/use-menu.ts
@@ -0,0 +1,17 @@
+import { useId } from 'react'
+import { useMachine } from '@dunky.dev/react-state-machine'
+import { menuMachine, menuConnect } from '@dunky.dev/menu'
+import type { MenuOptions } from '@dunky.dev/menu'
+
+import type { MenuContextValue } from './context'
+import { menuEffects } from './effects'
+
+export function useMenu(options: MenuOptions): Pick {
+ const id = useId()
+ // `?? id` (not spread order): an explicit `id={undefined}` must not knock out
+ // the generated fallback — ids also key the layer stack, so they must exist.
+ return useMachine(menuMachine, menuConnect, menuEffects, {
+ ...options,
+ id: options.id ?? id,
+ })
+}
diff --git a/packages/react/menu/stories/menu.stories.tsx b/packages/react/menu/stories/menu.stories.tsx
new file mode 100644
index 0000000..ea3e18c
--- /dev/null
+++ b/packages/react/menu/stories/menu.stories.tsx
@@ -0,0 +1,194 @@
+import { useState, type CSSProperties, type ReactNode } from 'react'
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { Menu, type MenuProps } from '@dunky.dev/react-menu'
+
+const meta: Meta = {
+ title: 'Primitives/Menu',
+ component: Menu,
+}
+
+export default meta
+type StoryType = StoryObj
+
+// The primitive ships headless — the story is the consumer, so it brings the
+// styles. `data-state` on trigger/content and `data-highlighted` /
+// `data-disabled` on items are the real styling hooks; the highlight has to be
+// a selector-based style because it is an attribute, not :hover.
+const stylesheet = `
+ .menu-content {
+ min-width: 180px;
+ padding: 4px;
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
+ outline: none;
+ }
+ .menu-item {
+ padding: 6px 10px;
+ border-radius: 6px;
+ cursor: default;
+ user-select: none;
+ }
+ .menu-item[data-highlighted] {
+ background: #4666ff;
+ color: white;
+ }
+ .menu-item[data-disabled] {
+ color: #aaa;
+ }
+`
+const triggerStyle: CSSProperties = {
+ padding: '8px 14px',
+ border: '1px solid #ccc',
+ borderRadius: 8,
+ background: 'white',
+ cursor: 'pointer',
+}
+const groupLabelStyle: CSSProperties = {
+ padding: '6px 10px',
+ fontSize: 11,
+ textTransform: 'uppercase',
+ letterSpacing: '0.04em',
+ color: '#888',
+}
+const separatorStyle: CSSProperties = {
+ height: 1,
+ margin: '4px 0',
+ background: '#eee',
+}
+
+// There is no positioning engine in v0 — anchoring is the consumer's concern.
+// The story anchors the content by portaling it into an absolutely-positioned
+// box right under the trigger. The anchor lives in state so the portal reads a
+// real element on the second render instead of null.
+const Anchored = ({
+ menu,
+ children,
+}: {
+ menu: (anchor: HTMLElement) => ReactNode
+ children: ReactNode
+}) => {
+ const [anchor, setAnchor] = useState(null)
+ return (
+
+
+ {children}
+
+ {anchor && menu(anchor)}
+
+ )
+}
+
+const StandardMenu = (props: MenuProps) => (
+
+ (
+
+
+ console.log('rename')}>
+ Rename
+
+ console.log('duplicate')}
+ >
+ Duplicate
+
+
+ Archive (soon)
+
+
+
+ Danger zone
+ console.log('delete')}
+ >
+ Delete
+
+
+
+
+ )}
+ >
+ Board actions
+
+
+)
+
+export const standard: StoryType = {
+ render: () => ,
+}
+
+export const openByDefault: StoryType = {
+ render: () => ,
+}
+
+const TypeaheadMenu = () => (
+
+ (
+
+
+ {['Amsterdam', 'Berlin', 'Barcelona', 'Bergamo', 'Lisbon', 'London', 'Ljubljana'].map(
+ city => (
+ console.log(city)}
+ >
+ {city}
+
+ ),
+ )}
+
+
+ )}
+ >
+
+ Pick a city (open, then type "b" repeatedly)
+
+
+
+)
+
+export const typeahead: StoryType = {
+ render: () => ,
+}
+
+// Controlled: the consumer owns the state; every intent — trigger press,
+// Escape, Tab, outside press, item activation — reports through onOpenChange.
+const ControlledMenu = () => {
+ const [open, setOpen] = useState(false)
+ return (
+
+
+ (
+
+
+ console.log('one')}>
+ Action one
+
+ console.log('two')}>
+ Action two
+
+
+
+ )}
+ >
+ Controlled menu
+
+
+
open: {String(open)}
+
+ )
+}
+
+export const controlled: StoryType = {
+ render: () => ,
+}
diff --git a/packages/react/menu/tests/menu.test.tsx b/packages/react/menu/tests/menu.test.tsx
new file mode 100644
index 0000000..3387139
--- /dev/null
+++ b/packages/react/menu/tests/menu.test.tsx
@@ -0,0 +1,392 @@
+// @vitest-environment jsdom
+// The React edge of the Menu — behavior only; the machine's own contract is
+// covered in @dunky.dev/menu's tests.
+import { StrictMode } from 'react'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Menu, type MenuProps } from '@dunky.dev/react-menu'
+
+// Two enabled c-items (Copy, Cut) exercise typeahead cycling; Duplicate is the
+// disabled item every navigation must skip.
+const DefaultMenu = ({ onRename, ...props }: MenuProps & { onRename?: () => void }) => (
+
+ Actions
+
+
+
+ Rename
+
+
+ Duplicate
+
+ Copy
+ Cut
+
+
+ Danger
+ Delete
+
+
+
+
+)
+
+const trigger = (): HTMLElement => screen.getByText('Actions')
+const content = (): HTMLElement => screen.getByTestId('content')
+const openMenu = (): void => {
+ act(() => trigger().click())
+}
+const pressEscape = (): void => {
+ fireEvent.keyDown(document.body, { key: 'Escape' })
+}
+const highlightOf = (): string | null => content().getAttribute('aria-activedescendant')
+
+// RTL auto-cleanup needs vitest globals; this repo runs with globals: false.
+afterEach(cleanup)
+
+describe('Menu', () => {
+ describe('open / close', () => {
+ it('opens on trigger press and closes back on a second press', () => {
+ render( )
+ expect(screen.queryByRole('menu')).toBeNull()
+
+ openMenu()
+ expect(screen.queryByRole('menu')).not.toBeNull()
+
+ openMenu()
+ expect(screen.queryByRole('menu')).toBeNull()
+ })
+
+ it('renders open when defaultOpen', () => {
+ render( )
+ expect(screen.queryByRole('menu')).not.toBeNull()
+ })
+
+ it('fires onOpenChange with the new value on open and close', () => {
+ const onOpenChange = vi.fn()
+ render( )
+
+ openMenu()
+ expect(onOpenChange).toHaveBeenLastCalledWith(true)
+
+ act(pressEscape)
+ expect(onOpenChange).toHaveBeenLastCalledWith(false)
+ })
+ })
+
+ describe('keyboard', () => {
+ it('opens on trigger Enter with the first enabled item highlighted, focusing the content', () => {
+ render( )
+ fireEvent.keyDown(trigger(), { key: 'Enter' })
+
+ expect(document.activeElement).toBe(content())
+ expect(highlightOf()).toBe(screen.getByText('Rename').id)
+ expect(screen.getByText('Rename').hasAttribute('data-highlighted')).toBe(true)
+ })
+
+ it('opens on trigger ArrowUp with the last enabled item highlighted', () => {
+ render( )
+ fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
+ expect(highlightOf()).toBe(screen.getByText('Delete').id)
+ })
+
+ it('moves the highlight with the arrows, skipping disabled items and wrapping', () => {
+ render( )
+ fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
+ expect(highlightOf()).toBe(screen.getByText('Rename').id)
+
+ fireEvent.keyDown(content(), { key: 'ArrowDown' }) // skips disabled Duplicate
+ expect(highlightOf()).toBe(screen.getByText('Copy').id)
+
+ fireEvent.keyDown(content(), { key: 'ArrowUp' })
+ fireEvent.keyDown(content(), { key: 'ArrowUp' }) // wraps to the end
+ expect(highlightOf()).toBe(screen.getByText('Delete').id)
+ })
+
+ it('jumps to the ends with Home and End', () => {
+ render( )
+ fireEvent.keyDown(content(), { key: 'End' })
+ expect(highlightOf()).toBe(screen.getByText('Delete').id)
+
+ fireEvent.keyDown(content(), { key: 'Home' })
+ expect(highlightOf()).toBe(screen.getByText('Rename').id)
+ })
+
+ it('typeahead jumps over a disabled match', () => {
+ render( )
+ fireEvent.keyDown(content(), { key: 'd' }) // Duplicate is disabled -> Delete
+ expect(highlightOf()).toBe(screen.getByText('Delete').id)
+ })
+
+ it('typeahead cycles on a repeated character', () => {
+ render( )
+ fireEvent.keyDown(content(), { key: 'c' })
+ expect(highlightOf()).toBe(screen.getByText('Copy').id)
+ fireEvent.keyDown(content(), { key: 'c' })
+ expect(highlightOf()).toBe(screen.getByText('Cut').id)
+ })
+
+ it('keeps document order in navigation after an item prop flips at runtime', () => {
+ const Flipping = ({ bDisabled }: { bDisabled: boolean }) => (
+
+
+
+ Alpha
+
+ Beta
+
+ Gamma
+
+
+
+ )
+ const { rerender } = render( )
+ rerender( )
+
+ fireEvent.keyDown(content(), { key: 'ArrowDown' })
+ expect(highlightOf()).toBe(screen.getByText('Alpha').id)
+ fireEvent.keyDown(content(), { key: 'ArrowDown' }) // b kept its place
+ expect(highlightOf()).toBe(screen.getByText('Beta').id)
+ })
+
+ it('typeahead reads the textValue prop over the rendered text', () => {
+ render(
+
+
+
+
+ Archive
+
+
+
+ ,
+ )
+ fireEvent.keyDown(content(), { key: 'z' })
+ expect(highlightOf()).toBe(screen.getByRole('menuitem').id)
+ })
+
+ it('Enter activates the highlighted item: onSelect fires, the menu closes, focus returns', () => {
+ const onRename = vi.fn()
+ render( )
+ act(() => trigger().focus())
+ fireEvent.keyDown(trigger(), { key: 'Enter' })
+
+ fireEvent.keyDown(content(), { key: 'Enter' })
+ expect(onRename).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('menu')).toBeNull()
+ expect(document.activeElement).toBe(trigger())
+ })
+
+ it('Escape closes and returns focus to the trigger', () => {
+ render( )
+ act(() => trigger().focus())
+ openMenu()
+
+ act(pressEscape)
+ expect(screen.queryByRole('menu')).toBeNull()
+ expect(document.activeElement).toBe(trigger())
+ })
+
+ it('stays open when onEscapeKeyDown prevents default', () => {
+ const onEscapeKeyDown = vi.fn(event => event.preventDefault())
+ render( )
+ act(pressEscape)
+ expect(onEscapeKeyDown).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('menu')).not.toBeNull()
+ })
+
+ it('Tab closes the menu', () => {
+ render( )
+ fireEvent.keyDown(content(), { key: 'Tab' })
+ expect(screen.queryByRole('menu')).toBeNull()
+ })
+ })
+
+ describe('pointer', () => {
+ it('highlights an item on hover and clears on leave', () => {
+ render( )
+ const item = screen.getByText('Copy')
+
+ fireEvent.pointerOver(item)
+ expect(highlightOf()).toBe(item.id)
+ expect(item.hasAttribute('data-highlighted')).toBe(true)
+
+ fireEvent.pointerOut(item)
+ expect(highlightOf()).toBeNull()
+ })
+
+ it('activates on item press: onSelect fires, then the menu closes', () => {
+ const onRename = vi.fn()
+ render( )
+ act(() => screen.getByText('Rename').click())
+ expect(onRename).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('menu')).toBeNull()
+ })
+
+ it('a disabled item neither highlights nor activates', () => {
+ render( )
+ const item = screen.getByText('Duplicate')
+
+ fireEvent.pointerOver(item)
+ expect(highlightOf()).toBeNull()
+
+ act(() => item.click())
+ expect(screen.queryByRole('menu')).not.toBeNull()
+ })
+ })
+
+ describe('outside interaction', () => {
+ it('closes on a press outside the content', () => {
+ render( )
+ fireEvent.pointerDown(document.body)
+ expect(screen.queryByRole('menu')).toBeNull()
+ })
+
+ it('closes when focus lands outside the content', () => {
+ render(
+ <>
+ outside
+
+ >,
+ )
+ 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'],