Action Menu
The three dots at the end of a row or card, or a labelled button, that open a short list of actions on that one thing. A disclosure mode does the same for a few links to other pages.
When to Use
'mode' => 'disclosure'.
ws_wl_menu until it moves onto this one. A disclosure must not be a page's only route somewhere: without script its panel stays hidden.
Variants
Icon trigger: the actions on a row
The default. A quiet more_horiz button named by the label (“Actions for Quarterly planning offsite”), which also names the menu. Delete opens its confirmation dialog.
<?= ws_menu('Actions for ' . $plan['name'], [
['label' => 'Open in Planner', 'href' => $planUrl, 'icon' => 'edit_note'],
['label' => 'Run in Facilitator', 'href' => $runUrl, 'icon' => 'play_arrow'],
['type' => 'separator'],
['label' => 'Duplicate', 'action' => 'duplicate', 'icon' => 'content_copy'],
['label' => 'Copy share link', 'action' => 'share', 'icon' => 'link'],
['type' => 'separator'],
['label' => 'Delete', 'action' => 'delete', 'icon' => 'delete', 'danger' => true, 'modal' => 'confirm-delete'],
], ['id' => 'plan-' . $id . '-menu', 'attrs' => ['data-plan-id' => $id]]) ?>A disabled item names its reason in description: here 'disabled' => !$hasAgenda, 'description' => 'Add an agenda first' on Save as template.
Button trigger
'trigger' => 'button' shows the label with a chevron that turns while the menu is open. Here the items are links, so a middle click opens a new tab.
<?= ws_menu('Export', [
['label' => 'PDF', 'href' => $pdfUrl, 'icon' => 'picture_as_pdf'],
['label' => 'Slide deck', 'href' => $deckUrl, 'icon' => 'slideshow'],
['label' => 'Run-of-show', 'href' => $runUrl, 'icon' => 'format_list_numbered'],
], ['trigger' => 'button', 'variant' => 'secondary', 'align' => 'start']) ?>Headings, descriptions and the current item
A heading starts a group that runs to the next heading or separator. description is a muted second line, and current marks where the reader already is. Create workspace opens a dialog, and closing the dialog brings focus back to this trigger.
Disclosure: a few links
'mode' => 'disclosure' is for navigation. The panel is a plain list of links in the Tab order, with no menu roles, so a screen reader keeps its reading mode. Items that open a dialog are dropped in this mode. Known limitation: the panel is hidden until components.js opens it, so without script these links cannot be reached; link the same places elsewhere on the page too.
<?= ws_menu('Get the book', [
['label' => 'Buy on Kindle', 'href' => $buyHref, 'icon' => 'shopping_bag'],
['label' => 'Read online', 'href' => '#about', 'icon' => 'auto_stories'],
], ['mode' => 'disclosure', 'trigger' => 'button', 'variant' => 'primary']) ?>A long list
Past the room on the page the list scrolls inside the panel. Type a letter to jump: S goes to Silent timer, S again to Sketch.
One panel for a whole list
When every row of a list has the same menu, render the entries once with ws_menu_panel(), and give each row's ws_menu the panel option naming it. The row draws only its trigger; the panel is cloned in after it when it opens and removed when it closes, and ws-menu-open lets the page drop the entries that row lacks and fill in its ids. A page of forty rows then carries one set of entries, not forty. The workshop list's card menu works this way.
<?= ws_menu_panel($rowItems, ['id' => 'plans-menu']) ?> // once, in the list
<?php foreach ($plans as $plan): ?>
<?= ws_menu('Actions for ' . $plan['name'], [], [
'id' => 'plan-' . $plan['id'] . '-menu',
'panel' => 'plans-menu',
'attrs' => ['data-plan-id' => $plan['id']],
]) ?>
<?php endforeach; ?>States
On a card that lifts on hover
A hovered card moves with a transform, which would carry a fixed panel with it. The panel sits in the top layer instead, so it stays put and nothing on the card clips it.
Quarterly planning offsite
Two days, 14 people
In a scrolling table
The table below scrolls and clips its overflow. The open panel is not cut off; scroll the table and the panel follows its trigger, and closes once the trigger has scrolled out of view.
| State | Behavior |
|---|---|
| Open | The trigger's aria-expanded="true"; an icon trigger keeps its hover look while open, a button trigger's chevron turns. |
| Active item | Hover and the arrow keys share one active item: hovering an item focuses it, so the arrows carry on from where the pointer is. The focus ring shows only from the keyboard. |
| Current | aria-current="true", red and semibold: where the reader already is. |
| Disabled | aria-disabled="true". Still focusable, so it is found and its description heard as the reason; it never activates and the menu stays open. |
| Danger | Error red text and icon. Always confirms in a ws_modal: the item is a button that opens the dialog named by its modal, even when it was also given an href. With no modal it renders disabled. |
| Busy | A listener may set aria-busy="true" on an item during a slow action; it reads faint and does not activate. |
Real-World Usage
The workshop list's card menu (ws_wl_menu in app/includes/workshop-list.php) is a ws_menu on one shared panel per list. Its app/js/workshop-list-menu.js cuts each clone down to the row on ws-menu-open, runs duplicate, share, template and comments on ws-menu-action, and fills the page's one delete confirmation on ws-menu-dialog. The team page's row menus, the book pages' Get the book and the planner landing's switcher come next; each builds its own copy today.
Keyboard
| Key | Where | Menu mode | Disclosure |
|---|---|---|---|
| Enter, Space | Trigger | Open on the first item | Open or close; focus stays on the trigger |
| ↓ / ↑ | Trigger | Open on the first / last item | Nothing |
| ↓ / ↑ | Item | Next / previous item, wrapping; disabled items included, headings and separators skipped | Next / previous link, wrapping |
| Home / End | Item | First / last item | First / last link |
| A letter | Item | The next item starting with it | Nothing |
| Enter, Space | Item | Choose it | The link's own |
| Esc | Anywhere in the menu | Close, focus on the trigger | Close, focus on the trigger |
| Tab | Item | Close, and move on to the control after the menu | The browser's; leaving the panel closes it |
| Shift+Tab | Item | Close, focus on the trigger | The browser's |
Events
All bubble from the menu's wrapper. ws-menu-open fires once the panel is shown and placed, before focus moves, so a listener can still edit the entries; ws-menu-close carries its reason (escape, outside, tab, select, toggle, scroll, api); ws-menu-action fires when an item with an action that acts at once is chosen, after the menu has closed and focus is back on the trigger. An item that opens a dialog (a modal or sidePanel) never fires ws-menu-action: it fires ws-menu-dialog with action, modal or sidePanel, item, menu and trigger, just before the dialog opens, so the page can say in the dialog which workshop it is about. Only the dialog's own button deletes. Choose an item above to see the event here.
document.addEventListener('ws-menu-action', function (e) {
if (e.detail.action === 'duplicate') duplicatePlan(e.detail.menu.dataset.planId);
});
document.addEventListener('ws-menu-dialog', function (e) {
// Fill in the confirmation; it opens next. Its own Delete button deletes.
if (e.detail.modal === 'confirm-delete') confirmDelete.dataset.planId = e.detail.menu.dataset.planId;
});Scripting: wsMenuOpen(menu, { focus: 'first' }), wsMenuClose(menu, { restoreFocus, reason }) and wsMenuToggle(menu), where menu is the wrapper or its id. From JavaScript, draw one with ws.menu(label, items, options) from js/ws.js; it needs no ws.init().
Do and Don't
danger item always names a modal, the confirmation the destructive-confirmation recipe builds. Without one it renders disabled and the dev server warns; an href on it is ignored.
['label' => 'Delete', 'href' => '/x?delete=1', 'danger' => true] // no modal: disabled, the href ignored
Options
| Option | Values | Default | Purpose |
|---|---|---|---|
$label | string | required | The trigger's accessible name and, through aria-labelledby, the menu's; a button trigger's visible text |
$items | array | required | The entries, below. With none left to show the helper prints nothing |
id | string | ws-menu-{n} | Wrapper id; the trigger, panel, list and items derive theirs. Pass one per row |
trigger | icon | button | icon | An icon-only button named by the label, or a button showing it |
icon | Material Symbols name | more_horiz | The icon trigger's glyph |
variant | ghost | secondary | primary | ghost | The trigger's button variant |
size | sm | md | sm | The trigger's button size |
align | end | start | end | Which edge of the panel lines up with the trigger's |
placement | bottom | top | bottom | The preferred side; it flips when that side lacks room |
mode | menu | disclosure | menu | Actions, or a few links to other pages |
class, attrs | string, array | On the wrapper (the row's data, such as data-plan-id) | |
triggerAttrs | array | Extra attributes on the trigger; its ids, name and ARIA stay the component's | |
panel | a ws_menu_panel() id | Clone that shared panel on open instead of items of its own; the menu draws only its trigger |
Item keys
| Key | Purpose |
|---|---|
type | item (the default), separator, or heading, which starts a group |
label | Required; an item with none is dropped. Sentence case, verb first |
href | A link. Wins over everything below, except on a danger item. Only http, https, mailto, tel, relative and fragment hrefs; any other scheme, such as javascript:, is dropped |
action | A button; choosing it fires ws-menu-action with this name |
icon | A leading 24px icon |
description | A muted second line, and the item's accessible description: a disabled item's reason |
danger | Destructive. Put it last, after a separator, and give it a modal: it is always a button that opens that confirmation, and without one it is disabled |
disabled | Focusable, never activates. A disabled link renders as a button |
current | Where the reader already is |
modal | A ws_modal id: the menu closes, focus goes to the trigger, ws-menu-dialog fires, the dialog opens, and closing it returns focus to the trigger. Menu mode only |
sidePanel | A ws_side_panel id, the same way. Menu mode only |
attrs | Extra attributes on the item, names lowercased. Nothing the item writes itself (role, tabindex, href, type, its ARIA, data-ws-menu-*) can be set here |
Accessibility
Menu mode is the WAI-ARIA menu button; disclosure mode is the disclosure APG recommends for site navigation.
| Concern | Behavior |
|---|---|
| Names | The trigger is named by the label (its aria-label on an icon trigger, its text on a button trigger), and the menu by the trigger through aria-labelledby. An item is named by its label alone; its description is its aria-describedby. A group is named by its heading. |
| Roles and state | Menu mode: role="menu", menuitem, separator and group; the trigger has aria-haspopup="menu". Disclosure: no menu roles, a list of links. The trigger's aria-expanded is always true or false and its aria-controls names an element in the page. |
| Keyboard | As the table above. Items are out of the Tab order in menu mode, reached with the arrows. Focus never lands on something hidden: every close either returns it to the trigger or leaves it where the reader put it. |
| Dialogs | An item that opens a ws_modal or side panel closes the menu and focuses the trigger first, so the dialog hands focus back to the trigger. Escape in a menu inside an open modal closes only the menu. A menu that closes on its own (scrolled away, or closed by a script) puts focus on the trigger rather than dropping it. |
| Disabled | aria-disabled="true", still focusable, so it is found and its reason heard. GrayText in forced colours. |
| Targets | 40px items and a 40px trigger with a fine pointer; 44px (--target-comfortable) on touch. |
| Contrast | Trigger icon --text-muted (5.39:1 on white), items --text-default (7.66:1), danger --color-error-dark (4.83:1), current --brand-ink (5.02:1). A focused danger item and its description turn --color-error-ink on the danger tint (6.6:1). Disabled text is exempt; its reason is not and stays --text-muted. |
| Motion | One 150ms entrance, a 4px drift away from the trigger; none under reduced motion. Closing is instant. |
| Forced colours | The focused item takes a Highlight outline, separators are drawn in CanvasText and disabled items in GrayText; danger and current carry their meaning in words and weight, not colour alone. |
Tokens
| Token | Use |
|---|---|
--ws-menu-gap / --ws-menu-edge | Space between trigger and panel (--space-1-5), and the margin the panel keeps from the viewport's edge (--space-2) |
--ws-menu-min-width / --ws-menu-max-width / --ws-menu-max-height | 216px, 320px and 360px by default; set them on the wrapper |
--bg-surface, --gray-200, --radius-lg, --shadow-lg | The white panel, its edge, corners and lift |
--z-dropdown | The stacking of the fallback panel where the browser has no top layer |
--text-default, --text-dark, --text-muted, --gray-100 | Item text, the active item and its background, icons, descriptions and headings |
--brand-ink, --brand | The current item's text and icon |
--color-error-dark, --color-error-very-light, --color-error-ink | Danger at rest and active |
--text-faint, --gray-50 | Disabled |
--target-min, --icon-lg, --space-2, --target-comfortable | Item height: one icon plus its padding (40px) with a fine pointer, 44px on touch |
--focus-ring, --focus-ring-width | The keyboard ring, inset on an item |
--duration-fast, --ease-out | The entrance and the chevron's turn |
CSS Classes
| Class | Purpose |
|---|---|
.ws-menu | The wrapper; --align-start/--align-end, --top and --disclosure modifiers |
.ws-menu__trigger | The trigger, a ws_button |
.ws-menu__panel | The panel; [data-side] says which side it opened on |
.ws-menu__list | The role="menu" list, or a disclosure's <ul> |
.ws-menu__item | An item; --danger for a destructive one |
.ws-menu__icon, .ws-menu__text, .ws-menu__label, .ws-menu__desc | An item's parts |
.ws-menu__group, .ws-menu__heading, .ws-menu__separator | Grouping |
Files
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_menu() and ws_menu_panel() |
includes/components/menu.php | Template |
includes/components/components.css | Styles, in the MENU COMPONENT block |
includes/components/components.js | wsMenuOpen/Close/Toggle(), placement, keys, focus and dismissal, all delegated |
js/ws.js | ws.menu(), the same markup from JavaScript |