Overlaysstable.menu
Menu
A popover list of actions or links opened from a button — the home of secondary actions ("Weitere Aktionen").
Use a menu for secondary actions of a row, card or page once there are more than ~3 buttons (UX guide §5.4): edit, send email, move, remove. The primary action stays visible outside the menu.
Built on the native popover attribute: the trigger button has popovertarget, so opening, Esc and outside clicks (light dismiss) need no script. The menu behaviour (js/menu.js) places the menu next to its trigger (end-aligned, flipping above when there is no room below, mirrored in RTL), keeps aria-expanded on the trigger in sync, moves focus to the first item and adds arrow-key navigation.
Items are buttons with data-action (handled by the page with one delegated listener) or links with href. Separators group related items; destructive items go last, after a separator, in red — and still need undo or a confirm.
Examples
Row actions with a destructive item
Data
render({
"id": "menu-m-1042",
"label": "Weitere Aktionen für Lea Müller",
"items": [
{
"action": "edit",
"label": "Bearbeiten",
"icon": "edit"
},
{
"action": "mail",
"label": "E-Mail senden",
"icon": "mail"
},
{
"href": "/members/m-1042/print",
"label": "Mitgliederausweis drucken",
"icon": "printer"
},
{
"separator": true
},
{
"action": "remove",
"label": "Aus Gruppe entfernen",
"icon": "trash",
"danger": true
}
]
})Markup
<button class="button" type="button" data-variant="ghost" data-icon-only aria-label="Weitere Aktionen für Lea Müller" popovertarget="menu-m-1042" aria-haspopup="menu" aria-expanded="false"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#more"/></svg></button>
<div class="menu" id="menu-m-1042" popover role="menu" aria-label="Weitere Aktionen für Lea Müller" data-behavior="menu"><button class="menu__item" type="button" role="menuitem" data-action="edit"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#edit"/></svg>Bearbeiten</button><button class="menu__item" type="button" role="menuitem" data-action="mail"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#mail"/></svg>E-Mail senden</button><a class="menu__item" role="menuitem" href="/members/m-1042/print"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#printer"/></svg>Mitgliederausweis drucken</a><div class="menu__separator" role="separator"></div><button class="menu__item" type="button" role="menuitem" data-action="remove" data-variant="danger"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#trash"/></svg>Aus Gruppe entfernen</button></div>Text trigger in a page header
Data
render({
"id": "menu-exam",
"label": "Aktionen für Gurtprüfung November",
"triggerText": "Aktionen",
"triggerIcon": "chevron-down",
"triggerVariant": "secondary",
"items": [
{
"action": "export",
"label": "Teilnehmerliste exportieren",
"icon": "download"
},
{
"action": "print",
"label": "Urkunden drucken",
"icon": "printer",
"disabled": true
}
]
})Markup
<button class="button" type="button" aria-label="Aktionen für Gurtprüfung November" popovertarget="menu-exam" aria-haspopup="menu" aria-expanded="false"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#chevron-down"/></svg>Aktionen</button>
<div class="menu" id="menu-exam" popover role="menu" aria-label="Aktionen für Gurtprüfung November" data-behavior="menu"><button class="menu__item" type="button" role="menuitem" data-action="export"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#download"/></svg>Teilnehmerliste exportieren</button><button class="menu__item" type="button" role="menuitem" data-action="print" disabled><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#printer"/></svg>Urkunden drucken</button></div>Data contract
What render(data) accepts — validated against this JSON Schema in development and tests. Unknown fields are rejected. The same schema is in components.json.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | — | Id of the popover (popovertarget of the trigger). Must be unique on the page, e.g. menu-{memberId}.pattern ^[A-Za-z][\w-]*$ |
label | string | required | — | Accessible name of trigger and menu, specific to the object ("Weitere Aktionen für Lea Müller"). min 1 chars |
triggerText | string | optional | — | Visible trigger text ("Aktionen"). Without it the trigger is an icon-only button named by label.min 1 chars |
triggerIcon | string | optional | "more" | Icon id of the trigger. |
triggerVariant | "secondary" | "ghost" | optional | "ghost" | Trigger button variant. |
items | array of object | object | required | — | Menu entries in order: actions, links and separators. min 1 items |
JSON Schema
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label",
"items"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Id of the popover (`popovertarget` of the trigger). Must be unique on the page, e.g. `menu-{memberId}`."
},
"label": {
"type": "string",
"minLength": 1,
"description": "Accessible name of trigger and menu, specific to the object (\"Weitere Aktionen für Lea Müller\")."
},
"triggerText": {
"type": "string",
"minLength": 1,
"description": "Visible trigger text (\"Aktionen\"). Without it the trigger is an icon-only button named by `label`."
},
"triggerIcon": {
"type": "string",
"default": "more",
"description": "Icon id of the trigger."
},
"triggerVariant": {
"type": "string",
"enum": [
"secondary",
"ghost"
],
"default": "ghost",
"description": "Trigger button variant."
},
"items": {
"type": "array",
"minItems": 1,
"description": "Menu entries in order: actions, links and separators.",
"items": {
"description": "An action/link item or a separator.",
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"label"
],
"description": "An action (`action`) or a link (`href`).",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"description": "Item text, a verb (\"Bearbeiten\", \"E-Mail senden\")."
},
"action": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Action key written to `data-action` (\"edit\", \"remove\"). Renders a `<button>`."
},
"href": {
"type": "string",
"description": "Link target URL. Renders an `<a>` instead of a button."
},
"icon": {
"type": "string",
"description": "Icon id shown before the text."
},
"danger": {
"type": "boolean",
"default": false,
"description": "Destructive item (red text); still needs undo or confirm."
},
"disabled": {
"type": "boolean",
"default": false,
"description": "Not available now; skipped by arrow keys (buttons only)."
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"separator"
],
"description": "A divider line between groups of items.",
"properties": {
"separator": {
"const": true,
"description": "Marks this entry as a separator."
}
}
}
]
}
}
}
}Markup & states
Root: div.menu — usable without render() by writing the markup directly.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
popover | — | — | Native popover (top layer, light dismiss). |
role="menu" | — | — | Menu semantics; items have role="menuitem". |
data-behavior="menu" | — | — | Positioning and keyboard support from js/menu.js. |
popovertarget / aria-haspopup / aria-expanded (on trigger) | id / menu / true|false | — | Connects the trigger; aria-expanded is kept in sync by the behaviour. |
Parts
| Part | Description |
|---|---|
.menu__item | <button role="menuitem" data-action> or <a role="menuitem" href>, 44 px high. |
.menu__separator | Divider (role="separator"). |
States
| State | Description |
|---|---|
:popover-open | Shown. |
.menu__item:hover / :focus-visible | Highlighted row. |
.menu__item[data-variant="danger"] | Destructive item in red. |
.menu__item:disabled | Not available; skipped by arrow keys. |
Behaviour & events
Module: js/menu.js
data-behavior="menu" on the popover + mount(root). Opening/closing itself is native (popovertarget). Handle choices with one delegated listener: root.addEventListener("click", e => { const a = e.target.closest("[data-action]"); if (a) … a.dataset.action }).
Events
| Event | Detail | Description |
|---|---|---|
click on [data-action] | event.target.closest("[data-action]").dataset.action | An action item was chosen; the menu closes itself. |
toggle (on the menu) | event.newState → "open" | "closed" | Native popover event; the behaviour uses it to place the menu and focus the first item. |
Accessibility
- Keyboard: opening focuses the first item; ↓/↑ move (wrapping), Home/End jump to first/last, Esc closes and returns focus to the trigger (native), Tab closes the menu.
- Icon-only triggers are named by
label— make it specific ("Weitere Aktionen für Lea Müller"), not just "Mehr". - Destructive items are marked by text, not only by red colour.
Guidance
Related: Button · List · Bottom sheet