Canvas Design System
Main Site Tokens

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

Use when: One card, row or page has three or more actions that do not each deserve a button: Open, Duplicate, Copy share link, Delete at the end of a workshop row. Put navigation first, then actions, then the one destructive item last, after a separator. Labels are sentence case and start with a verb. Past about seven items, group them under headings. For a few links to other pages (Buy on Kindle, Read online), use 'mode' => 'disclosure'.
Don't use when: There are one or two actions: show them as Buttons. The reader picks a value a form submits: that is a Select. The choices need a search, a form or more than about ten entries: use a Side Panel. A facilitator needs the tool in the moment while running a session: keep it visible, not one click away (DESIGN.md §10.3). The workshop list's own card menu stays 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.

WorkshopLengthActions
Team retrospective90 min
Design sprint kickoffHalf day
Onboarding workshop2 hours
StateBehavior
OpenThe trigger's aria-expanded="true"; an icon trigger keeps its hover look while open, a button trigger's chevron turns.
Active itemHover 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.
Currentaria-current="true", red and semibold: where the reader already is.
Disabledaria-disabled="true". Still focusable, so it is found and its description heard as the reason; it never activates and the menu stays open.
DangerError 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.
BusyA 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

KeyWhereMenu modeDisclosure
Enter, SpaceTriggerOpen on the first itemOpen or close; focus stays on the trigger
↓ / ↑TriggerOpen on the first / last itemNothing
↓ / ↑ItemNext / previous item, wrapping; disabled items included, headings and separators skippedNext / previous link, wrapping
Home / EndItemFirst / last itemFirst / last link
A letterItemThe next item starting with itNothing
Enter, SpaceItemChoose itThe link's own
EscAnywhere in the menuClose, focus on the triggerClose, focus on the trigger
TabItemClose, and move on to the control after the menuThe browser's; leaving the panel closes it
Shift+TabItemClose, focus on the triggerThe 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.

No action chosen yet.

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

Do: show one or two actions as buttons, where they can be seen.
Don't: give a destructive item no confirmation. A 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

OptionValuesDefaultPurpose
$labelstringrequiredThe trigger's accessible name and, through aria-labelledby, the menu's; a button trigger's visible text
$itemsarrayrequiredThe entries, below. With none left to show the helper prints nothing
idstringws-menu-{n}Wrapper id; the trigger, panel, list and items derive theirs. Pass one per row
triggericon | buttoniconAn icon-only button named by the label, or a button showing it
iconMaterial Symbols namemore_horizThe icon trigger's glyph
variantghost | secondary | primaryghostThe trigger's button variant
sizesm | mdsmThe trigger's button size
alignend | startendWhich edge of the panel lines up with the trigger's
placementbottom | topbottomThe preferred side; it flips when that side lacks room
modemenu | disclosuremenuActions, or a few links to other pages
class, attrsstring, arrayOn the wrapper (the row's data, such as data-plan-id)
triggerAttrsarrayExtra attributes on the trigger; its ids, name and ARIA stay the component's
panela ws_menu_panel() idClone that shared panel on open instead of items of its own; the menu draws only its trigger

Item keys

KeyPurpose
typeitem (the default), separator, or heading, which starts a group
labelRequired; an item with none is dropped. Sentence case, verb first
hrefA 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
actionA button; choosing it fires ws-menu-action with this name
iconA leading 24px icon
descriptionA muted second line, and the item's accessible description: a disabled item's reason
dangerDestructive. 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
disabledFocusable, never activates. A disabled link renders as a button
currentWhere the reader already is
modalA 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
sidePanelA ws_side_panel id, the same way. Menu mode only
attrsExtra 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.

ConcernBehavior
NamesThe 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 stateMenu 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.
KeyboardAs 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.
DialogsAn 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.
Disabledaria-disabled="true", still focusable, so it is found and its reason heard. GrayText in forced colours.
Targets40px items and a 40px trigger with a fine pointer; 44px (--target-comfortable) on touch.
ContrastTrigger 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.
MotionOne 150ms entrance, a 4px drift away from the trigger; none under reduced motion. Closing is instant.
Forced coloursThe 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

TokenUse
--ws-menu-gap / --ws-menu-edgeSpace 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-height216px, 320px and 360px by default; set them on the wrapper
--bg-surface, --gray-200, --radius-lg, --shadow-lgThe white panel, its edge, corners and lift
--z-dropdownThe stacking of the fallback panel where the browser has no top layer
--text-default, --text-dark, --text-muted, --gray-100Item text, the active item and its background, icons, descriptions and headings
--brand-ink, --brandThe current item's text and icon
--color-error-dark, --color-error-very-light, --color-error-inkDanger at rest and active
--text-faint, --gray-50Disabled
--target-min, --icon-lg, --space-2, --target-comfortableItem height: one icon plus its padding (40px) with a fine pointer, 44px on touch
--focus-ring, --focus-ring-widthThe keyboard ring, inset on an item
--duration-fast, --ease-outThe entrance and the chevron's turn

CSS Classes

ClassPurpose
.ws-menuThe wrapper; --align-start/--align-end, --top and --disclosure modifiers
.ws-menu__triggerThe trigger, a ws_button
.ws-menu__panelThe panel; [data-side] says which side it opened on
.ws-menu__listThe role="menu" list, or a disclosure's <ul>
.ws-menu__itemAn item; --danger for a destructive one
.ws-menu__icon, .ws-menu__text, .ws-menu__label, .ws-menu__descAn item's parts
.ws-menu__group, .ws-menu__heading, .ws-menu__separatorGrouping

Files

FilePurpose
includes/components/helpers.phpws_menu() and ws_menu_panel()
includes/components/menu.phpTemplate
includes/components/components.cssStyles, in the MENU COMPONENT block
includes/components/components.jswsMenuOpen/Close/Toggle(), placement, keys, focus and dismissal, all delegated
js/ws.jsws.menu(), the same markup from JavaScript