Navigation

Menu

<oblyx-menu> is a trigger button that opens a role="menu" popup of actions. For content that needs richer interaction than a list of commands (a form, arbitrary controls), use popover instead.

Basic menu

Rename Duplicate Delete
HTMLKotlin
<oblyx-menu label="Actions">
  <oblyx-menu-item>Rename</oblyx-menu-item>
  <oblyx-menu-item>Duplicate</oblyx-menu-item>
  <oblyx-menu-separator></oblyx-menu-separator>
  <oblyx-menu-item disabled>Delete</oblyx-menu-item>
</oblyx-menu>
oblyxMenu(label = "Actions") {
  oblyxMenuItem { +"Rename" }
  oblyxMenuItem { +"Duplicate" }
  oblyxMenuSeparator()
  oblyxMenuItem(disabled = true) { +"Delete" }
}

Composition

<oblyx-menu-item> and <oblyx-menu-separator> are plain light-DOM children, not a data-driven items array: there's no render-prop mode here, only compound elements the caller composes directly, the same as every other Oblyx family. Neither element holds a reference to its parent menu in either direction: an item resolves "which menu am I in" with this.closest('[role="menu"]') at the moment it's needed (a click, a keypress), not once at connect time. That's what makes a standalone <oblyx-menu-item>, rendered by itself with no <oblyx-menu> around it in an HTMX fragment, degrade to a normal clickable row instead of throwing.

Clicking a disabled item does nothing and never reaches a delegated click listener on an ancestor: a plain custom element gets no platform-level disabled behaviour the way a native <button disabled> does, so this component stops the click itself.

Disabled trigger

The whole menu can be disabled too. The popup can never open while set.

Rename Duplicate
HTMLKotlin
<oblyx-menu label="Actions" disabled>
  <oblyx-menu-item>Rename</oblyx-menu-item>
  <oblyx-menu-item>Duplicate</oblyx-menu-item>
</oblyx-menu>
oblyxMenu(label = "Actions", disabled = true) {
  oblyxMenuItem { +"Rename" }
  oblyxMenuItem { +"Duplicate" }
}

Opening and dismissing

A mouse click on the trigger opens the menu with no item focused. From the keyboard, ArrowDown, Enter or Space open it with the first enabled item focused, and ArrowUp opens it with the last enabled item focused: each is a real, immediate DOM focus move once the popup finishes opening, not aria-activedescendant.

The popup carries popover="auto", so a click anywhere outside it, or Escape, closes it natively with no listener of Oblyx's own for either case. Once focus is on an item, though, Escape and Tab are handled explicitly and differently, and the difference matters:

Selecting an item (click, or Enter/Space) closes the menu and returns focus to the trigger by default too, the same as Escape. A native light-dismiss triggered by clicking some other focusable element on the page does not refocus the trigger: that click already moved focus somewhere the user chose, and forcing it back would fight that too.

Returning focus somewhere other than the trigger

Returning focus to the trigger assumes the trigger is what owned keyboard focus before the menu opened, which is the common case: a toolbar button, a card's own "more actions" trigger. It stops being true once the trigger sits inside something else that owns a roving keyboard cursor of its own, a grid row or list item with its own tabindex="0" and its own :focus-visible styling, the kind of row a keyboard-driven board or table uses instead of native selection. Returning focus to the trigger there buries the cursor one level down, on a button inside the row, and the row itself stops looking focused.

returnFocusTo names the element to focus instead: a CSS selector or a plain element id, resolved fresh at the moment the popup actually closes, never an element reference captured ahead of time. That matters whenever the row is something a fragment swap can replace out from under the menu, an id or selector still finds whichever element currently has that id after a swap; a captured reference would already be pointing at a detached node.

Returning focus to the row a menu was opened from

Escape (or selecting Rename/Duplicate, which leave the row in place) returns focus to task-row #task-42, not to the trigger button nested inside it. Selecting Delete, which removes the row as its own side effect, still resolves the target at close time; when a later swap removes it, focus simply falls through, exactly the behaviour a caller who wants "focus ends up nowhere" after that kind of action wants, with no special case required.

Write the Q3 report Rename Duplicate Delete
HTMLKotlin
<div class="task-row" id="task-42" tabindex="0">
  <span>Write the Q3 report</span>
  <oblyx-menu label="Actions" return-focus-to="task-42">
    <oblyx-menu-item>Rename</oblyx-menu-item>
    <oblyx-menu-item>Duplicate</oblyx-menu-item>
    <oblyx-menu-item>Delete</oblyx-menu-item>
  </oblyx-menu>
</div>
div(classes = "task-row") {
  id = "task-42"
  attributes["tabindex"] = "0"
  span { +"Write the Q3 report" }
  oblyxMenu(label = "Actions", returnFocusTo = "task-42") {
    oblyxMenuItem { +"Rename" }
    oblyxMenuItem { +"Duplicate" }
    oblyxMenuItem { +"Delete" }
  }
}

If return-focus-to names nothing that exists when the popup closes, a typo or a row that was already removed, focus does not fall back to the trigger either: the popup still closes, but nothing new receives focus, and the browser console names the selector that failed to resolve. A silent fallback would make that mistake look like it works until the one time it matters; leaving focus alone makes it visible immediately instead.

Keyboard navigation

With focus on an item: ArrowDown/ArrowUp move to the next/previous enabled item, clamped at the ends rather than wrapping. Home/End jump to the first/last enabled item. Typing any other single printable character starts typeahead: keystrokes accumulate into a buffer for 500ms and jump focus to the first enabled item whose text starts with it, case-insensitively.

Focus

Menu does not trap focus, deliberately, unlike popover. Once open, real DOM focus moves onto each row as the cursor moves between them (roving focus, not aria-activedescendant), and every <oblyx-menu-item> is tabindex="-1" so Tab can never land on one directly: the only way a row receives focus is this component (or a row's own keydown handler) moving it there explicitly. A menu's keyboard model requires Tab to close the menu and hand focus to whatever comes next on the page, which is the opposite of what a focus trap does, so wrapping the same trap bundle oblyx-popover uses here would actively break the required behaviour rather than just being unnecessary.

Before it's opened, the popup renders nothing visible at all: it relies on the same [popover]:not(:popover-open) { display: none } user-agent default every anchored popup in this family uses, with no author-origin display declaration of its own to fight it.

Positioning

Placement is CSS anchor positioning, the same mechanism popover and tooltip use: no JavaScript measurement, no positioning library. In a browser without it, the menu still opens, closes and responds to every keyboard interaction on this page correctly; only its position relative to the trigger degrades, to a fixed default location rather than anchored below it.

Reference

<oblyx-menu>

AttributeTypeDefaultDescription
labelstring | undefinedTrigger button text, also used as its accessible name, since a menu trigger has no separate label element the way a form control does. Required; omitting it is a defect and is flagged loudly rather than silently rendering an unnamed button.
disabledbooleanfalseDisables the trigger. The popup can never open while set.
return-focus-tostring | undefinedWhere to send focus when the popup closes after Escape or after selecting an item, instead of the trigger button. A CSS selector or a plain element id (tried first, so an id that happens to contain characters CSS would otherwise need escaping still works), never an element reference, and resolved fresh at the moment the popup actually closes, not cached. That matters for a trigger swapped in and out of the page by something like an HTMX fragment update: an id or selector still finds the current element after a swap, while a reference captured earlier would point at a detached node. The default, unset, sends focus to the trigger, unchanged from before this property existed: the WAI-ARIA APG menu-button pattern, which assumes the trigger owns keyboard focus. Set this when it doesn't, when the trigger lives inside a larger composite that owns its own roving focus, a grid row or list item with tabindex="0" and its own :focus-visible styling, say. Closing the menu should return focus to that composite, not bury it one level down on a button inside it. If the selector matches nothing when the popup closes, focus does NOT fall back to the trigger. A silent fallback would make a stale or mistyped selector look like it works, focus lands somewhere plausible, right up until the one time it matters. Instead, the popup still closes, but nothing new receives focus, which makes the miss visible immediately, a lost focus indicator, on top of a console error naming the selector that failed to resolve, rather than quietly papering over it. This also gives selecting an item the behavior a caller who removes the target as a side effect of that selection wants without any special case, deleting the row the menu was opened from, say: the target still exists at the instant the popup closes, so it receives focus normally, and only stops existing once whatever swap removes it runs. After that, focus falls through to the document by the same rule the platform already applies to any focused element that gets detached.

<oblyx-menu-item>

AttributeTypeDefaultDescription
disabledbooleanfalseSkipped by focus navigation, typeahead, and click activation. Same contract as oblyx-option[disabled].

<oblyx-menu-separator>

This element declares no attributes.