Menu

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.

FieldTypeRequiredDefaultDescription
idstringrequired—Id of the popover (popovertarget of the trigger). Must be unique on the page, e.g. menu-{memberId}.
pattern ^[A-Za-z][\w-]*$
labelstringrequired—Accessible name of trigger and menu, specific to the object ("Weitere Aktionen für Lea Müller").
min 1 chars
triggerTextstringoptional—Visible trigger text ("Aktionen"). Without it the trigger is an icon-only button named by label.
min 1 chars
triggerIconstringoptional"more"Icon id of the trigger.
triggerVariant"secondary" | "ghost"optional"ghost"Trigger button variant.
itemsarray of object | objectrequired—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

AttributeValuesDefaultDescription
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

PartDescription
.menu__item<button role="menuitem" data-action> or <a role="menuitem" href>, 44 px high.
.menu__separatorDivider (role="separator").

States

StateDescription
:popover-openShown.
.menu__item:hover / :focus-visibleHighlighted row.
.menu__item[data-variant="danger"]Destructive item in red.
.menu__item:disabledNot 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

EventDetailDescription
click on [data-action]event.target.closest("[data-action]").dataset.actionAn 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

DoKeep the primary action outside the menu.
DoPut destructive items last, after a separator.
Don'tDon't hide the only way to do a common task in a menu.
Don'tDon't nest menus.
Don'tDon't put form controls into a menu.

Related: Button · List · Bottom sheet