Primitives
36

Menu

A list of commands, anchored to whatever opened it. The third thing that hangs off place(), and the difference from the other two is what the reader does with it: a tooltip is read, a popover is worked in, a menu is fired from and then gone — so every row dismisses it. It is not a Select either: a select reports a value that stays chosen and is announced as such, a menu fires and reports nothing, which is why no row here is ever aria-selected and the list carries no tick. Focus is the other half of that difference. A select keeps focus on its trigger and points aria-activedescendant at the row from there; a menu moves focus into the list, and Tab closes it rather than cycling inside — a menu you cannot leave forwards is a trap, not a menu. Pass point and it opens at a pair of coordinates instead of beside the trigger, which is what a context menu is.

plain markup — no library on the page
rendered by aura-react
rendered by aura-react
rendered by aura-react
rendered by aura-react

Code

index.html
<button type="button" class="aura-btn aura-btn--secondary"
        aria-haspopup="menu" aria-expanded="true" aria-controls="m1-menu">Actions</button>

<div data-state="open" class="aura-popup aura-menu">
  <ul id="m1-menu" role="menu" aria-label="Actions" aria-activedescendant="m1-item-0" tabindex="-1"
      class="aura-listbox aura-scroll">
    <li id="m1-item-0" role="menuitem" data-active="true" class="aura-listbox__option">
      <span class="aura-listbox__label">Open</span>
    </li>
    <li id="m1-item-1" role="menuitem" data-active="false" class="aura-listbox__option">
      <span class="aura-listbox__label">Rename<span class="aura-listbox__description">Give it another name</span></span>
    </li>
    <li role="separator" class="aura-listbox__separator"></li>
    <li id="m1-item-2" role="menuitem" data-active="false" class="aura-listbox__option aura-listbox__option--danger">
      <span class="aura-listbox__label">Delete</span>
    </li>
  </ul>
</div>
Demo.tsx
import { Button, Menu, type MenuItem } from '@ilomee/aura-react';

const items: MenuItem[] = [
  { value: 'open', label: 'Open' },
  { value: 'rename', label: 'Rename', description: 'Give it another name' },
  { value: 'duplicate', label: 'Duplicate' },
  { value: 'archive', label: 'Archive', disabled: true },
  { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
];

/**
 * Click the trigger, then use the arrow keys. Focus moves into the list, the cursor is virtual —
 * one focused element with `aria-activedescendant` pointing at the row — and typing a letter jumps
 * to the row that starts with it. Enter runs the row, Escape closes without running anything, and
 * Tab leaves the menu entirely rather than cycling inside it.
 *
 * The disabled row is stepped over by the arrows rather than parked on, and the destructive one is
 * ruled off from the rest.
 */
export default function Demo() {
  return (
    <Menu items={items} label="Actions" onSelect={(value) => console.log(value)}>
      <Button variant="secondary">Actions</Button>
    </Menu>
  );
}
Demo.vue
<script setup lang="ts">
import { Menu, Button } from '@ilomee/aura-vue';

const items = [
  { value: 'open', label: 'Open' },
  { value: 'rename', label: 'Rename', description: 'Give it another name' },
  { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
];
</script>

<template>
  <Menu :items="items" label="Actions" @select="run">
    <Button variant="secondary">Actions</Button>
  </Menu>
</template>
Demo.svelte
<script lang="ts">
  import { Menu } from '@ilomee/aura-svelte';

  const items = [
    { value: 'open', label: 'Open' },
    { value: 'delete', label: 'Delete', danger: true, separatorBefore: true },
  ];
</script>

<Menu {items} label="Actions" onselect={run}>
  {#snippet trigger(props)}
    <button type="button" class="aura-btn aura-btn--secondary" {...props}>Actions</button>
  {/snippet}
</Menu>

Used as [auraMenu] — a directive you put on your own element.

demo.component.ts
<button auraButton variant="secondary" [auraMenu]="items" label="Actions" (select)="run($event)">
  Actions
</button>

The panel is built with Renderer2 rather than a template: the content is data, not markup the caller wrote, so a component instantiated into the body would put its own host element where the other three emit a <div class="aura-popup">. The directive removes its own auraMenu attribute in ngOnInit, as auraPopover does. There is no per-row template — the row is a label and a description, and a template input for two strings is API nobody asked for.

Props

Plain markup has no props — the classes below are the whole API. The four packages do nothing more than set these same classes for you.
NameTypeDefaultNotes
itemsMenuItem[] | nullThe commands. value identifies one to the handler, label is what it says; description, disabled, danger, group and separatorBefore are the rest of a row. Nullable renders an empty list rather than throwing.
childrenReactElementThe control that opens it. Cloned, not wrapped. Optional: with point there is no trigger at all.
labelstringThe menu’s accessible name. role="menu" without one is announced as an unlabelled group.
opentwo-wayboolean
defaultOpenbooleanfalse
onOpenChangeevent(open: boolean) => void
onSelectevent(value: string) => voidThe row that was run. The menu closes on its own — that is what makes it a menu.
point{ x: number; y: number } | nullOpen at these viewport coordinates instead of beside the trigger — clientX/clientY, not page offsets.
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | stringThe panel’s width — pixels or any CSS length. Left out, it is as wide as its widest row, floored at 200px and capped at 320px. It is never the trigger’s width: a menu is routinely opened from something narrower than itself, and at a point there is no trigger to measure.
renderItem(item, state) => ReactNodeReplaces a row’s contents — never its <li>.
NameTypeDefaultNotes
itemsMenuItem[] | null
defaultslotslotThe trigger. Exactly one element, cloned. Optional with point.
itemslotslotReplaces a row’s contents. Handed { item, index, active, disabled }.
labelstring
opentwo-wayboolean
update:openevent(open: boolean) => void
selectevent(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string
NameTypeDefaultNotes
itemsAuraMenuItem[] | null
triggerSnippet<[TriggerProps]>Handed the props to spread onto the control. Optional with point.
itemSnippet<[item, state]>Replaces a row’s contents.
labelstring
opentwo-wayboolean
onselectevent(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string
NameTypeDefaultNotes
auraMenuAuraMenuItem[] | nullThe commands, named after the selector so the binding reads on the trigger.
labelstring
opentwo-wayboolean
openChangeevent(open: boolean) => void
selectevent(value: string) => void
point{ x: number; y: number } | null
side'top' | 'right' | 'bottom' | 'left''bottom'
align'start' | 'center' | 'end''start'
emptyLabelstring'No actions'
widthnumber | string

Emitted classes

Every framework emits the same classes — that is what makes the four libraries look identical. You can use them directly, without any component library.

ClassRoleNotes
.aura-popupbaseRequired. A modifier does nothing without it.
.aura-menubaseRequired. A modifier does nothing without it.
.aura-listboxbaseRequired. A modifier does nothing without it.
.aura-listbox__optionelement
.aura-listbox__separatorelement

Tokens and rules

components.css
layer
var(--z-dropdown) — under a modal, on purpose
width
its rows, floored at 200px and capped at 320px — or var(--menu-w)
height
min(320px, 60vh), then it scrolls — .aura-listbox’s own
destructive row
var(--danger), inverted under the cursor