MenuTreeController

Nested submenu coordination: timers, keyboard and popovers.

Reach for it for a menu button or menubar with submenus.

Import

import { MenuTreeController, createMenuTreeController } from '@ecopages/radiant-ui/controllers';

Try it

The submenu is the menu that follows its menuitem. Click Open recent, or focus it and press ArrowRight or Enter, to open it; choosing a leaf item reports it.

Nothing chosen

<div data-example="menu-tree">
	<div role="menu" aria-label="File">
		<button type="button" role="menuitem">New</button>
		<button type="button" role="menuitem">Open recent</button>
		<div role="menu" aria-label="Open recent">
			<button type="button" role="menuitem">Report.md</button>
			<button type="button" role="menuitem">Notes.md</button>
		</div>
	</div>
	<p data-status>Nothing chosen</p>
</div>
import { MenuTreeController } from '@ecopages/radiant-ui/controllers';
 
export function mount(root: HTMLElement): () => void {
	const menu = root.querySelector<HTMLElement>('[role="menu"]')!;
	const status = root.querySelector('[data-status]')!;
 
	const tree = new MenuTreeController({
		getRoot: () => root,
		getRootMenu: () => menu,
		onActivate: (item) => (status.textContent = `Chose ${item.textContent}`),
		onCloseRoot: () => tree.closeAll(),
	});
	tree.attach();
	tree.sync();
 
	menu.addEventListener('click', (event) => tree.handleClick(event));
	menu.addEventListener('keydown', (event) => tree.handleKeydown(event));
	return () => tree.destroy();
}

mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.

Config

OptionDescription
getRootThe element whose descendants hold the menus, read on every event.
getRootMenuThe top-level menu.
onActivateCalled with a leaf item the user activates by click, Enter or Space.
onCloseRootCalled for Escape (true, return focus to the trigger) and Tab (false).

Lifecycle

The controller holds no listener of its own: attach() has nothing to start, and the owner routes events to handlePointerOver, handlePointerOut, handleFocusOut, handleKeydown and handleClick. Call sync() after the menus render to set aria-haspopup, aria-expanded, aria-controls and hidden on the items with a submenu. destroy() closes every open branch and releases its popover and timers; closeAll() closes them on demand.

With a host, createMenuTreeController(host, config) does the same on connect and disconnect; see bindController.

Protected hooks

None. The key map and timers are fixed; behavior that differs belongs in onActivate and onCloseRoot.