Popover menu
A button that opens a menu beside it, walked with the arrow keys and closed with Escape or a press outside.
Goal
An actions menu on a row, a card or a toolbar. Opening moves focus into the menu, the arrow keys move between items, and every way of closing it ends with focus back on the button that opened it when the user did not choose to go elsewhere.
Primitives
- PopoverController shows the panel beside the button and keeps it positioned. With
onDismissset, it also reports an outside press, focus leaving and Escape through a DismissController it owns, so the recipe does not add a second one.portal: falsekeeps the panel where it is. - RovingController gives the menu items one tab stop and moves it with the arrows, Home and End.
Left to the recipe: the open flag, moving focus into the menu on open, and returning focus to the button. The popover reports why it was dismissed and does not restore focus, because that depends on the reason: after Escape or choosing an item the button gets focus, after a press elsewhere the press already moved it.
Example
Open the menu with the button, move with the arrow keys, choose with Enter, or press Escape to close it. The two forms below behave the same.
Plain JavaScript
A mount function wires the controllers to one element and returns the cleanup that undoes it.
Menu closed
<div data-example="popover-menu">
<button type="button" aria-haspopup="menu" aria-expanded="false">Actions</button>
<div role="menu" aria-label="Actions" hidden>
<button type="button" role="menuitem">Rename</button>
<button type="button" role="menuitem">Duplicate</button>
<button type="button" role="menuitem">Delete</button>
</div>
<p data-status aria-live="polite">Menu closed</p>
</div>import { PopoverController, RovingController } from '@ecopages/radiant-ui/controllers';
export function mount(root: HTMLElement): () => void {
const trigger = root.querySelector('button')!;
const menu = root.querySelector<HTMLElement>('[role="menu"]')!;
const status = root.querySelector('[data-status]')!;
const items = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
let open = false;
const popover = new PopoverController({
getAnchor: () => trigger,
getFloating: () => menu,
getOpen: () => open,
getPlacement: () => 'bottom-start',
portal: false,
onDismiss: (reason) => setOpen(false, reason === 'escape'),
});
const roving = new RovingController({
getItems: () => items,
getTarget: () => menu,
orientation: 'vertical',
});
function setOpen(next: boolean, restoreFocus = false): void {
open = next;
popover.sync();
trigger.setAttribute('aria-expanded', String(open));
status.textContent = open ? 'Menu open' : 'Menu closed';
if (open) {
roving.sync();
roving.focus(items[0]);
} else if (restoreFocus) {
trigger.focus();
}
}
popover.attach();
roving.attach();
roving.sync();
trigger.addEventListener('click', () => setOpen(!open, true));
for (const item of items) {
item.addEventListener('click', () => {
setOpen(false, true);
status.textContent = `Chose ${item.textContent}`;
});
}
return () => {
popover.destroy();
roving.destroy();
};
}mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.
Radiant element
The same behavior as a RadiantElement. The markup is authored inside the element and the element finds its parts with @query; there is nothing to mount, clean up or run again.
Menu closed
<recipe-popover-menu>
<button type="button" aria-haspopup="menu" aria-expanded="false">Actions</button>
<div role="menu" aria-label="Actions" hidden>
<button type="button" role="menuitem">Rename</button>
<button type="button" role="menuitem">Duplicate</button>
<button type="button" role="menuitem">Delete</button>
</div>
<p data-status aria-live="polite">Menu closed</p>
</recipe-popover-menu>import { RadiantElement, customElement, onEvent, query } from '@ecopages/radiant';
import { createPopoverController, createRovingController } from '@ecopages/radiant-ui/controllers';
@customElement('recipe-popover-menu')
export class RecipePopoverMenu extends RadiantElement {
@query({ selector: 'button' }) private trigger!: HTMLElement;
@query({ selector: '[role="menu"]' }) private menu!: HTMLElement;
@query({ selector: '[role="menuitem"]', all: true }) private items!: HTMLElement[];
@query({ selector: '[data-status]' }) private status!: HTMLElement;
private open = false;
private readonly menuPopover = createPopoverController(this, {
getAnchor: () => this.trigger,
getFloating: () => this.menu,
getOpen: () => this.open,
getPlacement: () => 'bottom-start',
portal: false,
onDismiss: (reason) => this.setOpen(false, reason === 'escape'),
});
private readonly roving = createRovingController(this, {
getItems: () => this.items,
getTarget: () => this.menu,
orientation: 'vertical',
});
override onConnected(): void {
this.roving.sync();
}
private setOpen(next: boolean, restoreFocus = false): void {
this.open = next;
this.menuPopover.sync();
this.trigger.setAttribute('aria-expanded', String(next));
this.status.textContent = next ? 'Menu open' : 'Menu closed';
if (next) {
this.roving.sync();
this.roving.focus(this.items[0]);
} else if (restoreFocus) {
this.trigger.focus();
}
}
@onEvent({ selector: 'button[aria-haspopup]', type: 'click' })
toggle(): void {
this.setOpen(!this.open, true);
}
@onEvent({ selector: '[role="menuitem"]', type: 'click' })
choose(event: Event): void {
this.setOpen(false, true);
this.status.textContent = `Chose ${(event.target as HTMLElement).textContent}`;
}
}createPopoverController(this, ...) and createRovingController(this, ...) are held in fields because the element calls them when the menu opens; both attach when the element connects and are destroyed when it disconnects, as are the @onEvent listeners. The popover still reports the dismissal reason, so the element decides whether focus returns to the button. sync() goes in onConnected because the items exist only after the element has its children.
What the Radiant form removes: the mount function and its registration, the cleanup to return and call, and the re-run after a client-side navigation.
Styles
The menu hides with hidden and sits in the flow under the button; add position: absolute to float it over what follows.
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) {
flex-direction: column;
align-items: flex-start;
min-height: 13rem;
}
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) [role='menu'] {
min-width: 10rem;
box-shadow: 0 var(--space-1) var(--space-3) color-mix(in srgb, var(--on-surface) 20%, transparent);
}
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) [role='menu'][hidden] {
display: none;
}
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) [role='menuitem'] {
padding: var(--space-2) var(--space-3);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--on-surface-container);
}
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) [role='menuitem']:hover {
background: var(--surface-container-high);
}
:is([data-example='popover-menu'], .primitive-example:has(> recipe-popover-menu)) [role='menuitem']:focus-visible {
background: var(--primary);
color: var(--on-primary);
outline-offset: -2px;
}