Menu
Group actions and destinations in a keyboard-accessible menu. Built on Popover, Menu supports Arrow keys, Home/End, typeahead, disabled items, selection, and focus return.
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.
<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>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>
</>
)
}<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>View source 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 from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add menupnpm dlx klean-ui add menuyarn dlx klean-ui add menubunx klean-ui add menuUsage
Copy the example above for your framework.
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 visibility when open state is uncontrolled. |
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
Use compact spacing for operational actions or a stronger border and offset shadow for a more expressive menu.
View source 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
<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>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<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.