---
title: Popover menu
description: 'A menu button whose menu opens beside it, moves with the arrow keys and returns focus on close.'
order: 5
---

import { PrimitiveExample } from '@/components/primitive-example';

export const config = {
	dependencies: {
		components: [PrimitiveExample],
		scripts: ['./popover-menu.script.ts', './popover-menu.element.ts'],
		stylesheets: ['./popover-menu.css'],
	},
};

# Popover menu

<p class="docs-lede">
	A button that opens a menu beside it, walked with the arrow keys and closed with Escape or a press outside.
</p>

## 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](/docs/controllers/popover) shows the panel beside the button and keeps it positioned. With `onDismiss` set, it also reports an outside press, focus leaving and Escape through a [DismissController](/docs/controllers/dismiss) it owns, so the recipe does not add a second one. `portal: false` keeps the panel where it is.
- [RovingController](/docs/controllers/roving) 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.

<PrimitiveExample name="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>
</PrimitiveExample>

```html
<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>
```

```ts
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](/docs/recipes/overview#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.

<PrimitiveExample host>
	<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>
</PrimitiveExample>

```html
<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>
```

```ts
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.

```css
: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;
}
```
