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

# `lr-mention-popover`

- **Import** `import '@aceshooting/lyra-ui/components/lr-mention-popover.js';` (stable tag alias; registers the tag)
- **Class** `LyraMentionPopover`, also available unregistered from `@aceshooting/lyra-ui/components/utility/mention-popover/mention-popover.class.js`
- **Family** `components/utility/` — 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** 6 parts, 7 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-mention-popover`

A caret-anchored, keyboard-navigable popover for `@`-mention and `/`-slash-command autocomplete
inside a plain-text `<textarea>`/`<input>` the host owns. First-party invention (no Web Awesome
equivalent). A textarea keeps its native textbox semantics while open and, after a consumed
navigation key, `focusActiveOption()` moves real focus to the active option in the shadow listbox.
A single-line text input retains its active-descendant element-reference route; only that route
uses `syncActiveDescendant()`, which returns `false` for a textarea.
Textarea ARIA/AOM values are restored on close, anchor replacement, disconnect, or adoption; a
cross-root string IDREF is never left behind. Later localized result-count and active-position
changes are announced once through the shared polite region; opening markup and unchanged state are
silent.

Forward native input keydown events to `handleKeyDown()` as usual. Events with `isComposing` or
legacy `keyCode === 229` return false without preventing default, moving the active suggestion,
selecting, or closing the popover. Removing `query` uses the unfiltered empty-query behavior while
preserving null readback; an explicitly empty query remains empty.

**Properties:**

- `anchor?: HTMLElement` (attribute: false) — the element to position the popup relative to. A
  plain `<textarea>` or single-line text `<input type="text"|"search">` gets caret-precise
  positioning; any other element anchors the whole popup under that element's own box.
- `items: readonly Readonly<LyraMentionItem>[] = []` (attribute: false) — the full candidate set,
  pre-`query`-filtering. Assignment takes a shallow frozen snapshot. Runtime rows without a string
  `label` remain in that diagnostic snapshot but are omitted from filtering/rendering before the
  built-in or custom predicate runs, so one malformed provider row cannot take down valid siblings.
  An entry's `disabled` marks that row non-actionable: `aria-disabled="true"` replaces its
  selected/active affordances, activating it (click, or Enter/Tab while highlighted) commits
  nothing and emits no `lr-mention-select`, and ArrowDown/ArrowUp highlighting -- including the
  default pre-highlighted first row -- steps past it instead of landing on it. Omitted or `false`
  renders the row exactly as before this field existed.
- `query: string = ''` — the text typed since the trigger character; drives the built-in filtering
  (see `filter`).
- `open: boolean = false` (reflected)
- `filter: LyraMentionFilter | null = null` (attribute: false) — overrides the built-in
  case-insensitive `label`/`description` substring match entirely.
- `emptyText?: string` (attribute `empty-text`) — `undefined` uses localized `noMatches`; every
  supplied string, including `''` and `'No matches'`, is caller-owned
- `label?: string` — accessible name for the `role="listbox"` popup. `undefined` uses localized
  `mentionSuggestions`; every supplied string, including `''` and `'Suggestions'`, is
  caller-owned. A host-level plain `aria-label` attribute on `<lr-mention-popover>` itself takes
  priority over this property when present (checked via a plain `getAttribute()` read, not a
  reactive property) — matches the same fallback on `<lr-combobox>`/`<lr-table>`.
- `filteredItems: readonly Readonly<LyraMentionItem>[]` — read-only getter; label-valid `items`
  filtered by `query` via `filter` (or the built-in default). An empty `query` skips the query
  predicate but still omits malformed-label rows.
- `activeDescendantId: string | null` — read-only getter; the `id` of the currently-highlighted
  internal row, or `null` while closed or when `filteredItems` is empty. Useful for diagnostics and
  same-tree consumers; do not copy it to an external control as a string IDREF.
- `activeDescendantElement: HTMLElement | null` — read-only getter; the highlighted shadow option
  for the platform's element-reference ARIA API.
- `listboxId: string` — read-only getter; the internal `id` of the `role="listbox"` element. Like
  `activeDescendantId`, it cannot form a cross-shadow string IDREF from a host input.

**Methods:**

- `handleKeyDown(e: KeyboardEvent): boolean` — the host's own text-control `keydown` handler calls
  this while the popover is open. Handles `ArrowDown`/`ArrowUp` (moves the highlight) and
  `Enter`/`Tab` (commits the highlighted row) — both pairs return `false` with no
  `preventDefault()` when `filteredItems` is empty, letting the keystroke fall through to the
  host's own control unchanged. `Escape` closes with no selection. Returns `true` whenever the key
  was intercepted and `false` for keys the method does not recognize.
- `syncActiveDescendant(control: HTMLElement): boolean` — applies
  `ariaActiveDescendantElement` only for a retained single-line input route. For a textarea it
  returns `false` without mutating `aria-activedescendant`; the managed textarea session clears that
  value separately.
- `focusActiveOption(options?: LyraMentionFocusOptions): Promise<boolean>` — same-tree fallback
  after a consumed navigation key when `syncActiveDescendant()` returns `false`. Focuses the active
  option, lets the popover handle subsequent navigation, and restores focus to `anchor` when the
  popover closes. Pass `ownsFocus: () => boolean` when the caller has its own suggestion-session
  generation/disabled/focus-exit state; the predicate is rechecked immediately before transfer and
  during later fallback navigation. Close, candidate/query/filter/anchor replacement,
  disconnect/adoption, a newer transfer, or failed ownership resolves `false` without moving focus.

**Exported types:** `LyraMentionItem { suggestionId: string; label: string; description?: string;
icon?: string; disabled?: boolean }`; `LyraMentionFilter = (item: LyraMentionItem, query: string) => boolean`;
`LyraMentionFocusOptions { ownsFocus?: () => boolean }`;
`LyraMentionSelectDetail { suggestionId: string; index: number; label: string }`.

**Events:** `lr-mention-select` (`detail: LyraMentionSelectDetail`; `index` is the occurrence in
the assigned `items` collection before filtering and disambiguates repeated `suggestionId` values),
`lr-mention-close` (no detail payload —
`this.emit('lr-mention-close')` is called with no second argument, so `event.detail` is `null`,
not `undefined`; fires on Escape or any other `open: true -> false` transition, but never for the
close that immediately follows a `lr-mention-select` commit, and never for markup that simply
renders `open="false"` on first paint)

**Slots:** none.

**CSS parts:** `listbox`, `option`, `option-icon` (when `icon` is set), `option-label`,
`option-description` (when `description` is set), `empty`

**Themeable custom properties:** `--lr-mention-popover-option-active-bg` (default
`var(--lr-color-brand-quiet)`) — background of the hovered or `[data-active]`
(keyboard-highlighted) suggestion row. Component-scoped indirection over the shared
`--lr-color-brand-quiet` token, so a consumer can retheme just this highlighted/active row without
repainting every other component that reuses the same shared token.
`--lr-mention-popover-option-disabled-opacity` (default `0.5`) — opacity of a row whose `items`
entry sets `disabled`. Plus shared tokens —
`--lr-space-xs`/`-s`/`-m` (popup padding,
row padding/gap), `--lr-radius`
(row corners — the popup's own corner is the overlay family's, below),
`--lr-transition-fast` (open/close
transition), `--lr-color-brand` (selected-row
text), `--lr-color-text-quiet`/`--lr-color-text` (description text, full-contrast on the active
row), and `--lr-popover-viewport-clamp` (default `92vw`) — the shared narrow-viewport ceiling the
popup's max-inline-size is `min()`ed against, alongside its own `24rem` cap and the positioner's
available space. See `lr-tour` for the shared-clamp note.

The popup is a floating surface and paints 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)`) and `--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
surface together with every other floating surface in the library. `--lr-overlay-radius` (default `var(--lr-radius)`) is the matching corner radius.

`--lr-positioning-strategy` (16.0.0) — the popup 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-mention-popover>`; set the custom property on `:root`, a theme, or one clipping ancestor to
change every unset mention popover beneath it.

**Optional peer deps:** none.

```html
<textarea id="composer"></textarea>
<lr-mention-popover
  id="mentions"
  label="People"
  empty-text="No matches"
></lr-mention-popover>
<script type="module">
  const textarea = document.getElementById("composer");
  const popover = document.getElementById("mentions");
  let suggestionGeneration = 0;

  textarea.addEventListener("keydown", (e) => {
    if (popover.open && popover.handleKeyDown(e)) {
      if (
        !popover.syncActiveDescendant(textarea) &&
        (e.key === "ArrowDown" || e.key === "ArrowUp")
      ) {
        const generation = suggestionGeneration;
        void popover.focusActiveOption({
          ownsFocus: () =>
            generation === suggestionGeneration &&
            (document.activeElement === textarea ||
              document.activeElement === popover),
        });
      }
      return;
    }
  });
  textarea.addEventListener("input", () => {
    suggestionGeneration += 1;
    popover.anchor = textarea;
    popover.items = [
      {
        suggestionId: "ada",
        label: "Ada Lovelace",
        description: "Engineering",
        icon: "👩‍💻",
      },
      {
        suggestionId: "grace",
        label: "Grace Hopper",
        description: "Engineering",
      },
    ];
    popover.query = "a"; // detected since the trigger character
    popover.open = true;
    popover.updateComplete.then(() => popover.syncActiveDescendant(textarea));
  });
  textarea.addEventListener("blur", (event) => {
    if (event.relatedTarget !== popover) {
      suggestionGeneration += 1;
      popover.open = false;
    }
  });

  popover.addEventListener("lr-mention-select", (e) => {
    // splice `${e.detail.label}` into the textarea at the trigger offset
  });
</script>
```

Integration is entirely the host's responsibility: detect a mention/command trigger in the host's
own `input` handling, set `anchor`/`items`/`query` and flip `open = true`, and forward every
`keydown` through `handleKeyDown()` while open. Anchor relationship and active-descendant syncing
are automatic; if an explicit `syncActiveDescendant()` check returns `false`, call
`focusActiveOption()` after the first consumed ArrowUp/ArrowDown so the fallback owns navigation
from then on. Setting `open = false` whenever the
query stops looking like an active mention context (a space typed, the trigger deleted, the input
blurred, …) is also the host's job — `lr-mention-close` fires automatically from that.

Positioning measures exactly where the caret currently paints via a hidden-mirror-element technique
(`caretClientRect()`) and positions against that single point with `internal/positioner.js`'s
`place()`, so the popup tracks the caret rather than sitting under the whole textarea. Re-measures
automatically only on an `anchor` or `query` change while open (a keystroke moves the caret, so a
fresh `query` is the proxy for "the caret may have moved").

**Known gotchas:**

- a host-level `aria-label` attribute on `<lr-mention-popover>` now takes priority over `label`
  (and its localized default) when resolving `[part="listbox"]`'s accessible name — previously it
  was silently ignored. Matches the same fallback on `<lr-combobox>`/`<lr-table>`.
- Never copy `activeDescendantId` or `listboxId` onto a document-owned control as a string ARIA
  IDREF; shadow-root IDs are outside that control's tree scope. Use `syncActiveDescendant()` and its
  `focusActiveOption()` fallback.
- The popover opens pre-highlighted on the top match (index 0), unlike `<lr-combobox>`'s own
  listbox which opens with nothing highlighted (`-1`) — a bare Enter right after opening commits
  immediately.
- Caret-precise positioning only applies to a plain `<textarea>` or single-line text
  `<input type="text"|"search">`; any other `anchor` element, or a text control whose caret rect
  can't be measured (e.g. `display: none`), silently falls back to whole-element anchoring against
  `anchor` itself.
- A caret that moves for a reason other than typing (e.g. a mouse click elsewhere in the text while
  the popover happens to still be open) is not separately tracked — force a re-measure by toggling
  `open` or reassigning `anchor`.
- `activeIndex` resets to `0` whenever `query`, `items`, or `filter` changes, but not when only
  `anchor` changes — reassigning `anchor` alone preserves whatever row was last highlighted. If
  fallback focus currently lives in the listbox and filtering removes every option, closing,
  emptying, or disconnecting the popover returns focus to the connected anchor before removing the
  active option.
- There's no persisted "selection" the way `<lr-combobox>`'s own listbox has one — a mention is
  either committed (closing the popover) or dismissed with nothing chosen. `aria-selected="true"`
  here marks whichever row is currently _active_ (what Enter/Tab would commit right now, per the
  WAI-ARIA combobox-with-list-autocomplete pattern), not a separate persisted value.

---
