Actions & Overlays
Menu
A menubar with nested submenus.
hydrateless/menu.cssA menubar or navigation menu with nested submenus, following the WAI-ARIA menubar pattern. Without JavaScript the stylesheet reveals submenus on hover and :focus-within. The enhancer marks the root data-hl-ready, takes over visibility, and wires a roving tabindex, orientation-aware arrow navigation, typeahead, checkable items, and disabled skipping. Submenus are promoted to native popovers so they render in the top layer, positioned against their trigger with CSS anchor positioning (with a JS fallback). Submenus nest to any depth: an item followed by a sibling data-hl-submenu opens it with Right (Left in RTL), and Left or Esc steps back out. The open top-level submenu is the menu's value.
Menubar
Arrow keys move between items; Enter, Space, or Down opens a submenu and Esc closes it. Each top-level trigger's data-hl-value is the value reported while its submenu is open.
JavaScript
This component ships an optional enhancer. With auto-init it loads automatically; to wire it yourself, import it directly:
import { enhanceMenu } from '@hydrateless/enhancers/menu';
const handle = enhanceMenu(document);
// handle.api -> imperative controls
// handle.destroy() -> tear everything downProps
The framework bindings expose these props (and forward the rest to the root element).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | Not set | Controlled open submenu (`null` when all are closed); pair with `onValueChange` (Vue: `v-model`, Svelte: `bind:value`). |
defaultValue | string | null | null | Submenu to open on mount, uncontrolled. |
onValueChange | (value: string | null) => void | Not set | Called with the open submenu value (or `null`) after every change. |
onSelect | (value: string, item: HTMLElement, checked?: boolean) => void | Not set | Called with the item value when a leaf item is activated. For checkable items, `checked` is the new state. |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Direction of the top-level items. Vertical menus open submenus to the side. |
MenuSubmenu.label | string | Not set | Text of the top-level trigger that opens the submenu. Vue and Svelte also accept a `trigger` slot/snippet for rich content; React accepts any node. |
MenuSubmenu.value | string | Not set | Value reported by `onValueChange` while the submenu is open. Defaults to the top-level index. |
MenuItem.value | string | Not set | Value reported by `onSelect` when the leaf item is activated. Defaults to the item text. |
MenuItem.href | string | Not set | Render the item as a link instead of a button. |
MenuItem.role | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'menuitem' | Plain action, toggle, or single-select item. Pair checkable roles with `checked`. |
MenuItem.disabled | boolean | false | Skipped by arrow navigation and typeahead; cannot be activated. |
Events and callbacks
| Event / Callback | Payload | Description |
|---|---|---|
hl:change | { value: string | null } | Fires when a submenu opens or closes, with the open submenu value (also `onValueChange`). |
hl:select | { value: string, item: HTMLElement, checked?: boolean } | Fires when a leaf item is activated. Cancelable. |
CSS variables
Override these on any ancestor to theme the component. See the theme studio to preview changes live.
| CSS variable | Used for |
|---|---|
--hl-surface | Submenu background. |
--hl-border | Submenu border. |
--hl-overlay-shadow | Submenu elevation. |
Accessibility
- A roving tabindex keeps the menubar a single tab stop.
- Submenu triggers expose
aria-haspopup="menu",aria-expanded, andaria-controls, at every level of nesting. - Before the enhancer runs, submenus open on hover and
:focus-within, so the navigation is usable with no JavaScript. - Disabled items are skipped;
menuitemcheckboxandmenuitemradiokeeparia-checkedin sync.