HabMenu
An action menu anchored to a trigger.
<HabMenu :items="items">
<template #trigger>
<HabButton
:icon="mdiDotsVertical"
icon-only
variant="text"
tone="neutral"
aria-label="More actions"
/>
</template>
</HabMenu>The trigger slot is rendered as the anchor rather than wrapped in one, so your own button keeps its styling and its accessible name.
Items
import { mdiDelete, mdiPencil } from "@mdi/js";
const items = [
{ label: "Rename", icon: mdiPencil, onSelect: rename },
{ divider: true },
{ label: "Delete", icon: mdiDelete, tone: "error", onSelect: remove },
];onSelect rather than onClick, because Enter and Space activate an item too and "click" would misdescribe that.
Put destructive items last, after a divider. A delete adjacent to a rename is a misclick waiting to happen, and tone: "error" alone does not buy enough separation.
align controls which edge the sheet aligns to; right suits a row action.
A disabled item
An item carrying disabled stays in the menu, in disabled ink, and its onSelect never runs. Use it for an action that exists but is not available here: a delete the reader has no permission for, a copy only the owner may make.
const items = computed(() => [
{ label: "Rename", icon: mdiPencil, onSelect: rename },
{ label: "Duplicate", icon: mdiContentCopy, disabled: !canEdit.value, onSelect: duplicate },
{ divider: true },
{ label: "Delete", icon: mdiDelete, tone: "error", disabled: !canEdit.value, onSelect: remove },
]);Disable rather than hide. A menu whose rows come and go with the selection changes shape under the pointer, and a missing action says nothing about why it is missing.
disabled wins over tone and over a chosen option's tint: a disabled delete is not drawn as an available destructive one. On a submenu row it keeps the second sheet closed.
A single choice
An item carrying selected is an option, not a command: a theme, a sort order, a density. It renders as a menuitemradio, so a screen-reader user hears which one is on and that the others are its alternatives.
const items = computed(() => [
{ label: "Account", icon: mdiAccount },
{ divider: true },
...["Light", "Dark", "System"].map((name) => ({
label: name,
selected: theme.value === name,
onSelect: () => (theme.value = name),
})),
]);selected is tri-state. Leaving it off makes a plain command; true and false both mark an option. Commands and options coexist in one menu on purpose — that is the usual shape.
Adjacent options form one choice. A divider, a plain command or a submenu ends the run, so two choices in one menu are two runs with something between them. Exactly one option of a run should carry true.
The menu does not own the choice. onSelect says which option was picked; the marker moves only when the next render says so. A menu that moved its own marker would show the wrong answer whenever you declined the change.
tone is ignored on an option — a choice is not a destructive command.
A submenu
An item carrying items is a way in rather than a command: it opens a second sheet on hover, on Enter and on Right Arrow, and carries a chevron to say so.
const THEMES = [
{ name: "Light", icon: mdiWeatherSunny },
{ name: "Dark", icon: mdiWeatherNight },
{ name: "System", icon: mdiThemeLightDark },
];
const items = computed(() => [
{ label: "Account", icon: mdiAccount },
{ divider: true },
{
label: "Preferences",
icon: mdiTune,
items: [
...THEMES.map((entry) => ({
label: entry.name,
icon: entry.icon,
selected: theme.value === entry.name,
onSelect: () => (theme.value = entry.name),
})),
{ divider: true },
...["Collapse", "Keep open"].map((name) => ({
label: name,
selected: panels.value === name,
onSelect: () => (panels.value = name),
})),
],
},
{ divider: true },
{ label: "Sign out", icon: mdiLogoutVariant },
]);The nested array holds the same kinds of row as the top level — commands, dividers, choice runs, further submenus — because the renderer is the same one.
A submenu ends a choice run, exactly as a divider or a plain command does. Options either side of one are two choices, not one.
Its own onSelect is not called. Opening the sheet is what choosing the row does. selected is ignored too: a submenu is a place to go, not one option of a choice, and a row that tried to be both would leave its run showing an answer you cannot see.
Two levels is the practical limit. Nesting is recursive because the renderer is, but a third tier is a sign the menu wanted to be a dialog.
A description and a panel
description puts a second line under an item's label, in secondary ink — why a disabled item is unavailable, say. panel: "<name>" turns an item into a submenu whose sheet is the panel-<name> slot rather than rows, for a choice that is not a list. See HabContextMenu, which shares these rows.
<HabMenu :items="[{ label: 'Colour', icon: mdiPaletteOutline, panel: 'colour' }]">
<template #trigger>…</template>
<template #panel-colour="{ close }">
<HabColorGrid v-model="colour" label="Colour" @update:model-value="close" />
</template>
</HabMenu>A panel does not close the menu by itself, because what is in it is not a menu item. The slot receives close, which closes the whole menu — every sheet, not just the panel's. Call it from whatever commits the choice; leave it uncalled for a panel meant to stay open while the reader tries options.
The panel's keys belong to its content. Left and Right Arrow inside it reach what is there — a grid's swatches — instead of closing the sheet; Tab cycles through the panel's own tab stops and never leaves it; Escape closes the menu. Left Arrow steps back to the row only while nothing inside the panel has focus.
An item is a submenu of rows or a panel, never both: items and panel together are a type error, and a row that arrives with both from untyped code shows the panel and warns.
The menu otherwise opens and closes itself. Bind v-model:open only if you need to know, or to set, whether it is showing.
Width
The sheet is min-w-48 and sizes to its content, because a menu trigger is usually an icon button and matching a 32px button would give a 32px sheet.
Set match-trigger when the trigger is a full-width row — a nav rail's footer, a card header, a picker that looks like a field — and the sheet takes the trigger's width as its minimum instead. This is the one place HabMenu differs from HabSelect, which always matches: a select's list belongs to the field that opened it, a menu's does not.
Accessibility
- Wraps reka-ui's DropdownMenu. Unlike
HabSelectthere is no divergence to reconcile here — the design source specifies that a menu moves real focus into itself, because a menu item is a command and the thing that runs on Enter should be the thing that has focus. That is what the primitive does. - Arrow keys step between actions and skip dividers, since a separator is not focusable. Home and End jump to the ends; Esc closes and returns focus to the trigger.
- A run of options is wrapped in a
role="group", which is what gives "2 of 3" something to count. The group is left unnamed: reka names one from a menu label this API does not render, and a name pointing at nothing is worse than none. - A submenu row is announced as owning a popup (
aria-haspopup="menu"), opens with Right Arrow, and stays filled while its sheet is open so the two sheets read as connected. Left Arrow closes the second sheet and puts the cursor back on its row; Esc closes the whole menu from any level, rather than stepping back one. - A disabled item carries
aria-disabled="true"and is skipped by the arrow keys, as a divider is — that is reka's roving focus. It is still read out when the pointer reaches it. - Give the trigger an accessible name. An icon-only button needs
aria-label—HabButtonwarns when it has neither. - Known limitation: with the menu open, axe reports
aria-hidden-focusagainst reka's own focus sentinels. Primitive markup, not ours.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
align | "left" | "right" | "right" | Which edge the sheet aligns to. right suits a row action. |
items | HabMenuItem[] | [] | — |
matchTrigger | boolean | false | Widen the sheet to the trigger, instead of its own min-w-48. OPT-IN, unlike HabSelect — which always matches, because an option list belongs to the field that opened it. A menu's trigger is usually an icon button, and matching a 32px button would give a 32px sheet. Set it when the trigger is a full-width row: a nav rail's footer, a card header, a picker that looks like a field. |
open | boolean | undefined | Whether the sheet is showing, for an optional v-model:open. Omit it and the menu opens and closes itself, from its trigger — the usual case. |
Events
| Event | Payload | Description |
|---|---|---|
update:open | open: boolean | — |
Slots
| Slot | Bindings | Description |
|---|---|---|
trigger | any | The anchor — usually an icon-only HabButton. |