Components › Forms & input › Button › Menu button

Menu button

canon

The one action menu of the app: a labelled button with a chevron, or the kebab „⋯“ (iconOnly), opening a menu of actions; no action is the default one.

AD + owner 28 Sep 2026 („sjednotit popover do menu-button“): Menu button is THE action menu — the row anatomy moved here from Popover. Row: .ic (icon | iconNode) · label (.tx > .nm when something is on the right) · .tag · .acrm-kbd (aria-hidden) · .chk (checked rows); variants danger (auto separator before the first danger row), strong, disabled + title (tooltip + aria-describedby); empty/loading row {empty} = .acrm-popover-empty. Kebab = iconOnly → canonical IconButton (sm / framed), label = aria-label + phone sheet title, aria-haspopup/expanded + stopPropagation by the component. „Dimmed but clickable“ rows do not exist. Phone = BottomSheet. Mechanical refactor moved 18 hand-built Popover menus onto it (acrm MR, 28 Sep 2026); sub-menu (mail „Přesunout do složky ›“, reservation „Předat…“), menu header (account menu) and insert menus wait for an approved shape (MR-B). History: FA204, 26 Sep 2026 (acrm !3890) 1:1 port of „Přidat aktivitu“ (P144, TL-12); custom trigger({open, menu, onClick}) acrm !3899 (dialog chip row, rail link !4014). forceMenu (menu even with one item) and danger items (2026-09-28, acrm !4088). Touch (AD 28 Sep 2026, acrm !4111): in the phone sheet a disabled item shows its title as a visible second line .acrm-popover-reason (--hint, --ink-600, aligned with the label, outside the button) — MenuItems sheet; desktop keeps the tooltip. 3 Oct 2026: MenuButtonItem count / countTone / muted / actions / actionsLabel (acrm !4527). Registry repaired 17 Oct 2026 (review F6): added exported MenuPanel to react[].

Used in 63 files · 33 areas · 69× — components/AppShell, components/FilterBar, components/NavItemMenu, components/ReservationControl, components/accounting, components/assistant …

The one action menu of the app (AD + owner 28 Sep 2026: „sjednotit popover do menu-button"). A trigger plus a menu of actions — a labelled button „Přidat aktivitu ⌄", or the kebab „⋮" of a row, card or window. Popover is only the overlay layer underneath; a screen never composes an action menu from Popover + .acrm-popover-item itself. Picking a value is PickerMenu, not this.

When to use

  • Several related actions share one entry point and none of them is the default: „Přidat aktivitu" → Schůzka, Úkol, Zaznamenat kontakt; „Nahrát audio" → the recording sources.
  • The kebab „⋮" of a row, a card header, a record or a window (iconOnly): Upravit, Kopírovat odkaz, Smazat.
  • The empty state needs such an action — pass it as action.menu to EmptyState, never compose a menu under it by hand.

When not to use

  • One action is clearly the default — use Split button.
  • The items pick a value (view, stage, owner, filter) — use Picker menu, Select or Check menu.
  • Only one action is available — pass one item; a labelled trigger becomes a plain Button that runs it. A kebab (iconOnly, or forceMenu) still opens the menu: three dots must never run an unseen action (often „Smazat“).
  • The layer holds a form, a calendar or a preview — that is a Dialog, Picker panel or Entity preview, not a menu.

Anatomy

  1. Trigger — canonical <Button> (default, primary or ghost) with an optional leading glyph, the label and a trailing chevron-down; or, with iconOnly, the canonical IconButton (quiet sm, or iconVariant="framed" in a window/toolbar; glyph dots-vertical ⋮ unless icon says otherwise — one „more" symbol in the whole app, ⋯ is gone (owner 6 Oct 2026, acrm !4800)). With iconOnly the label is not drawn but is required: it is the aria-label and the title of the phone sheet. aria-haspopup="menu" / aria-expanded and stopPropagation (a kebab lives on clickable rows) are the component's job, never the host's.
  2. Menu — DS Popover aligned to the start of the trigger, flipping to the end only when it would not fit (owner 26 Sep 2026, acrm !3903); on a phone a BottomSheet (grip, title = label, Hotovo, scrim).
  3. Row .acrm-popover-item (role="menuitem"), 32px, 500 13/20, ALWAYS ONE LINE (owner 28 Sep 2026, ACRM-375–377): the label sits in .nm and a long one ends with an ellipsis, the full text is the row's title; an action menu is capped at min(360px, 100vw − --sp-5). Slots, in order:
  4. .ic — the glyph (icon) or a custom mark (iconNode: avatar, SVG);
  5. label — bare text, or .tx > .nm when something stands to the right;
  6. .acrm-kbd — a keyboard shortcut, hidden from screen readers (the label names the action);
  7. .chk — only for checked rows (menuitemradio), .chk.off keeps the space. A row with href (export, download, open in a new tab) is a real <a role="menuitem"> — target, download, middle-click and „open in new tab“ keep working; a disabled one is a button without href.
  8. Row variants: .danger (irreversible action), .strong (the recommended one among equals), :disabled with its reason in title (tooltip + aria-describedby) — on the phone too, never a visible second line (owner 28 Sep 2026; the .acrm-popover-reason line of acrm !4111 was removed).
  9. Separator .acrm-popover-sep between groups by intent. The separator before the first danger row is drawn by the component — the host never writes it; separators at the edges and doubled ones are dropped.
  10. Empty / loading row .acrm-popover-empty ({ empty: true, label }) — one muted line („Načítám…", „Žádné podpisy"), not an item, never a heading; a menu with only this row still opens.
  11. Submenu row (submenu) — a trailing chevron-right in the .drill slot (a glyph, not text; not .sub — a global side-menu class); it runs nothing, it opens the next level. Header (header) and switch row (switchOn) — see below.

Custom trigger

trigger({ open, menu, disabled, onClick }) (acrm !3899) swaps the drawn trigger for a host element that must still look right in the shape the surrounding row already owns — the component keeps deciding the menu, the items and the "one item = no menu" rule; the host only renders the trigger and wires onClick. Shipped shapes: a dialog's chip row (MetaChip, .tk-chip); a quiet rail row link („Nahrát audio ⌄", .mr-reclink, acrm !4014) — the host forwards aria-haspopup/aria-expanded itself there, since it owns the element. Use iconOnly, not a custom trigger, for a kebab.

forceMenu and danger items (2026-09-28, acrm !4088)

forceMenu keeps the menu even with a single item — for a ⋮ whose content changes with the screen (the bar's „Další akce" on a phone, a filter condition's „Odebrat filtr"), so one tap never runs an action the user did not see. danger: true on an item draws the red .danger row.

  • Submenu (drill-in) — { key, label, icon, submenu: MenuButtonItem[] }. Choosing it swaps the content of the SAME panel for the submenu, topped by a back row (arrow-left + the submenu's name) and a separator. On a phone it is the next step of the same sheet: the sheet title becomes the submenu's name and the back row says „Zpět". Closing the menu always returns it to the root. One level only. Pattern: mail „Přesunout do složky ›" (the folders are checked rows), reservation „Předat kolegovi ›".
  • Header — header={…}: an identity block above the items (avatar = the same UserAvatar face as the top bar, name, e-mail) with a separator under it, drawn only in the root. It is information („who am I signed in as"), not a heading and not an item. Only the account menu uses it.
  • Switch row — { key, label, icon, switchOn: boolean, onClick }: the canonical .acrm-switch on the right; a click toggles it and the menu stays open, so the result is visible at once (dark mode in the account menu). A setting with more than two values is not a switch — use checked rows or a submenu.

MenuPanel (@acrm/ui) is the body of Menu button — rows, submenu, header, the phone sheet — without the trigger. The host owns the anchor and the open state: <MenuPanel anchorEl open onClose label items />. Use it only where the trigger cannot belong to the menu:

  • the anchor is someone else's element (the phone menu of a preview chip);
  • the menu is opened from an editor or another control, or has two triggers;
  • the host needs to know the menu is open (items load only on open: template variables, bank „Přiřadit" suggestions).

The host draws its trigger with aria-haspopup="menu" and aria-expanded. label is the invisible menu name and the phone sheet title — short, no parenthetical explanation („Proměnné", not „Proměnné do pole Předmět (ukázkové hodnoty)"). Loading, error and empty states are { empty: true, label } rows. Closing by the host returns the menu to the root. A bare Popover with hand-made rows is never the answer — that is exactly what MenuPanel replaces.

Sizes

  • The Button's height, or IconButton sm (24) / framed (32×30) for iconOnly; no size of its own.

Best practices

Do
  • Keep the label a verb phrase true for every item („Nahrát audio", not „Soubor").
  • Group by intent (plan vs. record) with a separator, never with a heading.
  • Put irreversible actions last with danger: true — the separator comes by itself.
  • Say why an item is disabled (title) unless it is obvious from the screen.
  • Give the same action the same glyph and order on every surface — one shared list, not a copy per screen.
Don’t
  • Don't compose rows from Popover + .acrm-popover-item — use Menu button, or MenuPanel when the host must own the trigger.
  • Don't make an item „dimmed but clickable" — an item is either active, or disabled with its reason.
  • Don't let a row wrap to two lines, and don't add a grey note to it — no trailing tag (state, count, „Firemní", „brzy", „Přiřazeno"), no second line with a reason or a description. „Ty šedé texty jsou úplně k ničemu" (owner, 28 Sep 2026). The tag prop and PickerMenu desc no longer exist; validate-ds-usage rejects .tag in a menu row.
  • Don't open a second, nested popover from a row — a submenu (submenu) swaps the content of the same panel.
  • Don't use a submenu for more than one level, or for 2–3 items that fit in the root.
  • Don't make a bare ⋮ a direct action. The kebab means „more actions“ and never runs anything blind — with iconOnly (or forceMenu) the menu opens even for a single item (AD, 28 Sep 2026). Collapsing „one item = a plain button“ holds only for a Menu button with a text label, where the action is visible.
  • Don't add a heading over the rows.

Content

  • Label: verb, sentence case. Items: the full action („Nahrát soubor", „Z Plaudu"); a trailing „…" only when the action opens a further step („Sloučit s jinou osobou…"). One line each, no period.
  • Empty row is a sentence: „Žádné podpisy", „Načítám…".
  • Button, Split button (shares the rows), Icon button (the kebab trigger), MenuPanel (the same menu, host-owned trigger), Picker menu (pick a value), Popover (the overlay layer), Empty state.
  • Stories: components--menu-button--default, --variants (kebab), --states, --anatomy, --submenu-header-switch, --composition.

Count, row actions and muted items (acrm !4527, 3 Oct 2026)

  • count (+ countTone: 'accent') draws the canonical .acrm-count at the right edge of an item — use it for a number such as unread messages; kbd stays reserved for a keyboard shortcut.
  • actions (+ actionsLabel) adds a trailing ⋮ next to the row (wrapper .acrm-popover-row, never a button inside a button): clicking the row runs its onClick, ⋮ drills the same panel into the row's actions with a back row named after it — for lists where a row opens a record and ⋮ manages it. On touch the ⋮ is always visible.
  • muted dims only the item's glyph while the label stays readable — the same meaning as RecordOption.muted (e.g. an archived thread once group headings are gone).
  • First consumer: the assistant's thread history (groups by time are separated only by a divider, never headed). Story: Menu button › Row actions & count.

Source: docs/button/menu-button.usage.md · GitLab