Menu
Menu is an accessible list of actions and navigation destinations. It composes Klean Popover, so the browser still owns native top-layer display and light dismissal. Menu adds the missing composite behavior: menu and menuitem semantics, one roving focus stop, Arrow keys, Home/End, printable-key typeahead, disabled-item handling, selection, and reliable cleanup.
The application supplies real buttons and links plus ordinary Tailwind. There is no MenuTrigger, MenuItem, item-data schema, asChild, visual variant, provider, or theme object.
Menu.vue
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Matches the native button's `popovertarget`. */
id: { type: String, default: undefined },
/** Framework-native controlled state. Omit for native uncontrolled use. */
open: { type: Boolean, default: undefined },
/** Initial state when `open` is not controlled. */
defaultOpen: { type: Boolean, default: false },
/** Preferred logical placement. Collision handling may flip it. */
placement: { type: String, default: 'bottom-start' },
/** Space in pixels between the invoker and menu. */
offset: { type: Number, default: 8 }
})
const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const popover = ref()
const internalOpen = ref(props.defaultOpen)
const activeInvoker = ref()
const isControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
isControlled.value ? props.open : internalOpen.value
)
const menuAttrs = computed(() => {
const {
class: _class,
role: _role,
tabindex: _tabindex,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
const menuClasses = computed(() => twMerge('min-w-40 p-1', attrs.class))
const TABBABLE_SELECTOR =
'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]'
let interactionRoot
let itemObserver
let pendingFocus = 'first'
let restoreOnClose = false
let tabExitPending = false
let tabExitTarget
let typeahead = ''
let typeaheadTimer
function contentElement() {
const exposed = popover.value?.content
return exposed?.value ?? exposed
}
function eventPath(event) {
return event.composedPath?.() ?? [event.target]
}
function invokers() {
const content = contentElement()
const root = content?.getRootNode?.() ?? document
return [...(root.querySelectorAll?.('[popovertarget]') ?? [])].filter(
(element) => element.getAttribute('popovertarget') === content?.id
)
}
function syncInvokerSemantics() {
for (const invoker of invokers()) {
invoker.setAttribute('aria-haspopup', 'menu')
}
}
function matchingInvoker(event) {
const id = contentElement()?.id
return eventPath(event).find(
(element) => element?.getAttribute?.('popovertarget') === id
)
}
function rememberInvoker(event) {
const invoker = matchingInvoker(event)
if (invoker) activeInvoker.value = invoker
}
function restoreInvokerFocus() {
const invoker = activeInvoker.value?.isConnected
? activeInvoker.value
: invokers()[0]
invoker?.focus?.({ preventScroll: true })
}
function tabStopsOutsideMenu(root, content) {
return [...(root.querySelectorAll?.(TABBABLE_SELECTOR) ?? [])].filter(
(element) =>
!content?.contains(element) &&
element.tabIndex >= 0 &&
!element.matches(':disabled') &&
!element.closest('[hidden], [inert]')
)
}
function adjacentTabStop(backward) {
const content = contentElement()
const invoker = activeInvoker.value?.isConnected
? activeInvoker.value
: invokers()[0]
let anchor = invoker
let root = content?.getRootNode?.() ?? document
while (anchor && root) {
const stops = tabStopsOutsideMenu(root, content)
const current = stops.indexOf(anchor)
let target
if (current >= 0) {
target = stops[current + (backward ? -1 : 1)]
} else {
const candidates = stops.filter((element) => {
const relation = anchor.compareDocumentPosition(element)
return backward ? Boolean(relation & 2) : Boolean(relation & 4)
})
target = backward ? candidates.at(-1) : candidates[0]
}
if (target) return target
if (!root.host) return undefined
anchor = root.host
root = anchor.getRootNode?.()
}
return undefined
}
function completeTabExit() {
if (tabExitTarget?.isConnected) {
tabExitTarget.focus({ preventScroll: true })
} else {
const root = contentElement()?.getRootNode?.() ?? document
root.activeElement?.blur?.()
}
tabExitTarget = undefined
tabExitPending = false
}
function itemRole(element) {
return ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(
element.getAttribute('role')
)
}
function menuItems() {
const content = contentElement()
if (!content) return []
for (const element of content.querySelectorAll('button, a[href]')) {
if (!element.hasAttribute('role')) element.setAttribute('role', 'menuitem')
}
const items = [
...content.querySelectorAll(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
)
].filter((element) => element.closest('[role="menu"]') === content)
for (const item of items) item.tabIndex = -1
return items
}
function itemIsDisabled(item) {
return (
item.matches(':disabled') ||
item.getAttribute('aria-disabled') === 'true' ||
item.hidden ||
item.closest('[hidden]') !== null
)
}
function enabledItems() {
return menuItems().filter((item) => !itemIsDisabled(item))
}
function focusedElement() {
return (
contentElement()?.getRootNode?.().activeElement ?? document.activeElement
)
}
function focusItem(item) {
if (!item) return
for (const candidate of menuItems()) candidate.tabIndex = -1
item.tabIndex = 0
item.focus({ preventScroll: true })
}
function focusEdge(edge = 'first') {
const items = enabledItems()
const item = edge === 'last' ? items.at(-1) : items[0]
if (item) focusItem(item)
else contentElement()?.focus({ preventScroll: true })
}
function clearTypeahead() {
typeahead = ''
clearTimeout(typeaheadTimer)
typeaheadTimer = undefined
}
function normalizedText(item) {
return (item.getAttribute('aria-label') ?? item.textContent ?? '')
.trim()
.toLocaleLowerCase()
}
function handleTypeahead(event) {
if (
event.key.length !== 1 ||
event.key === ' ' ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false
}
event.preventDefault()
clearTimeout(typeaheadTimer)
typeahead += event.key.toLocaleLowerCase()
typeaheadTimer = setTimeout(clearTypeahead, 500)
const items = enabledItems()
if (!items.length) return true
const current = items.indexOf(focusedElement())
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
let match = ordered.find((item) => normalizedText(item).startsWith(typeahead))
if (!match && new Set(typeahead).size === 1) {
typeahead = typeahead.at(-1)
match = ordered.find((item) => normalizedText(item).startsWith(typeahead))
}
if (match) focusItem(match)
return true
}
function requestOpen(nextOpen) {
if (!isControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
}
function openMenu(edge = 'first') {
pendingFocus = edge
if (isOpen.value) focusEdge(edge)
else requestOpen(true)
}
function closeMenu({ restoreFocus = false } = {}) {
restoreOnClose ||= restoreFocus
if (isOpen.value) requestOpen(false)
else if (restoreOnClose) {
restoreOnClose = false
nextTick(restoreInvokerFocus)
}
}
function handlePopoverOpen(nextOpen) {
if (!isControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
}
function handleInvokerKeydown(event) {
const invoker = matchingInvoker(event)
if (!invoker || invoker.matches(':disabled')) return
activeInvoker.value = invoker
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
openMenu(event.key === 'ArrowUp' ? 'last' : 'first')
}
}
function itemFromEvent(event) {
const content = contentElement()
return eventPath(event).find(
(element) =>
element?.nodeType === Node.ELEMENT_NODE &&
itemRole(element) &&
element.closest?.('[role="menu"]') === content
)
}
function handleClick(event) {
const item = itemFromEvent(event)
if (!item) return
if (itemIsDisabled(item)) {
event.preventDefault()
event.stopImmediatePropagation()
return
}
closeMenu({ restoreFocus: true })
}
function handleKeydown(event) {
const items = enabledItems()
const currentIndex = items.indexOf(focusedElement())
let nextIndex
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeMenu({ restoreFocus: true })
return
}
if (event.key === 'Tab') {
event.preventDefault()
clearTypeahead()
restoreOnClose = false
tabExitTarget = adjacentTabStop(event.shiftKey)
tabExitPending = true
closeMenu()
return
}
if (event.key === 'ArrowDown') {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
} else if (event.key === 'ArrowUp') {
nextIndex =
currentIndex < 0
? items.length - 1
: (currentIndex - 1 + items.length) % items.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = items.length - 1
} else if (handleTypeahead(event)) {
return
} else {
return
}
if (!items.length) return
event.preventDefault()
focusItem(items[nextIndex])
}
watch(
isOpen,
async (nextOpen) => {
await nextTick()
syncInvokerSemantics()
if (nextOpen) {
focusEdge(pendingFocus)
pendingFocus = 'first'
return
}
clearTypeahead()
menuItems()
if (tabExitPending) completeTabExit()
else if (restoreOnClose) restoreInvokerFocus()
restoreOnClose = false
},
{ flush: 'post' }
)
onMounted(async () => {
await nextTick()
const content = contentElement()
interactionRoot = content?.getRootNode?.() ?? document
interactionRoot.addEventListener('keydown', handleInvokerKeydown)
interactionRoot.addEventListener('click', rememberInvoker, true)
syncInvokerSemantics()
menuItems()
if (typeof MutationObserver !== 'undefined' && content) {
itemObserver = new MutationObserver(menuItems)
itemObserver.observe(content, { childList: true, subtree: true })
}
if (isOpen.value) focusEdge(pendingFocus)
})
onBeforeUnmount(() => {
clearTypeahead()
itemObserver?.disconnect()
interactionRoot?.removeEventListener('keydown', handleInvokerKeydown)
interactionRoot?.removeEventListener('click', rememberInvoker, true)
})
defineExpose({ close: closeMenu, open: openMenu })
</script>
<template>
<Popover
ref="popover"
v-bind="menuAttrs"
:id="id"
:open="isOpen"
:placement="placement"
:offset="offset"
role="menu"
tabindex="-1"
data-slot="menu"
:class="menuClasses"
@update:open="handlePopoverOpen"
@click.capture="handleClick"
@keydown="handleKeydown"
>
<slot :open="isOpen" :close="closeMenu" />
</Popover>
</template>Installation
Run the same command in Vue, React, or Svelte. Klean detects the framework and conventional destination, then installs Popover first when it is missing.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add menupnpm dlx klean-ui add menuyarn dlx klean-ui add menubunx klean-ui add menu- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
The dependency is source-level, not configuration: Menu imports its sibling Popover. The registry resolves that prerequisite before Menu and installs only the direct packages their readable source imports. No initializer, klean-ui.json, public cn.js, alias prompt, or Klean runtime appears.
Usage
Vue
<script setup>
import Button from '~/components/ui/button/Button.vue'
import Menu from '~/components/ui/menu/Menu.vue'
</script>
<template>
<Button popovertarget="project-actions">Actions</Button>
<Menu id="project-actions" aria-label="Project actions" class="w-56">
<button
type="button"
class="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
>
Redeploy
</button>
<a
href="/deployments"
class="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
>
View deployments
</a>
</Menu>
</template>
React
import Button from '~/components/ui/button/Button.jsx'
import Menu from '~/components/ui/menu/Menu.jsx'
export default function ProjectActions() {
return (
<>
<Button popovertarget="project-actions">Actions</Button>
<Menu id="project-actions" aria-label="Project actions" className="w-56">
<button
type="button"
className="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
>
Redeploy
</button>
<a
href="/deployments"
className="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
>
View deployments
</a>
</Menu>
</>
)
}
Svelte
<script>
import Button from '~/components/ui/button/Button.svelte'
import Menu from '~/components/ui/menu/Menu.svelte'
</script>
<Button popovertarget="project-actions">Actions</Button>
<Menu id="project-actions" aria-label="Project actions" class="w-56">
<button
type="button"
class="flex w-full cursor-pointer rounded px-3 py-2 text-left text-sm outline-none hover:bg-gray-100 focus:bg-gray-100"
>
Redeploy
</button>
<a
href="/deployments"
class="flex w-full rounded px-3 py-2 text-sm no-underline outline-none hover:bg-gray-100 focus:bg-gray-100"
>
View deployments
</a>
</Menu>
The framework syntax changes; the HTML contract does not. A real button uses native popovertarget. Native button and anchor children become menu items automatically, so developers do not repeat roles or tab indices.
Truthful items
Use a native button when selection performs an action:
<button
type="button"
class="cursor-pointer ..."
@click="redeploy"
>Redeploy</button>Use an anchor for navigation. The Boring Stack Link renders an anchor too, so it works without an adapter:
<Link href="/projects/42/settings" class="...">Project settings</Link>Menu does not accept an item array because an array forces the component to guess whether each record is a button, anchor, download, or framework Link. Authorization, conditional visibility, event handlers, and destinations remain obvious in application markup.
Native buttons keep the browser's default arrow cursor, so button-item recipes opt into cursor-pointer explicitly. That visible Tailwind class is part of the application-owned visual API; Menu does not mutate its children's appearance.
Disabled items
A disabled action uses the native disabled attribute and is skipped during keyboard navigation. A navigation item that must remain visible can use aria-disabled="true"; Menu prevents activation and skips it. Prefer hiding unauthorized items in application logic instead of teaching Menu about permissions.
<button
type="button"
disabled
class="disabled:cursor-not-allowed disabled:opacity-40"
>
Stop provisioning
</button>API
| Input | Default | Purpose |
|---|---|---|
id | generated | Native target identifier. Supply a stable value when a button invokes the Menu. |
placement | bottom-start | Preferred logical placement. It may flip or shift to remain visible. |
offset | 8 | Pixel distance between the invoker and menu. |
| framework open binding | uncontrolled | Observe or control visibility only when application behavior genuinely needs it. |
defaultOpen | false | Initial uncontrolled visibility, mainly useful for examples and tests. |
class / className | — | Ordinary Tailwind classes merged last on the menu surface. |
| default content | — | Native buttons, anchors, or framework links. |
Vue uses v-model:open, React uses open with onOpenChange, and Svelte uses bind:open. Placement and offset are geometry, not appearance. Menu has no variant, tone, size, inset, destructive, animation, or theme props.
Keyboard and focus
- Click, Enter, or Space on the real trigger opens and focuses the first enabled item.
- Arrow Down on the trigger opens at the first enabled item; Arrow Up opens at the last.
- Arrow Down and Arrow Up wrap between enabled items.
- Home and End move to the first and last enabled items.
- Printable characters use a short buffered typeahead against visible text or
aria-label. - Enter and Space activation remain native to the real button or anchor, avoiding double firing.
- Escape closes and restores focus to the invoker.
- Selection closes and restores focus; link navigation may then move to the destination.
- Tab or Shift+Tab closes and continues to the next or previous control outside the menu; neither key moves between menu items.
- Outside interaction closes without stealing focus from the selected target.
The vertical key contract is the same in right-to-left documents. Menu adds no animation, so reduced-motion users get a stable surface by default. Product motion, if useful, belongs in caller Tailwind and must use motion-safe: or an equivalent fallback.
Menu is not every floating list
- Menu is a composite widget of actions and destinations with arrow navigation and typeahead.
- Popover holds ordinary forms, filters, help, or previews and keeps normal Tab order.
- Select chooses one value and has selected-option behavior.
- Combobox combines text input, filtering, and an option popup.
- Dialog is modal, contains focus, and makes the background inert.
Website navigation remains a semantic nav and list of links with ordinary Tab behavior. Do not add Menu roles merely because navigation appears in a floating surface.
Product recipes
Slipway needs compact operational actions; Hagfish needs a stronger border and offset shadow. Those are caller recipes, not Klean themes.
product-menus.vue
<script setup>
import Button from '~/components/ui/button/Button.vue'
import Menu from '~/components/ui/menu/Menu.vue'
</script>
<template>
<div class="bg-[#f4f0e8] p-6">
<p class="mb-5 font-mono text-xs uppercase tracking-[0.18em] text-gray-600">
Hagfish / invoice
</p>
<Button
popovertarget="invoice-actions"
class="rounded-none border-2 border-black bg-black text-white hover:bg-white hover:text-black"
>
Invoice actions
</Button>
<Menu
id="invoice-actions"
aria-label="Invoice actions"
class="w-64 rounded-none border-2 border-black p-2 shadow-[6px_6px_0_0_#000]"
>
<a
href="/invoices/42"
class="flex w-full border-2 border-transparent px-3 py-2 text-sm font-medium text-black no-underline outline-none hover:border-black focus:border-black"
>
Preview invoice
</a>
<button
type="button"
class="flex w-full cursor-pointer border-2 border-transparent px-3 py-2 text-left text-sm font-medium text-red-700 outline-none hover:border-red-700 focus:border-red-700"
>
Void invoice
</button>
</Menu>
</div>
<div class="dark bg-gray-950 p-6 text-white">
<p class="mb-5 font-mono text-xs uppercase tracking-[0.18em] text-gray-400">
Slipway / deploy
</p>
<Button
popovertarget="deploy-actions"
class="min-h-9 min-w-0 bg-gray-800 px-3 py-1.5 text-sm hover:bg-gray-700"
>
Actions
</Button>
<Menu
id="deploy-actions"
aria-label="Deployment actions"
class="w-52 border-gray-700 bg-gray-900 p-1 text-white shadow-xl"
>
<button
type="button"
class="flex w-full cursor-pointer rounded px-2 py-2 text-left text-sm text-gray-200 outline-none hover:bg-white/10 focus:bg-white/10"
>
Redeploy
</button>
<a
href="/deployments/42/logs"
class="flex w-full rounded px-2 py-2 text-sm text-gray-200 no-underline outline-none hover:bg-white/10 focus:bg-white/10"
>
View logs
</a>
<button
type="button"
disabled
class="flex w-full cursor-not-allowed rounded px-2 py-2 text-left text-sm text-gray-500 outline-none"
>
Stop provisioning
</button>
</Menu>
</div>
</template>
Complete framework source
The preview Source tab contains the complete Vue component. The equivalent framework-native React and Svelte sources are copyable here; both import their local Klean Popover and preserve the same behavior contract.
React source
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Popover from '../popover/Popover.jsx'
const ITEM_SELECTOR =
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
const TABBABLE_SELECTOR =
'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]'
function eventPath(event) {
return (
event.nativeEvent?.composedPath?.() ??
event.composedPath?.() ?? [event.target]
)
}
function itemRole(element) {
return ['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(
element.getAttribute('role')
)
}
function itemIsDisabled(item) {
return (
item.matches(':disabled') ||
item.getAttribute('aria-disabled') === 'true' ||
item.hidden ||
item.closest('[hidden]') !== null
)
}
const Menu = forwardRef(function Menu(
{
id,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
placement = 'bottom-start',
offset = 8,
className,
children,
onKeyDown,
onClickCapture,
...contentProps
},
forwardedRef
) {
const popoverRef = useRef(null)
const activeInvoker = useRef(null)
const pendingFocus = useRef('first')
const restoreOnClose = useRef(false)
const tabExit = useRef({ pending: false, target: undefined })
const typeahead = useRef('')
const typeaheadTimer = useRef()
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const isOpen = isControlled ? controlledOpen : internalOpen
const latestOpen = useRef(isOpen)
latestOpen.current = isOpen
const contentElement = useCallback(() => popoverRef.current?.content, [])
const invokers = useCallback(() => {
const content = contentElement()
const root = content?.getRootNode?.() ?? document
return [...(root.querySelectorAll?.('[popovertarget]') ?? [])].filter(
(element) => element.getAttribute('popovertarget') === content?.id
)
}, [contentElement])
const syncInvokerSemantics = useCallback(() => {
for (const invoker of invokers()) {
invoker.setAttribute('aria-haspopup', 'menu')
}
}, [invokers])
const matchingInvoker = useCallback(
(event) => {
const contentId = contentElement()?.id
return eventPath(event).find(
(element) => element?.getAttribute?.('popovertarget') === contentId
)
},
[contentElement]
)
const restoreInvokerFocus = useCallback(() => {
const invoker = activeInvoker.current?.isConnected
? activeInvoker.current
: invokers()[0]
invoker?.focus?.({ preventScroll: true })
}, [invokers])
const adjacentTabStop = useCallback(
(backward) => {
const content = contentElement()
const invoker = activeInvoker.current?.isConnected
? activeInvoker.current
: invokers()[0]
let anchor = invoker
let root = content?.getRootNode?.() ?? document
while (anchor && root) {
const stops = [
...(root.querySelectorAll?.(TABBABLE_SELECTOR) ?? [])
].filter(
(element) =>
!content?.contains(element) &&
element.tabIndex >= 0 &&
!element.matches(':disabled') &&
!element.closest('[hidden], [inert]')
)
const current = stops.indexOf(anchor)
let target
if (current >= 0) {
target = stops[current + (backward ? -1 : 1)]
} else {
const candidates = stops.filter((element) => {
const relation = anchor.compareDocumentPosition(element)
return backward ? Boolean(relation & 2) : Boolean(relation & 4)
})
target = backward ? candidates.at(-1) : candidates[0]
}
if (target) return target
if (!root.host) return undefined
anchor = root.host
root = anchor.getRootNode?.()
}
return undefined
},
[contentElement, invokers]
)
const completeTabExit = useCallback(() => {
const target = tabExit.current.target
if (target?.isConnected) {
target.focus({ preventScroll: true })
} else {
const root = contentElement()?.getRootNode?.() ?? document
root.activeElement?.blur?.()
}
tabExit.current = { pending: false, target: undefined }
}, [contentElement])
const menuItems = useCallback(() => {
const content = contentElement()
if (!content) return []
for (const element of content.querySelectorAll('button, a[href]')) {
if (!element.hasAttribute('role'))
element.setAttribute('role', 'menuitem')
}
const items = [...content.querySelectorAll(ITEM_SELECTOR)].filter(
(element) => element.closest('[role="menu"]') === content
)
for (const item of items) item.tabIndex = -1
return items
}, [contentElement])
const enabledItems = useCallback(
() => menuItems().filter((item) => !itemIsDisabled(item)),
[menuItems]
)
const focusedElement = useCallback(
() =>
contentElement()?.getRootNode?.().activeElement ?? document.activeElement,
[contentElement]
)
const focusItem = useCallback(
(item) => {
if (!item) return
for (const candidate of menuItems()) candidate.tabIndex = -1
item.tabIndex = 0
item.focus({ preventScroll: true })
},
[menuItems]
)
const focusEdge = useCallback(
(edge = 'first') => {
const items = enabledItems()
const item = edge === 'last' ? items.at(-1) : items[0]
if (item) focusItem(item)
else contentElement()?.focus({ preventScroll: true })
},
[contentElement, enabledItems, focusItem]
)
const clearTypeahead = useCallback(() => {
typeahead.current = ''
clearTimeout(typeaheadTimer.current)
typeaheadTimer.current = undefined
}, [])
const requestOpen = useCallback(
(nextOpen) => {
if (!isControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
},
[isControlled, onOpenChange]
)
const openMenu = useCallback(
(edge = 'first') => {
pendingFocus.current = edge
if (latestOpen.current) focusEdge(edge)
else requestOpen(true)
},
[focusEdge, requestOpen]
)
const closeMenu = useCallback(
({ restoreFocus = false } = {}) => {
restoreOnClose.current ||= restoreFocus
if (latestOpen.current) requestOpen(false)
else if (restoreOnClose.current) {
restoreOnClose.current = false
queueMicrotask(restoreInvokerFocus)
}
},
[requestOpen, restoreInvokerFocus]
)
useImperativeHandle(
forwardedRef,
() => ({ content: contentElement(), open: openMenu, close: closeMenu }),
[closeMenu, contentElement, openMenu]
)
useEffect(() => {
syncInvokerSemantics()
if (isOpen) {
focusEdge(pendingFocus.current)
pendingFocus.current = 'first'
return
}
clearTypeahead()
menuItems()
if (tabExit.current.pending) completeTabExit()
else if (restoreOnClose.current) restoreInvokerFocus()
restoreOnClose.current = false
}, [
clearTypeahead,
completeTabExit,
focusEdge,
isOpen,
menuItems,
restoreInvokerFocus,
syncInvokerSemantics
])
useEffect(() => {
const content = contentElement()
const root = content?.getRootNode?.() ?? document
function rememberInvoker(event) {
const invoker = matchingInvoker(event)
if (invoker) activeInvoker.current = invoker
}
function handleInvokerKeydown(event) {
const invoker = matchingInvoker(event)
if (!invoker || invoker.matches(':disabled')) return
activeInvoker.current = invoker
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
openMenu(event.key === 'ArrowUp' ? 'last' : 'first')
}
}
root.addEventListener('keydown', handleInvokerKeydown)
root.addEventListener('click', rememberInvoker, true)
syncInvokerSemantics()
menuItems()
const observer =
typeof MutationObserver !== 'undefined' && content
? new MutationObserver(menuItems)
: undefined
observer?.observe(content, { childList: true, subtree: true })
return () => {
observer?.disconnect()
root.removeEventListener('keydown', handleInvokerKeydown)
root.removeEventListener('click', rememberInvoker, true)
clearTypeahead()
}
}, [
clearTypeahead,
contentElement,
matchingInvoker,
menuItems,
openMenu,
syncInvokerSemantics
])
function itemFromEvent(event) {
const content = contentElement()
return eventPath(event).find(
(element) =>
element?.nodeType === 1 &&
itemRole(element) &&
element.closest?.('[role="menu"]') === content
)
}
function handleClick(event) {
const item = itemFromEvent(event)
if (!item) {
onClickCapture?.(event)
return
}
if (itemIsDisabled(item)) {
event.preventDefault()
event.nativeEvent.stopImmediatePropagation()
return
}
closeMenu({ restoreFocus: true })
onClickCapture?.(event)
}
function handleTypeahead(event) {
if (
event.key.length !== 1 ||
event.key === ' ' ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false
}
event.preventDefault()
clearTimeout(typeaheadTimer.current)
typeahead.current += event.key.toLocaleLowerCase()
typeaheadTimer.current = setTimeout(clearTypeahead, 500)
const items = enabledItems()
if (!items.length) return true
const current = items.indexOf(focusedElement())
const ordered = [
...items.slice(current + 1),
...items.slice(0, current + 1)
]
const itemText = (item) =>
(item.getAttribute('aria-label') ?? item.textContent ?? '')
.trim()
.toLocaleLowerCase()
let match = ordered.find((item) =>
itemText(item).startsWith(typeahead.current)
)
if (!match && new Set(typeahead.current).size === 1) {
typeahead.current = typeahead.current.at(-1)
match = ordered.find((item) =>
itemText(item).startsWith(typeahead.current)
)
}
if (match) focusItem(match)
return true
}
function handleKeydown(event) {
const items = enabledItems()
const currentIndex = items.indexOf(focusedElement())
let nextIndex
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeMenu({ restoreFocus: true })
} else if (event.key === 'Tab') {
event.preventDefault()
clearTypeahead()
restoreOnClose.current = false
tabExit.current = {
pending: true,
target: adjacentTabStop(event.shiftKey)
}
closeMenu()
} else if (event.key === 'ArrowDown') {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length
} else if (event.key === 'ArrowUp') {
nextIndex =
currentIndex < 0
? items.length - 1
: (currentIndex - 1 + items.length) % items.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = items.length - 1
} else if (!handleTypeahead(event)) {
onKeyDown?.(event)
return
}
if (nextIndex !== undefined && items.length) {
event.preventDefault()
focusItem(items[nextIndex])
}
onKeyDown?.(event)
}
return (
<Popover
{...contentProps}
ref={popoverRef}
id={id}
open={isOpen}
onOpenChange={requestOpen}
placement={placement}
offset={offset}
role="menu"
tabIndex={-1}
data-slot="menu"
className={twMerge('min-w-40 p-1', className)}
onClickCapture={handleClick}
onKeyDown={handleKeydown}
>
{typeof children === 'function'
? children({ open: isOpen, close: closeMenu })
: children}
</Popover>
)
})
export default Menu
Svelte source
<script>
import { onMount, untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import Popover from "../popover/Popover.svelte";
const TABBABLE_SELECTOR =
'a[href], button, input, select, textarea, [tabindex], [contenteditable="true"]';
let {
id,
open = $bindable(),
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
offset = 8,
class: className = "",
children,
onkeydown,
onclickcapture,
...contentProps
} = $props();
let popoverElement = $state();
let internalOpen = $state(untrack(() => defaultOpen));
let activeInvoker = $state();
let isOpen = $derived(open ?? internalOpen);
let pendingFocus = "first";
let restoreOnClose = false;
let tabExitPending = false;
let tabExitTarget;
let typeahead = "";
let typeaheadTimer;
function contentElement() {
return popoverElement?.getContent?.();
}
function eventPath(event) {
return event.composedPath?.() ?? [event.target];
}
function invokers() {
const content = contentElement();
const root = content?.getRootNode?.() ?? document;
return [...(root.querySelectorAll?.("[popovertarget]") ?? [])].filter(
(element) => element.getAttribute("popovertarget") === content?.id,
);
}
function syncInvokerSemantics() {
for (const invoker of invokers()) {
invoker.setAttribute("aria-haspopup", "menu");
}
}
function matchingInvoker(event) {
const contentId = contentElement()?.id;
return eventPath(event).find(
(element) => element?.getAttribute?.("popovertarget") === contentId,
);
}
function restoreInvokerFocus() {
const invoker = activeInvoker?.isConnected ? activeInvoker : invokers()[0];
invoker?.focus?.({ preventScroll: true });
}
function adjacentTabStop(backward) {
const content = contentElement();
const invoker = activeInvoker?.isConnected ? activeInvoker : invokers()[0];
let anchor = invoker;
let root = content?.getRootNode?.() ?? document;
while (anchor && root) {
const stops = [
...(root.querySelectorAll?.(TABBABLE_SELECTOR) ?? []),
].filter(
(element) =>
!content?.contains(element) &&
element.tabIndex >= 0 &&
!element.matches(":disabled") &&
!element.closest("[hidden], [inert]"),
);
const current = stops.indexOf(anchor);
let target;
if (current >= 0) {
target = stops[current + (backward ? -1 : 1)];
} else {
const candidates = stops.filter((element) => {
const relation = anchor.compareDocumentPosition(element);
return backward ? Boolean(relation & 2) : Boolean(relation & 4);
});
target = backward ? candidates.at(-1) : candidates[0];
}
if (target) return target;
if (!root.host) return undefined;
anchor = root.host;
root = anchor.getRootNode?.();
}
return undefined;
}
function completeTabExit() {
if (tabExitTarget?.isConnected) {
tabExitTarget.focus({ preventScroll: true });
} else {
const root = contentElement()?.getRootNode?.() ?? document;
root.activeElement?.blur?.();
}
tabExitTarget = undefined;
tabExitPending = false;
}
function itemRole(element) {
return ["menuitem", "menuitemcheckbox", "menuitemradio"].includes(
element.getAttribute("role"),
);
}
function menuItems() {
const content = contentElement();
if (!content) return [];
for (const element of content.querySelectorAll("button, a[href]")) {
if (!element.hasAttribute("role"))
element.setAttribute("role", "menuitem");
}
const items = [
...content.querySelectorAll(
'[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]',
),
].filter((element) => element.closest('[role="menu"]') === content);
for (const item of items) item.tabIndex = -1;
return items;
}
function itemIsDisabled(item) {
return (
item.matches(":disabled") ||
item.getAttribute("aria-disabled") === "true" ||
item.hidden ||
item.closest("[hidden]") !== null
);
}
function enabledItems() {
return menuItems().filter((item) => !itemIsDisabled(item));
}
function focusedElement() {
return (
contentElement()?.getRootNode?.().activeElement ?? document.activeElement
);
}
function focusItem(item) {
if (!item) return;
for (const candidate of menuItems()) candidate.tabIndex = -1;
item.tabIndex = 0;
item.focus({ preventScroll: true });
}
function focusEdge(edge = "first") {
const items = enabledItems();
const item = edge === "last" ? items.at(-1) : items[0];
if (item) focusItem(item);
else contentElement()?.focus({ preventScroll: true });
}
function clearTypeahead() {
typeahead = "";
clearTimeout(typeaheadTimer);
typeaheadTimer = undefined;
}
function normalizedText(item) {
return (item.getAttribute("aria-label") ?? item.textContent ?? "")
.trim()
.toLocaleLowerCase();
}
function handleTypeahead(event) {
if (
event.key.length !== 1 ||
event.key === " " ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false;
}
event.preventDefault();
clearTimeout(typeaheadTimer);
typeahead += event.key.toLocaleLowerCase();
typeaheadTimer = setTimeout(clearTypeahead, 500);
const items = enabledItems();
if (!items.length) return true;
const current = items.indexOf(focusedElement());
const ordered = [
...items.slice(current + 1),
...items.slice(0, current + 1),
];
let match = ordered.find((item) =>
normalizedText(item).startsWith(typeahead),
);
if (!match && new Set(typeahead).size === 1) {
typeahead = typeahead.at(-1);
match = ordered.find((item) =>
normalizedText(item).startsWith(typeahead),
);
}
if (match) focusItem(match);
return true;
}
function requestOpen(nextOpen) {
if (open === undefined) internalOpen = nextOpen;
else open = nextOpen;
onOpenChange?.(nextOpen);
}
function openMenu(edge = "first") {
pendingFocus = edge;
if (isOpen) focusEdge(edge);
else requestOpen(true);
}
function closeMenu({ restoreFocus = false } = {}) {
restoreOnClose ||= restoreFocus;
if (isOpen) requestOpen(false);
else if (restoreOnClose) {
restoreOnClose = false;
queueMicrotask(restoreInvokerFocus);
}
}
function itemFromEvent(event) {
const content = contentElement();
return eventPath(event).find(
(element) =>
element?.nodeType === 1 &&
itemRole(element) &&
element.closest?.('[role="menu"]') === content,
);
}
function handleClick(event) {
const item = itemFromEvent(event);
if (!item) {
onclickcapture?.(event);
return;
}
if (itemIsDisabled(item)) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
closeMenu({ restoreFocus: true });
onclickcapture?.(event);
}
function handleKeydown(event) {
const items = enabledItems();
const currentIndex = items.indexOf(focusedElement());
let nextIndex;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeMenu({ restoreFocus: true });
} else if (event.key === "Tab") {
event.preventDefault();
clearTypeahead();
restoreOnClose = false;
tabExitTarget = adjacentTabStop(event.shiftKey);
tabExitPending = true;
closeMenu();
} else if (event.key === "ArrowDown") {
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
} else if (event.key === "ArrowUp") {
nextIndex =
currentIndex < 0
? items.length - 1
: (currentIndex - 1 + items.length) % items.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = items.length - 1;
} else if (!handleTypeahead(event)) {
onkeydown?.(event);
return;
}
if (nextIndex !== undefined && items.length) {
event.preventDefault();
focusItem(items[nextIndex]);
}
onkeydown?.(event);
}
$effect(() => {
const nextOpen = isOpen;
queueMicrotask(() => {
syncInvokerSemantics();
if (nextOpen) {
focusEdge(pendingFocus);
pendingFocus = "first";
return;
}
clearTypeahead();
menuItems();
if (tabExitPending) completeTabExit();
else if (restoreOnClose) restoreInvokerFocus();
restoreOnClose = false;
});
});
onMount(() => {
const content = contentElement();
const root = content?.getRootNode?.() ?? document;
function rememberInvoker(event) {
const invoker = matchingInvoker(event);
if (invoker) activeInvoker = invoker;
}
function handleInvokerKeydown(event) {
const invoker = matchingInvoker(event);
if (!invoker || invoker.matches(":disabled")) return;
activeInvoker = invoker;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
openMenu(event.key === "ArrowUp" ? "last" : "first");
}
}
root.addEventListener("keydown", handleInvokerKeydown);
root.addEventListener("click", rememberInvoker, true);
syncInvokerSemantics();
menuItems();
const observer =
typeof MutationObserver !== "undefined" && content
? new MutationObserver(menuItems)
: undefined;
observer?.observe(content, { childList: true, subtree: true });
return () => {
observer?.disconnect();
root.removeEventListener("keydown", handleInvokerKeydown);
root.removeEventListener("click", rememberInvoker, true);
clearTypeahead();
};
});
</script>
<Popover
{...contentProps}
bind:this={popoverElement}
{id}
open={isOpen}
onOpenChange={requestOpen}
{placement}
{offset}
role="menu"
tabindex={-1}
data-slot="menu"
class={twMerge("min-w-40 p-1", className)}
onclickcapture={handleClick}
onkeydown={handleKeydown}
>
{@render children?.({ open: isOpen, close: closeMenu })}
</Popover>
Accessibility and Durable UI contract
- The invoker remains a real button and automatically receives
aria-haspopup="menu",aria-controls, and synchronizedaria-expanded. - Button and anchor children keep their truthful native activation while Menu supplies composite roles and roving focus.
- Disabled items cannot activate and never become the active roving focus stop.
- Escape and selection restore focus only when the invoker still exists; Tab exits forward or backward in composed document order; outside interaction does not steal focus.
- Keyboard behavior remains correct in RTL layouts and as items change.
- Menu open state is ephemeral and is never written to storage, cookies, server data, or the URL.
- Meaningful state selected from a menu follows the Durable UI contract; appearance follows the application-owned theming convention.