import { nextTick } from 'vue' export interface UseMenuKeyboardOptions { /** Returns the currently navigable items inside the open panel, in DOM order. */ items: () => HTMLElement[] /** True while the panel is open. */ isOpen: () => boolean /** Opens the panel. */ open: () => void /** Closes the panel. When `returnFocus` is true, focus should return to the trigger. */ close: (returnFocus?: boolean) => void } export interface UseMenuKeyboardReturn { /** * Bind to the trigger's `@keydown`. ArrowDown/Enter/Space open the panel (when * closed) and focus its first item; ArrowUp opens it focused on the last item. */ handleTriggerKeydown: (event: KeyboardEvent) => void /** * Bind to the open panel's `@keydown`. ArrowUp/ArrowDown cycle focus with * wraparound, Home/End jump to the first/last item, Escape closes, returns * focus to the trigger and stops propagating, and Tab closes without moving focus. */ handlePanelKeydown: (event: KeyboardEvent) => void } /** * Shared keyboard contract for menu- and listbox-style disclosure panels (the * WAI-ARIA menu button pattern). Used by ActionMenu and AppAvatarMenu, * each pairing it with `useDropdownState` for the disclosure state itself. */ export function useMenuKeyboard(options: UseMenuKeyboardOptions): UseMenuKeyboardReturn { function focusItem(position: 'first' | 'last') { void nextTick(() => { const items = options.items() const target = position === 'first' ? items[0] : items[items.length - 1] target?.focus() }) } function handleTriggerKeydown(event: KeyboardEvent) { if (options.isOpen()) return if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { event.preventDefault() options.open() focusItem('first') return } if (event.key === 'ArrowUp') { event.preventDefault() options.open() focusItem('last') } } function handlePanelKeydown(event: KeyboardEvent) { const items = options.items() const currentIndex = items.indexOf(document.activeElement as HTMLElement) if (event.key === 'Escape') { event.preventDefault() // The menu handles this Escape; an enclosing BaseModal must not close too. event.stopPropagation() options.close(true) return } if (event.key === 'Tab') { options.close() return } if (event.key === 'Home') { event.preventDefault() items[0]?.focus() return } if (event.key === 'End') { event.preventDefault() items[items.length - 1]?.focus() return } if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return event.preventDefault() const direction = event.key === 'ArrowDown' ? 1 : -1 const nextIndex = (Math.max(currentIndex, 0) + direction + items.length) % items.length items[nextIndex]?.focus() } return { handleTriggerKeydown, handlePanelKeydown } }