oriUI

useMenu

A headless menu button primitive built on the WAI-ARIA menu-button pattern — a trigger that opens a popup menu of actions, with roving tabindex, arrow / Home / End navigation, and Enter / Space activation. It owns the open state and the highlighted item and exposes the ARIA wiring as ready-to-bind prop bags; you own the markup, the styles, and the DOM focus (roving needs real focus, and click-outside / focus-return are host concerns a state projection can't reach).

This is the Vue binding; the framework-agnostic core lives in @oriui/headless, and the styled OriMenu is built on it. Like useDialog / useDisclosure, useMenu resolves through the swappable OriHeadless contract — its default is a thin projection of the core menu state machine (no adapter to register unless you want to swap one), and the composable hands you open / highlightedValue to watch so the UI can move real DOM focus itself.

Import

ts
import { useMenu, type MenuItem } from '@oriui/headless/vue'

Options

Pass an options object (or a getter returning one, to stay reactive):

OptionTypeDefaultDescription
itemsMenuItem[]requiredThe menu items, in render order. Reactive — navigation re-syncs when it changes.
idstringauto (useId)Stable base id for the trigger / content / item ARIA wiring. Pass one for SSR.
disabledbooleanfalseDisables the trigger and blocks opening the menu.
onSelect(value: string) => void—Fired when an item is activated (click / Enter / Space); the menu closes.

Each MenuItem is a plain object — value is the stable identity and the onSelect payload:

ts
interface MenuItem {
    value: string // stable identity + the value passed to onSelect
    label?: string // display text; falls back to value
    disabled?: boolean // skipped by roving navigation and selection
    separator?: boolean // renders a role=separator rule here instead of an item; skipped the same way
}

Returns

The open / highlight state plus the prop bags you bind to each part. Every *Props value is a ComputedRef, and getItemProps returns a bag — bind each with v-bind.

PropertyTypeDescription
openComputedRef<boolean>Whether the menu is open.
highlightedValueComputedRef<string | null>The roving-highlighted item's value, or null. Watch it to move real DOM focus to the active item.
itemsComputedRef<MenuItem[]>The current item list (echoes the items option) — convenient to v-for over.
triggerPropsComputedRef<object>The menu button: type, aria-haspopup="menu", aria-controls, live aria-expanded, data-state, and the click / keydown handlers.
contentPropsComputedRef<object>The role="menu" panel: aria-labelledby, aria-orientation="vertical", tabindex="-1", hidden while closed, and the navigation keydown handler.
separatorPropsComputedRef<object>A role="separator" divider (aria-orientation="horizontal") for grouping items — bind it for an entry whose separator is true.
getItemProps(item, index)(item: MenuItem, index: number) => objectPer-item bag: role="menuitem", roving tabindex, data-highlighted, aria-disabled, and the click / pointermove handlers.
getItemState(item)(item: MenuItem) => { highlighted: boolean }The item's derived state — currently just whether it is highlighted — for your own styling.
setOpen(open)(open: boolean) => voidOpen / close imperatively (the host uses this to close on outside click).
highlight(value)(value: string | null) => voidSet the highlighted item by value; null clears the highlight.
highlightFirst()() => voidHighlight the first enabled item.
highlightLast()() => voidHighlight the last enabled item.

Every prop bag also carries data-scope="menu" and a data-part (trigger / content / item / separator) styling hook.

Usage

The composable projects state to prop bags; the host moves real DOM focus to the highlighted item (roving requires it), returns focus to the trigger on close, and wires the outside-click dismissal — things a framework-agnostic projection can't do itself:

vue
<script setup lang="ts">
import { nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
import { useMenu, type MenuItem } from '@oriui/headless/vue'

const items: MenuItem[] = [
    { value: 'new', label: 'New file' },
    { value: 'open', label: 'Open…' },
    { value: 'delete', label: 'Delete', disabled: true }
]

const m = useMenu(() => ({ items, onSelect: (value) => console.log(value) }))

const contentRef = useTemplateRef<HTMLElement>('content')
const triggerEl = (): HTMLElement | null => {
    const id = m.triggerProps.value.id as string | undefined
    return id ? document.getElementById(id) : null
}

// Roving tabindex: move real DOM focus to the highlighted item whenever it changes while open.
watch(
    () => m.highlightedValue.value,
    async () => {
        if (!m.open.value) return
        await nextTick()
        contentRef.value?.querySelector<HTMLElement>('[data-highlighted]')?.focus()
    }
)

// On open, focus the menu and wire outside-click; on close, return focus to the trigger.
const onOutside = (event: PointerEvent): void => {
    const target = event.target as Node
    if (contentRef.value?.contains(target) || triggerEl()?.contains(target)) return
    m.setOpen(false)
}
watch(
    () => m.open.value,
    async (open) => {
        if (open) {
            await nextTick()
            if (m.highlightedValue.value === null) contentRef.value?.focus()
            document.addEventListener('pointerdown', onOutside)
        } else {
            document.removeEventListener('pointerdown', onOutside)
            triggerEl()?.focus()
        }
    }
)
onBeforeUnmount(() => document.removeEventListener('pointerdown', onOutside))
</script>

<template>
    <button v-bind="m.triggerProps.value">Actions</button>

    <div v-bind="m.contentProps.value" ref="content" class="ori-menu">
        <div
            v-for="(item, index) in items"
            :key="item.value"
            v-bind="m.getItemProps(item, index)"
            class="ori-menu__item"
        >
            {{ item.label ?? item.value }}
        </div>
    </div>
</template>

The styled OriMenu wraps exactly this pattern — the roving-focus watch, the open / close focus-return, and the outside-click listener — behind a #trigger slot and CSS Anchor Positioning.

Accessibility

The prop bags carry the full WAI-ARIA menu-button contract; the host completes it by moving real focus.

  • The trigger MUST be a real <button> (or a component rendering one) with an accessible name. triggerProps set type="button", aria-haspopup="menu", aria-controls (the panel id), and a live aria-expanded that tracks open — the machine owns open, so this stays in sync.
  • contentProps set role="menu", aria-labelledby the trigger, aria-orientation="vertical", tabindex="-1", and hidden while closed. Items get role="menuitem" from getItemProps.
  • Roving tabindex: exactly one item (the highlighted one) is tabindex="0" and carries data-highlighted; the rest are tabindex="-1". There is no aria-activedescendant — the host moves real DOM focus to the highlighted item as you navigate, driven by watching highlightedValue.
  • A disabled item (disabled: true) is aria-disabled="true" and is skipped by Arrow / Home / End navigation and by selection.
  • Selecting an item (click, or Enter / Space on the highlighted one) fires onSelect with its value and closes the menu; the host returns focus to the trigger. Escape, Tab, and an outside click also close it — the machine sends CLOSE on Escape / Tab, and the host calls setOpen(false) on the outside click.
KeyAction
Enter / Space (on trigger)Opens the menu and highlights the first enabled item.
↓ (on trigger)Opens the menu and highlights the first enabled item.
↑ (on trigger)Opens the menu and highlights the last enabled item.
↓ / ↑ (in menu)Moves the highlight to the next / previous enabled item (wraps).
Home / End (in menu)Highlights the first / last enabled item.
Enter / Space (on item)Activates the highlighted item — fires onSelect, then closes.
EscapeCloses the menu (the host returns focus to the trigger).
TabCloses the menu and moves focus onward (does not trap focus).

See also

  • @oriui/headless — the framework-agnostic core: the menu state machine + the connect projection behind this binding.
  • OriMenu — the styled component built on this composable.
  • useDialog · useDisclosure — the sibling headless overlay / show-hide primitives.
  • CSS layer — the standalone .ori-menu / .ori-menu__item classes for the menu parts.