v1.0.0

<bmx-menu>

A menu of commands: the one that drops from a button, the one that cascades into submenus, and the one that appears where the pointer was right-clicked. All three are the same component, because they are the same widget with a different way of being opened - and a library that ships them as three grows three subtly different keyboards.

10 properties · 2 events · 3 methods · 4 parts

Example

File A menu, a button, and one attribute joining them. Arrow keys move, letters search, Escape closes.
Right-click anywhere in here
View Sorted by name. Hidden files off.

Or written as markup, with no script at all:

Edit
Show markup
<div class="row">
  <bmx-button id="ex-menu-file" haspopup="menu">File</bmx-button>
  <bmx-menu id="ex-menu-1" trigger="#ex-menu-file" label="File"></bmx-menu>
  <span class="note">A menu, a button, and one attribute joining them. Arrow keys move, letters search, Escape closes.</span>
</div>

<div class="row">
  <div
    id="ex-menu-canvas"
    style="
      display: grid;
      place-items: center;
      min-block-size: 7rem;
      border: 1px dashed var(--bmx-border);
      border-radius: var(--bmx-radius-lg);
      color: var(--bmx-text-muted);
    "
  >
    Right-click anywhere in here
  </div>
  <bmx-menu id="ex-menu-context" context="#ex-menu-canvas" label="Canvas actions"></bmx-menu>
</div>

<div class="row">
  <bmx-button id="ex-menu-view" haspopup="menu" variant="outline" tone="neutral">View</bmx-button>
  <bmx-menu id="ex-menu-checks" trigger="#ex-menu-view" label="View"></bmx-menu>
  <span class="note" id="ex-menu-state">Sorted by name. Hidden files off.</span>
</div>

<div class="row">
  <p style="margin: 0">Or written as markup, with no script at all:</p>
</div>
<div class="row">
  <bmx-button id="ex-menu-edit" haspopup="menu" variant="soft" tone="neutral">Edit</bmx-button>
  <bmx-menu trigger="#ex-menu-edit" label="Edit">
    <bmx-menu-item value="undo" label="Undo" shortcut="⌘Z"></bmx-menu-item>
    <bmx-menu-item value="redo" label="Redo" shortcut="⇧⌘Z" disabled></bmx-menu-item>
    <bmx-menu-item separator></bmx-menu-item>
    <bmx-menu-item value="cut" label="Cut" shortcut="⌘X"></bmx-menu-item>
    <bmx-menu-item value="copy" label="Copy" shortcut="⌘C"></bmx-menu-item>
    <bmx-menu-item label="Paste special">
      <bmx-menu-item value="paste-text" label="Text only"></bmx-menu-item>
      <bmx-menu-item value="paste-values" label="Values only"></bmx-menu-item>
    </bmx-menu-item>
  </bmx-menu>
</div>

<script type="module">
  await customElements.whenDefined('bmx-menu');

  // `items` is an array, so it is a property rather than an attribute. A menu
  // written by hand can use `<bmx-menu-item>` children instead - see the last
  // example above - but anything built from data belongs here.
  document.getElementById('ex-menu-1').items = [
    { id: 'new', label: 'New', shortcut: '⌘N' },
    {
      id: 'recent',
      label: 'Open recent',
      items: [
        { id: 'accounts', label: 'Accounts.xlsx', description: 'Edited yesterday' },
        { id: 'budget', label: 'Budget.xlsx', description: 'Edited last week' },
        { separator: true },
        { id: 'clear', label: 'Clear the list', danger: true },
      ],
    },
    { separator: true },
    { id: 'print', label: 'Print', shortcut: '⌘P', disabled: true },
    { id: 'close', label: 'Close without saving', danger: true },
  ];

  document.getElementById('ex-menu-context').items = [
    { id: 'zoom-in', label: 'Zoom in', shortcut: '⌘+' },
    { id: 'zoom-out', label: 'Zoom out', shortcut: '⌘−' },
    { separator: true },
    { id: 'reset', label: 'Reset the view' },
  ];

  /*
   * Checkable items report the choice; they do not change themselves. The items
   * array belongs to the application - one rendered in two places must not
   * change under the other one - so `applyCheck` is exported for you to apply
   * the choice, with the radio rule already right.
   */
  const view = document.getElementById('ex-menu-checks');
  const readout = document.getElementById('ex-menu-state');

  view.items = [
    { id: 'hidden', label: 'Show hidden files', kind: 'checkbox', checked: false },
    { separator: true },
    { id: 'name', label: 'Sort by name', kind: 'radio', group: 'sort', checked: true },
    { id: 'date', label: 'Sort by date', kind: 'radio', group: 'sort' },
    { id: 'size', label: 'Sort by size', kind: 'radio', group: 'sort' },
  ];

  view.addEventListener('bmxSelect', event => {
    const chosen = event.detail.index;
    const items = view.items.map(item => ({ ...item }));
    const target = items[chosen];

    if (target.kind === 'radio') {
      items.forEach((item, index) => {
        if (item.kind === 'radio' && item.group === target.group) {
          item.checked = index === chosen;
        }
      });
    } else if (target.kind === 'checkbox') {
      target.checked = !target.checked;
    }

    view.items = items;

    const sort = items.find(item => item.group === 'sort' && item.checked);

    readout.textContent = `Sorted by ${sort.label.replace('Sort by ', '')}. Hidden files ${items[0].checked ? 'on' : 'off'}.`;
  });
</script>

HOW IT IS OPENED

Three ways, in the order they cost the author effort:

  • trigger names an element. The menu wires that element's aria-haspopup, aria-expanded and aria-controls, opens on a press, opens at the bottom of the list on ArrowUp, and closes again on a second press. This is the whole widget in one attribute, and it needs no script.
  • context names an element. A right-click anywhere inside it opens the menu at the pointer instead of against a box.
  • open is set, or openMenu() is called. The composing component decides, which is what bmx-split-button does - it already owns a trigger and does not want a second opinion about it.

WHY EACH LEVEL IS ITS OWN SURFACE

A cascading menu could be drawn as nested boxes inside one element. It should not be: the root has overflow-y: auto so a long menu can scroll, and an overflow scroller clips its own descendants - so the first submenu on a long menu would be cut off at the parent's edge. Each level is therefore its own element in the top layer, positioned against the item that opened it, which also means each gets the flipping and viewport clamping that BmxOverlaySurface already does.

The cost is that "outside" now means outside all of them, which is why the overlay engine grew a related option for this component.

WHAT MAKES IT USABLE RATHER THAN MERELY CORRECT

The safe triangle, in src/core/menu.ts. A submenu opens beside its parent item, so reaching it means moving the pointer diagonally across the items below - and a menu that closes on mouseleave snaps shut halfway through every attempt. Here the pointer's direction of travel is tested against the open submenu's near edge, so a pointer heading for the submenu keeps it, and a pointer heading anywhere else loses it at once. It is the difference between a cascading menu people use and one they learn to avoid.

ACCESSIBILITY

role="menu" with menuitem, menuitemcheckbox and menuitemradio children and a roving tabindex; focus moves into the menu on open and back to whatever opened it on close; a parent item carries aria-haspopup="menu" and aria-expanded; typeahead follows the platform, including Space joining a search already in progress rather than firing the item under it. RTL mirrors the arrows and the side the submenus open on.

Properties

PropertyAttributeTypeDefaultDescription
anchor anchor string | HTMLElement What the root surface is placed against. Defaults to the trigger. Separate from trigger because the two answer different questions. A bmx-split-button wires the ARIA to its chevron half and anchors the menu to both halves, so that bottom-start lines up with the primary action's leading edge rather than hanging a wide menu off a narrow button.
context context string | HTMLElement Right-clicking inside this element opens the menu at the pointer.
items property only BmxMenuEntry[] [] The menu's entries. An array, or the JSON spelling of one. Anything with a wrapper - React, Angular, Vue - assigns the array; markup that can only write attributes (Razor, Blazor's markup, a server-side template, an htmx swap) writes the JSON, and it is read once into the list it describes. Mutable for that reason alone: everything below reads a list.
label label string 'Menu' The menu's accessible name.
menuId menu-id string The id given to the root surface. Only needed by a component that owns its own trigger and has to point that trigger's aria-controls at a surface it does not render itself. Left alone, the menu generates one.
open open boolean false Whether the menu is showing. Mutable, so the component can close itself when something is chosen. A consumer driving it may treat it as controlled and watch bmxOpenChange.
placement placement BmxPlacement 'bottom-start' Which side the root opens on when there is room.
returnFocus return-focus boolean true Whether closing returns focus to whatever opened the menu.
returnFocusTo return-focus-to string | HTMLElement Where focus goes when the menu closes, as a CSS selector or an element. Defaults to the trigger, and failing that the anchor - which is right for every menu that opens itself. It is the composition case that needs this: bmx-split-button anchors the menu to both of its halves so the list lines up with the primary action's leading edge, but focus belongs on the chevron the user actually pressed. A selector is resolved against the menu's own root, so a composing component can name something inside its own shadow tree without waiting for a ref to exist.
trigger trigger string | HTMLElement The element that opens this menu, as a CSS selector or an element. Given one, the menu takes over its ARIA and its keyboard entirely. Omit it when the composing component already owns a trigger of its own.

Events

EventDetailDescription
bmxOpenChange boolean Fired when the menu opens or closes.
bmxSelect BmxMenuSelectDetail Fired when an item is chosen.

Methods

MethodSignatureDescription
closeMenu closeMenu() => Promise<void> Close the menu. Focus returns to whatever opened it.
openAt openAt(x: number, y: number) => Promise<void> Open the menu at a point in the viewport, as a context menu.
openMenu openMenu(atEnd?: boolean) => Promise<void> Open the menu against its anchor and move focus into it.

Slots

SlotDescription
(default) The default slot

CSS shadow parts

PartDescription
item A menu item.
menu The root surface.
separator A divider between items.
submenu A cascaded surface. Also matches menu for styling.

CSS custom properties

PropertyDescription
--bmx-menu-item-padding Space inside an item.
--bmx-menu-max-height Tallest a surface grows before it scrolls.
--bmx-menu-max-width Largest width a surface takes before its labels wrap.
--bmx-menu-min-width Smallest width a surface takes. Default 12rem.
--bmx-menu-padding Space between a surface's edge and its items.
--bmx-menu-radius Corner radius of a surface.