04 / Take action
Action menu
A compact menu for commands, with arrow navigation, typeahead, dismissal, and focus restoration.
Try it.
Isolated preview using the source below. In the playground, click Run to start.
Copy. Register. Use.
Keep the template outside your mount region and register the definition on your existing
app before mounting. Pass props for data and slots for custom markup.
Adapt the example parent state to your app, or create one with
brio.createApp({ state: exampleState }). Register once; use multiple instances.
<template id="brio-menu-template"> <section class="part-root" b-id="['title', 'trigger', 'menu']" b-bind-aria-labelledby="$id.title" b-bind-style="{ '--part-accent': $props.accent || null }" b-on-click.outside="$data.close(false)" b-on-focusout="$data.onFocusOut($event)" b-on-keydown="$data.onKey($event)"> <style> @scope { :scope { display: block; font: 15px/1.65 system-ui, sans-serif; color: light-dark(#152139, #edf3ff); } * { box-sizing: border-box; } h2 { margin: 0 0 12px; font-size: 1.5rem; line-height: 1.3; letter-spacing: -.035em; } p { margin: 0 0 16px; } [hidden] { display: none !important; } button { cursor: pointer; font: inherit; color: inherit; background: transparent; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 7px; padding: 8px 14px; } button:hover, .is-open { background: light-dark(#eef5ff, #1b3251); } :focus-visible { outline: 3px solid var(--part-accent, light-dark(#245fe2, #9bc1ff)); outline-offset: 3px; } a { color: var(--part-accent, light-dark(#245fe2, #9bc1ff)); } .menu-anchor { position: relative; margin-bottom: 18px; } [role="menu"] { position: absolute; top: calc(100% + 8px); left: 0; width: min(220px, 100%); z-index: 1; padding: 6px; background: light-dark(#fff, #111e32); border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 9px; box-shadow: 0 8px 24px #0002; } [role="menuitem"] { display: block; width: 100%; text-align: left; border: 0; } } </style> <h2 tabindex="-1" b-bind-id="$id.title" b-text="$props.title || $data.content.title"></h2> <p b-text="$data.content.description"></p> <div class="menu-anchor"> <button type="button" data-trigger b-bind-id="$id.trigger" aria-haspopup="menu" b-bind-aria-controls="$id.menu" b-bind-aria-expanded="$data.opened" b-bind-class="{ 'is-open': $data.opened }" b-on-click="$data.toggle()" b-on-keydown="$data.triggerKey($event)"> <slot name="trigger"><span b-text="$data.content.triggerLabel"></span></slot> </button> <div role="menu" b-bind-id="$id.menu" b-bind-aria-labelledby="$id.trigger" hidden b-bind-hidden="!$data.opened"> <button type="button" role="menuitem" tabindex="-1" b-for="item in $data.content.items" b-key="$for.item.id" b-on-click="$data.choose($for.item)" b-text="$for.item.label"></button> </div> </div> <p role="status" b-text="$data.content.message"></p> <a b-bind-href="'#' + $id.title" b-on-click.prevent="$data.backToHeading()" b-text="$data.content.backLabel"></a> </section></template>const menuComponent = { template: '#brio-menu-template', props: { title: String, accent: String }, setup({ el, $props, $emit }) { let focusRequest; let typed = ''; let typedAt = 0; const trigger = () => el.querySelector('[data-trigger]'); const menu = () => el.querySelector('[role="menu"]'); const items = () => [...el.querySelectorAll('[role="menuitem"]')]; brio.onCleanup(() => cancelAnimationFrame(focusRequest)); return { opened: false, get content() { const content = $props.content; if (!content || !Array.isArray(content.items)) throw new Error('brio-menu requires a content object with items.'); return content; }, open(index = 0) { if (!items().length) return; typed = ''; typedAt = 0; this.opened = true; cancelAnimationFrame(focusRequest); focusRequest = requestAnimationFrame(() => { if (this.opened && !menu().contains(document.activeElement)) (items()[index] || items()[0])?.focus(); }); }, close(restoreFocus = true) { this.opened = false; cancelAnimationFrame(focusRequest); if (restoreFocus) trigger()?.focus(); }, toggle() { if (this.opened) this.close(); else this.open(); }, backToHeading() { this.close(false); const heading = el.querySelector('h2'); heading.focus({ preventScroll: true }); heading.scrollIntoView({ block: 'nearest' }); }, choose(item) { $emit('action', { id: item.id, label: item.label }); this.close(); }, triggerKey(event) { if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return; event.preventDefault(); this.open(event.key === 'ArrowUp' ? items().length - 1 : 0); }, onFocusOut(event) { if (!el.contains(event.relatedTarget)) this.close(false); }, onKey(event) { if (!this.opened || !menu().contains(event.target)) return; if (event.key === 'Escape') { event.preventDefault(); this.close(); return; } if (event.key === 'Tab') { this.close(); return; } const entries = items(); const current = entries.indexOf(event.target); let next; if (event.key === 'ArrowDown') next = (current + 1) % entries.length; if (event.key === 'ArrowUp') next = (current + entries.length - 1) % entries.length; if (event.key === 'Home') next = 0; if (event.key === 'End') next = entries.length - 1; if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey && event.key !== ' ') { typed = Date.now() - typedAt > 700 ? event.key : typed + event.key; typedAt = Date.now(); const query = /^(.)\1+$/.test(typed) ? event.key : typed; for (let offset = 1; offset <= entries.length; offset++) { const index = (current + offset) % entries.length; if (entries[index].textContent.trim().toLowerCase().startsWith(query.toLowerCase())) { next = index; break; } } } if (next === undefined) return; event.preventDefault(); entries[next].focus(); }, }; },};app.component('brio-menu', menuComponent);const exampleState = () => ({ content: { "title": "A few things you can do.", "description": "An action menu, not a replacement for site navigation or a select input.", "triggerLabel": "Note actions", "backLabel": "Back to heading", "message": "Choose an action. This is a local demonstration.", "items": [ { "id": "duplicate", "label": "Duplicate" }, { "id": "export", "label": "Export" }, { "id": "archive", "label": "Archive" } ] }, act({ label }) { this.content.message = `${label} selected. No data was changed.`; }});<brio-menu b-prop-content="$data.content" b-on-action="$data.act($event.detail)"></brio-menu><brio-menu b-prop-content="$data.content" b-on-action="$data.act($event.detail)"> <span slot="trigger"><span aria-hidden="true">+</span> <span b-text="$data.content.triggerLabel"></span></span></brio-menu>Make it yours.
Supply content.items as { id, label } objects, plus trigger and status text. Handle commands by ID in the parent so translated labels do not change their identity. An empty list does not open.
- Use for commands, not site navigation or form selection. The example only updates a status message.
- Enabled commands only: no disabled items, submenus, checkable items, or async action handling.
- The menu opens downward without collision detection. Adapt positioning near viewport edges.
Content, props & slots
Replace the example data, not the template. Data-driven text renders as plain text.
Use b-prop-content to pass the object; a static content="..." attribute is not parsed as JSON.
Optional title overrides content.title.
Items need unique, nonempty string id values that stay stable across updates.
Empty lists are supported.
trigger: custom markup inside the existing trigger button. Include accessible text; do not nest buttons, links, or inputs. Command labels stay data-driven for keyboard typeahead.
Slotted markup uses the parent's state, refs, and handlers. Keep slot templates trusted. Default content is used when no matching slot is supplied.
Handle b-on-action using $event.detail.id and
$event.detail.label. Choosing an item emits the event and closes the menu;
the parent owns content.message and any data changes.
b-prop-* supplies component inputs; b-bind-* updates the host DOM.
To spread inputs, use b-props: its keys override named props regardless of attribute order.
Styling & requirements
Requires Brio 5 core, JavaScript, and native CSS @scope and light-dark() support.
CSS is included in the template; no add-ons or global stylesheet is required. No CSS polyfill is supplied.
Colors follow the document's color-scheme; set it to light dark to follow system preference.
Set the accent prop with b-prop-accent="$data.accent", or bind
--part-accent on the host or a wrapper. The accent prop takes precedence over an inherited
custom property. Native scoped CSS does not block global selectors or inherited styles;
check your app's resets and theme. Each instance has local state and unique generated IDs.
For npm, import onCleanup from
@jnreis/briojs and replace brio.onCleanup in the definition.
Integration & accessibility
Components render on mount; provide fallback markup if content must be available without JavaScript.
Keep templates trusted. Use Brio structural directives for component removal and
app.dispose() before removing the app. With the standard bundle, expressions require CSP
unsafe-eval. The CSP build
rejects #id templates, so pass the template markup as a string in the definition before using a Part there.
See the integration guide for lifecycle and server boundaries.
Test your final labels, contrast, zoom, touch, and screen-reader behavior. These recipes are not an accessibility certification.