<!-- GENERATED by scripts/build-llms.mjs from llms/layout.md — do not edit this file. -->

# `lr-menu`

- **Import** `import '@aceshooting/lyra-ui/components/lr-menu.js';` (stable tag alias; registers the tag)
- **Class** `LyraMenu`, also available unregistered from `@aceshooting/lyra-ui/components/layout/menu/menu.class.js`
- **Family** `components/layout/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 3 parts, 7 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Documented with** `lr-menu-item`, `lr-dropdown-item` (same section below)
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-menu` / `lr-menu-item`

The inline semantic menu mapped from Shoelace's `sl-menu`, plus its action-row element. A root
`<lr-menu>` is always visible and owns the named `role="menu"` list, real roving DOM focus,
wrapping keyboard navigation, type-ahead, and one canonical selection event. It deliberately has no
trigger, positioned popup, root open state, placement API, or overlay lifecycle.

Removing or disabling the remembered active item repairs the roving stop without taking focus from
outside controls or the menu's header/footer. If the changed item held actual focus, focus moves to
a valid survivor or through the existing owner dismissal path when none remain.

For a menu button or other anchored overlay, compose the semantic controller inside
`<lr-dropdown>`:

```html
<lr-dropdown label="Row actions">
  <button slot="trigger" aria-label="Row actions">⋮</button>
  <lr-menu>
    <lr-menu-item value="edit">Edit</lr-menu-item>
    <lr-menu-item value="delete" variant="danger">Delete</lr-menu-item>
  </lr-menu>
</lr-dropdown>
```

The dropdown shell owns trigger relationships, positioning, opening/closing, outside dismissal,
focus return, and lifecycle events. The contained menu remains the sole semantic owner: its
`header`/default/`footer` regions and named `role="menu"` list are preserved, while the outer popup
is neutral. This is also the composition used for direct `<lr-dropdown-item>` children.

### `lr-menu`

**Properties:**

- `label?: string` — accessible name for the `role="menu"` list. A host `aria-label`
  attribute is authoritative, including `aria-label=""`; any supplied `label` follows (including `"Menu"` or
  `""`),
  then a containing dropdown's supplied fallback, then the localized menu label. Omission,
  not comparison with an English sentinel, is what selects localization.
- `dropdownOpen: boolean = false` (attribute: false) — the containing dropdown's controlled open
  state, used to synchronize the menu interaction engine while it is rendered inside a popup.

**Events:**

- `lr-select` — cancelable, with `detail: { item }`. It originates exactly once at the menu that
  owns the activated item, then bubbles unchanged through ancestor menus and a containing dropdown.
  Preventing it anywhere keeps the current menu/submenu chain open. There are no
  `lr-menu-select`, `lr-menu-item-select`, or nested-selection aliases.

**Slots:** default (`<lr-menu-item>`/`<lr-dropdown-item>` plus semantic separators), `header`, and
`footer`. Header and footer are composed controls or explanatory regions outside the
`role="menu"` list, so filters, counts, or footer actions do not violate the menu required-child
contract. Arbitrary non-item content in the default slot still renders, but is not enrolled as a
menu item.

**CSS parts:** `header`, `list`, and `footer`. Root `trigger` and `popup` parts do not exist;
style those on `<lr-dropdown>` when using the overlay composition.

**Themeable custom properties:** the standalone menu surface and a submenu's own surface paint from
the **shared overlay-surface family** (16.0.0) — `--lr-overlay-surface` (default
`var(--lr-color-surface-overlay)`), `--lr-overlay-border` (default `var(--lr-color-border)`),
`--lr-overlay-radius` (default `var(--lr-radius)`) and, on the submenu only,
`--lr-overlay-shadow-anchored` (default `var(--lr-shadow-m)`). None is declared on `:host`, so one
declaration on `:root` (or on any ancestor, to scope it) retints this menu together with every other
floating surface. A menu contained by `<lr-dropdown>` paints no surface of its own, so the family has
no effect there — the dropdown's popup carries it. The header/footer dividing rules and a slotted
`<hr>` deliberately stay on `--lr-color-border`: they separate content rather than draw the
surface's edge. Otherwise shared spacing and motion tokens. Row chrome is controlled through the
menu-item properties listed below.

Width is a pair, applied to the standalone surface and to a submenu's own surface alike:
`--lr-menu-max-inline-size` (default `var(--lr-size-20rem)`) and `--lr-menu-min-inline-size`
(default `var(--lr-size-10rem)`). They move together — the floor wins over the ceiling, so capping
alone cannot take a menu below 10rem. Neither is declared on `:host`, so an ancestor theme wrapper's
value reaches the menu. The ceiling takes a length or a percentage; `100%` and `none` both uncap it
to the container, and any other value outside `<length-percentage>` is treated as `none` rather than
dropping the cap's safety terms. Those terms — the shared `--lr-popover-viewport-clamp` and the
container allocation — are applied outside the name, so no value of the hook can make a menu wider
than its container or the viewport. (That guarantee is enforced by a registered custom property, so
an out-of-syntax value falls back cleanly instead of invalidating the whole declaration; an engine
without `CSS.registerProperty` degrades to "use `100%`, not `none`".) A menu contained by
`<lr-dropdown>` sizes from its dropdown and is unaffected by both names.

**`--lr-positioning-strategy`** (16.0.0) — the private submenu surface reads this same cascading
`absolute`/`fixed` override documented on `<lr-popover>` when it is (re)positioned, falling back to
its own `fixed` default when nothing is set. There is no per-instance `positioning-strategy`
property on `<lr-menu>`; set the custom property on `:root`, a theme, or one clipping ancestor to
change every unset submenu beneath it. A menu contained by `<lr-dropdown>` is positioned by the
dropdown instead and is unaffected.

**Methods:** no menu-specific public overlay methods. Use `<lr-dropdown>`'s `show()`/`hide()` and
`open` state for an overlay. Menu-item submenu methods remain public because they drive a row's
nested disclosure.

**Keyboard and focus:** exactly one navigable row has `tabindex="0"`. ArrowDown/ArrowUp wrap;
Home/End move to the first/last navigable row; Enter/Space activate; printable input performs
locale-aware type-ahead. Rows that are disabled, loading, hidden, `aria-hidden`, inert, or inside an
inert subtree are skipped. Navigation is repaired live when those states or light-DOM membership
change. A root inline menu does not consume Escape or turn Tab into overlay dismissal; a containing
dropdown owns those root-level behaviors.

### `lr-menu-item`

A focusable action row owned by `<lr-menu>`. The host itself carries `role="menuitem"` (or
`menuitemcheckbox`/`menuitemradio`) and roving `tabindex`; `[part="base"]` is only the visual row.

**Properties:**

- `value: string = ''` — identifier available as `event.detail.item.value` on `lr-select`
- `size: '2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'small' | 'medium' | 'large' = 'm'`
- `disabled: boolean = false`
- `variant: 'default' | 'danger' = 'default'`
- `type: 'normal' | 'checkbox' | 'radio' = 'normal'`
- `checked: boolean = false` — meaningful only for `type="checkbox"`/`type="radio"`
- `group?: string` — narrows a `type="radio"` item's exclusive-choice scope to only the other
  radio items sharing this same string. Unset, the scope is every `type="radio"` item the same
  owning `<lr-menu>` owns directly — a nested submenu's radio items belong to that submenu's own
  `<lr-menu>` instead, so they're never in scope regardless of `group`. Meaningless for
  `type="normal"`/`"checkbox"`
- `loading: boolean = false`
- `href?: string` — when set to a safe link URL (`http:`/`https:`/`blob:`/`mailto:`/relative; see
  `safeLinkHref`, or `safeDownloadHref` when `download` is set, which drops `mailto:`),
  `[part="base"]` renders as a real `<a href=…>` instead of a `<span>`, and activation (click, or
  the owning menu's Enter/Space handling, which forwards through `click()` for a link item so the
  anchor's own native default action runs) navigates there in addition to firing the usual
  `select()`/`lr-select` contract. An unsafe/unparseable value falls back to the plain `<span>`,
  matching `lr-button`'s identical fallback
- `target?: string` — native anchor `target`, used only while `href` resolves to a link. Setting it
  (e.g. `'_blank'`) always force-adds `noopener noreferrer` to the rendered anchor's `rel`,
  matching `lr-button`'s identical pattern
- `rel?: string` — independently settable author relationship tokens, no default. Author tokens are
  merged rather than replaced: `opener` is always stripped, and whenever `target` is set the
  non-removable `noopener noreferrer` floor is added, so a same-tab link (no `target`) keeps
  exactly the author's tokens while a link opening a new context can never lose the guard
- `download?: string` — native anchor `download` attribute, used only while `href` resolves to a
  link. Presence narrows the safe-URL allowlist to `safeDownloadHref`'s, which drops `mailto:` — a
  mail handoff names no retrievable bytes, so it cannot be a download target
- `hasSubmenu: boolean` (read-only)
- `submenuOpen: boolean = false` — transient live state; assigning it drives an existing submenu
  without moving focus and disconnect resets it

**Methods:**

- `click(): void` forwards programmatic activation through the visual-row path
- `select(): void` activates through the current owning menu; it is inert while disabled/loading
- `openSubmenu(focus: 'first' | 'last' | 'none' = 'first'): Promise<void>`
- `closeSubmenu(): Promise<void>`
- `getTextLabel(): string` returns the accessibility-visible label used by type-ahead

The computed name is derived from the row's own visible label and does not depend on whether the
menu is currently displayed, so a row inside a closed dropdown — whose popup is `visibility:
hidden` — is named the same as an open one, and `getTextLabel()` drives type-ahead either way.
A row with no label text carries **no** `aria-label` attribute rather than an empty one, so the
browser falls back to the row's own content; an `aria-label` or `aria-labelledby` you set yourself,
including an explicitly empty value, still wins.

A checkbox activation first emits cancelable `lr-menu-item-change` with the proposed
`detail: { value, checked }`. Preventing that event retains the current checked state; the owning
menu's canonical `lr-select` still follows. A submenu parent is a disclosure instead of an action:
activation opens its submenu and emits neither checkbox-change nor selection.

A `type="radio"` item works the same way, with exclusive-choice semantics layered on top:
activating an already-checked radio is a no-op on `checked` — no `lr-menu-item-change`, no state
change, matching native `<input type="radio">` — but still falls through to the owning menu's
usual selection. Activating an unchecked radio fires `lr-menu-item-change` with
`checked: true`; once not prevented, this item becomes `checked` and every other `type="radio"`
item the same owning `<lr-menu>` owns directly whose `group` matches is unchecked directly
(without an `lr-menu-item-change` of its own).

**Events:**

- `lr-menu-item-change` — cancelable checkbox/radio-state proposal; never fired when activating an
  already-checked radio
- `lr-menu-item-state-change` — internal navigation repair signal with
  `detail: { disabled, hidden, inert }`; the owning menu consumes and contains it, so it does not
  escape a menu or a composite wrapper as an apparent public event

Item activation itself is private owner plumbing, not a public child event. Listen for `lr-select`
on the owning menu.

**Slots:** default label, `icon`, `prefix`, `details`, `suffix`, and `submenu`. Display slots are
inert visual content; their accessibility-visible default-slot text names the focusable host. The
submenu slot accepts either one nested `<lr-menu>` or direct mapped items. Long `details` and
`suffix` content shrinks and ellipsizes within the allocated row in both directions rather than
expanding the popup.

**CSS parts:** `base`, `icon`, `prefix`, `label`, `details`, `suffix`, `checkmark`,
`checked-icon`, `spinner`, `spinner__base`, `submenu-icon`, and `submenu`.

**Themeable custom properties:** `--lr-menu-item-gap`, `--lr-menu-item-radius`,
`--lr-menu-item-danger-color`, `--lr-menu-item-danger-hover-bg`,
`--lr-menu-item-danger-active-bg`, `--lr-menu-item-checked-bg` (default `transparent`),
`--lr-menu-item-checked-color` (default `inherit`), `--lr-menu-item-checked-font-weight` (default
`inherit`), and `--submenu-offset`, plus shared size/focus/color/spacing tokens. The checked hooks
apply to a `type="checkbox" checked` or `type="radio" checked` row's `[part="base"]`, matching the
checked/selected-state hooks `<lr-option>`, `<lr-select>`, `<lr-combobox>`, and `<lr-tree-item>`
already expose; unset, a checked row paints identically to an unchecked one.

Four more row-chrome hooks land in 16.0.0, each an inline fallback so unset rendering is
byte-identical: `--lr-menu-item-hover-bg` (default `var(--lr-color-brand-quiet)`) is the enabled
row's fill under the pointer, and the pressed state mixes from that same value, so a retuned hover
fill keeps its pressed step instead of snapping back to the brand default; `--lr-menu-item-active-bg`
(default `color-mix(in oklab, var(--lr-menu-item-hover-bg, var(--lr-color-brand-quiet)),
var(--lr-color-mix-partner) var(--lr-color-mix-active))`) overrides that pressed fill directly,
matching `--lr-option-active-bg`'s equivalent hook — unset, the pressed row keeps mixing from
`--lr-menu-item-hover-bg` exactly as before this hook existed; `--lr-menu-item-icon-color` (default
`inherit`) recolours `[part="icon"]` without touching the label beside it, so it still follows the
row while the row is disabled or `variant="danger"` unless you say otherwise; and
`--lr-menu-item-min-height` (default `max(var(--lr-form-control-height), var(--lr-size-24px))`)
sets the row's minimum block size for a denser or roomier menu, replacing a `::part(base)` rule per
item. A value below the 24px floor is your call, exactly as it is when overriding the shared ladder
itself.

### Nested submenus

Both supported authoring shapes use the `submenu` slot:

```html
<lr-menu label="Share actions">
  <lr-menu-item value="share">
    Share
    <lr-menu slot="submenu" label="Share options">
      <lr-menu-item value="email">Email</lr-menu-item>
      <lr-menu-item value="link">Copy link</lr-menu-item>
    </lr-menu>
  </lr-menu-item>
</lr-menu>
```

```html
<lr-dropdown-item>
  Share
  <lr-dropdown-item slot="submenu" value="email">Email</lr-dropdown-item>
  <lr-dropdown-item slot="submenu" value="link">Copy link</lr-dropdown-item>
</lr-dropdown-item>
```

The submenu presentation is private to the parent row, not a second root-menu API. A submenu parent
has `aria-haspopup="menu"`, explicit `aria-expanded="true|false"`, the transient
`submenu-open` state, and a logical-direction chevron. Its submenu is named from the parent's
accessible label unless the submenu supplies its own `aria-label` or non-default `label`.

The into-branch and back-out arrow keys swap under RTL. Enter/Space and the into key open and focus
the first child; the back-out key and Escape close only the innermost branch and return focus to its
parent row. Pointer hover uses an intent delay, outside pointer dismissal closes the branch, and at
most one branch per level is open. Selection bubbles as the same single `lr-select`; a non-vetoed
selection closes the full nested chain.

### `lr-dropdown-item`

The Web Awesome-compatible name for the same item implementation. It shares all menu-item
properties, slots, parts, methods, checkbox/radio/state events, roving focus, and canonical parent
`lr-select` behavior. Its host also exposes native, non-bubbling, composed `focus` and `blur`
events.

**Attributes:** `href`, `target`, `rel`, and `download` — the same link-rendering properties
documented above for `<lr-menu-item>`, settable directly in markup
(`<lr-dropdown-item href="/settings" target="_blank">`). The `rel` guard is identical: author
tokens merge rather than get replaced, `opener` is always stripped, and setting `target` force-adds
the non-removable `noopener noreferrer` floor, so a same-tab link keeps exactly the author's tokens
while a link opening a new context can never lose the guard.

**Events:** native, non-bubbling, composed, non-cancelable `focus` and `blur` (`FocusEvent`) when
the focusable host gains or loses focus, plus the shared menu-item events above.

**Themeable custom properties:** every `<lr-menu-item>` hook above, including 16.0.0's
`--lr-menu-item-hover-bg`, `--lr-menu-item-active-bg`, `--lr-menu-item-icon-color` and
`--lr-menu-item-min-height`.
`--lr-overlay-surface`, `--lr-overlay-border` and `--lr-overlay-radius` are listed on this tag
because it shares a stylesheet directory with `<lr-menu>`, whose surface reads them; a dropdown item
is a row **inside** that surface and paints no surface of its own, so setting them here is a no-op —
set them on the menu or on any ancestor instead. The row's own corner stays `--lr-menu-item-radius`.

`submenuOpen` reflects to canonical `submenu-open`. HTML normalizes Web Awesome's documented
mixed-case spelling to `submenuopen`, so that lowercase token is a permanent compatibility alias.
Adding either spelling opens the submenu and synchronizes the other; removing either closes it and
removes both. Internal close paths — `closeSubmenu()`, Escape, outside dismissal, selection,
replacement, and disconnect — clear both spellings, preventing a persistent alias from reopening a
dismissed branch. An authored initial open request remains pending until submenu content connects.

### 9.0 migration

Root overlay behavior moved from `<lr-menu>` to `<lr-dropdown>`. Migrate mechanically:

- Move a former menu `trigger` slot and menu `open`, `placement`, `anchor`, or
  `close-on-escape-anywhere` policy to an enclosing `<lr-dropdown>`.
- Replace `menu.show()`/`menu.hide()` and menu `lr-show`/`lr-hide` listeners with the equivalent
  dropdown APIs/events.
- Replace `lr-menu-select` (`detail.value`) and `lr-menu-item-select` listeners with one
  `lr-select` listener on the menu or dropdown and read `event.detail.item`.
- Replace menu `::part(trigger)`/`::part(popup)` rules with dropdown part styling. Menu
  `header`/`list`/`footer` parts remain on the semantic controller.

Standalone mapped `<sl-menu>` markup needs no wrapper: the mechanical `sl-` → `lr-` tag rename
continues to produce an inline menu.

---
