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

# `lr-option`

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

---

## `lr-combobox` / `lr-option`

Filterable single/multi-select combining a text input with a listbox. Mirrors the core
`<wa-combobox>` API under the `lr-` prefix. **Form-associated** (hand-rolled internals, not the
shared `FormAssociated` mixin — see gotchas).

Consumer writes on mounted options immediately update the owning picker and its submission,
without changing the reset default or emitting picker input/change events. Owner synchronization
does not echo as a consumer selected write.

A mounted `option.selected` assignment updates the live picker value and form submission
immediately, including deselection and equal-value writes; it emits no user input/change event.

When `multiple` becomes false, the public value, live option flags, and popup `aria-selected` expose
one selected occurrence. The retained multiple-selection history returns if multiple is enabled
again without another selection write.

Composing keyboard events (`isComposing` or legacy key code 229) remain with filter editing; they do
not navigate, select/create a value, or dismiss the popup.

Source options with `inert`, including inherited inertness, are unavailable through the popup. Live
inert changes refresh row availability. Text, insertion, replacement, removal, slot
reassignment/removal, and relevant attributes of `start`/`end`/`prefix`/`suffix` adornments refresh
the corresponding cloned presentation; unchanged presentation retains its clone identity while
filtering.

Host `aria-describedby` resolves external descriptions onto the native combobox filter before local
error/hint guidance. References track missing targets, replacement, removal/reinsertion, reconnect,
and adoption. Removing `label`, `hint`, or `error-text` safely omits the content while leaving
removed string properties at their native `null` readback; explicit empty strings remain supplied
empty strings.

**First-interaction registration.** Where initial-route weight is stricter than a static combobox
registration allows, keep a labelled native `<input list>` as the working pre-JavaScript control
and import only the granular combobox registration on its first focus. Copy the native value after
the import resolves so typing that happens while the chunk is in flight is not lost, then transfer
focus explicitly — the browser does not replay the focus event after custom-element upgrade:

```html
<div id="country-fallback">
  <label for="country-native">Country</label>
  <input id="country-native" name="country" list="country-options">
  <datalist id="country-options"><option value="France"></option></datalist>
</div>
<lr-combobox id="country-enhanced" name="country" label="Country" hidden>
  <lr-option value="France">France</lr-option>
</lr-combobox>
<script type="module">
  const fallback = document.querySelector("#country-fallback");
  const input = document.querySelector("#country-native");
  const combobox = document.querySelector("#country-enhanced");
  let registration;
  input.addEventListener("focus", async () => {
    if (!combobox.hidden) return;
    registration ??= import(
      "@aceshooting/lyra-ui/components/forms/combobox/combobox.js"
    ).catch((error) => {
      registration = undefined; // let a later interaction retry
      throw error;
    });
    await registration;
    await customElements.whenDefined("lr-combobox");
    combobox.value = input.value;
    fallback.hidden = true;
    combobox.hidden = false;
    combobox.focus();
  });
</script>
```

Leave the native control in place if registration fails. This pattern preserves the initial shell;
the full form-label, option, overlay, and first-open positioning contracts arrive in deferred
chunks instead of being weakened in a separate partial combobox implementation.

An `lr-option` row remains bounded by its owning listbox: the default label ellipsizes and each
`start`/`end` (or `prefix`/`suffix`) adornment is capped at 40% of the row. Unbroken metadata
therefore cannot widen a 320px LTR or RTL picker.

**Adornments in the popup (fixed in 11.0.0).** Before 11.0.0 this paragraph described behavior the
code did not have: `lr-combobox` builds its popup from normalized row *data* rather than from the
light-DOM nodes, so a slotted `start`/`end`/`prefix`/`suffix` adornment had nowhere to land and
simply never rendered — the documented slots and their documented parts were both dead inside the
one component `lr-option` exists to feed. They now render. The nodes are **cloned** into the row
(`option-start` / `option-end` parts, inert and `aria-hidden`, so they never join the option's
accessible name), which means the author's own `<lr-option>` subtree is left exactly where they put
it rather than being moved into a shadow root as a side effect of opening a dropdown:

```html
<lr-combobox label="Country">
  <lr-option value="fr"><lr-flag slot="start" country="fr"></lr-flag>France</lr-option>
  <lr-option value="mt"><lr-flag slot="start" country="mt"></lr-flag>Malta</lr-option>
</lr-combobox>
```

An async `source` row can carry the same two fields (`start`, `end`) alongside its existing `icon`.

### `lr-combobox`

**Properties:**

- `multiple: boolean = false` (reflected)
- `size: LyraSize = 'm'` (reflected — the shared control ladder, so both `2xs`/`xs`/`s`/`m`/`l`/`xl`
  and the `small`/`medium`/`large` spellings are accepted; also scales the "+N" overflow tag and
  decorative expand icon; `size="s"` shares its outer control height with `lr-input`, `lr-select`,
  and `lr-segmented` without part overrides)
- `pill: boolean = false` (reflected) — rounds the trigger row's corners to a full pill, mirroring
  `lr-input`'s own `pill`. It changes the private radius default to `--lr-radius-pill`, so an
  inherited or direct `--lr-combobox-radius` remains authoritative
- `placeholder: string = ''`
- `disabled: boolean = false` (reflected)
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`; also reflected as
  `aria-required` on `<input part="combobox-input">` immediately. That semantic input exposes
  `aria-invalid="true"` whenever visible error chrome is present, or after interaction while
  intrinsic/custom validity fails; it explicitly returns to `"false"` when neither applies)
- `name: string = ''`
- `label: string = ''`
- `hint: string = ''`
- `errorText: string = ''` (attribute `error-text` — static error copy shown below the hint;
  overridden by slotted `error` content when provided)
- `open: boolean = false` (reflected)
- `allowCreate: boolean = false` (attribute `allow-create`) — a nonmatching query renders a
  localized create row. Activating it emits cancelable `lr-create`; unless vetoed, the component
  appends a real `<lr-option>` and selects it (also supported in `multiple` mode)
- `allowCustomValue: boolean = false` (attribute `allow-custom-value`) — single-select only;
  commits arbitrary text on Enter without creating an option
- `showUnknownOption: boolean = false` (attribute `show-unknown-option`, reflected) — appends every
  committed value that no option or async row claims to the end of the listbox as a synthetic,
  badged, keyboard-reachable, re-selectable row. Off by default. The synthetic row is filtered by
  the active query exactly like the `allow-create` row is, so a query it does not match neither
  shows it nor suppresses the "no matches" copy; re-picking it re-commits the same value and
  deliberately does **not** reclassify it as known — the badge and the row both survive, and the row
  never appears in `selectedRows`
- `getUnknownLabel?: (value: string) => string` (attribute: false) — renders the label for a
  committed value that matches no option or async row, everywhere it appears (trigger, `multiple`
  tag, synthetic row). `getTag` cannot serve this case: it is handed a matched option and there is
  none. A blank return falls back to the raw value. Not consulted while an async `source` fetch has
  never yet resolved for this element — see "Unknown committed values" below; `loadingText` covers
  that window instead, since the value is not yet known to be unmatched at all
- `appearance: 'filled' | 'outlined' | 'filled-outlined' = 'outlined'` (reflected)
- `placement: 'top' | 'bottom' = 'bottom'` (reflected; flip/shift can still keep the listbox in view)
- `positioningStrategy: PlaceStrategy = 'fixed'` (attribute `positioning-strategy`, reflected) —
  the CSS positioning scheme the listbox is laid out with, spelled the same as on `lr-select`,
  `lr-popover`, `lr-dropdown`, `lr-tooltip` and `lr-color-picker`. `fixed` is this control's
  default and what it has always rendered: it positions against the viewport and escapes most
  clipping ancestors, which suits a typeahead list that usually sits inside a scrollable region.
  `absolute` positions against the nearest containing block and scrolls with it. An unsupported
  value resolves to the default. Like `placement`, a change takes effect the next time the listbox
  opens. When the instance sets nothing, the cascading `--lr-positioning-strategy` custom property
  (`absolute`/`fixed`, set on `:root`, a theme, or one clipping ancestor) is honoured ahead of the
  default; an explicit instance value always wins. There is deliberately no `hoist` alias here:
  unlike on `lr-select`, where it is Shoelace's established spelling, it would be a boolean
  defaulting to `true`, so its attribute could only ever express the value the control already has
- `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
  spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
  sizes to its own content, clamped between `--lr-size-12rem` and `--lr-size-28rem`, exactly as
  before. `sync="width"` drops that content-based clamp so a full-width trigger with short option
  labels gets a listbox that aligns to its own edges instead of floating narrower in the middle.
  A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** — the space the
  positioner actually measured beside the anchor, which still keeps an over-wide trigger from
  pushing it off-screen. `--lr-popover-viewport-clamp` does **not** apply to a synced listbox — its
  92vw default used to shorten the listbox against its own trigger in exactly the full-width case
  `sync` exists for, and `lr-popup` never applied it either. It still applies with `sync` unset.
  Like `placement`, a change takes effect the next time the listbox opens
- `clearable: boolean = false` (reflected) — displays the clear button while there is something to
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
  "the clear button covers two axes" below
- `withClear: boolean = false` (attribute `with-clear`) — Web Awesome's spelling of `clearable`;
  either one enables the same clear button. Not deprecated: Web Awesome names this attribute
  `with-clear` and Shoelace names it `clearable`, so honouring both is what keeps a mechanical tag
  rename from silently dropping the control
- `withLabel: boolean = false` and `withHint: boolean = false` (attributes `with-label` and
  `with-hint`) — SSR slot-presence hints
- `getTag: ((option: LyraOption, index: number) => unknown) | undefined` (attribute: false) —
  replaces a built-in multiple-selection tag with consumer Lit/DOM/text output; strings render as
  text, never as HTML
- `validators: LyraComboboxValidator[] = []` (attribute: false) — extra JavaScript validators run
  after the intrinsic `required` constraint, the same contract `lr-date-input` implements. Each
  entry may be a `(value, input) => void | boolean | string | ValidityStateFlags` function (`value`
  is the live `string | string[]`), an object with `validate(value, input)` returning that same
  vocabulary, or a Web Awesome-compatible object with `checkValidity(input)` returning
  `{ isValid, message, invalidKeys }`, where `invalidKeys` names `ValidityState` flags; that object
  may also expose `observedAttributes` and a string or callback `message`. Changing any listed host
  attribute revalidates automatically. `isValid: true` (or `true`/`undefined` from a function)
  passes; otherwise the listed flags are set (`customError` when the list maps to nothing) and the
  returned message wins over the validator-level fallback. A throwing validator fails closed with
  the localized generic message. `disabled` bars them exactly as it bars `required`
- `validationTarget: HTMLElement | undefined` — writable native-validity focus anchor. After the
  first render it defaults to the internal filter input; assign another element to override it, or
  assign `undefined` to restore that input. It is `undefined` before the input exists
- `autocomplete: string = 'off'`, `inputMode: string = ''` (attribute `inputmode`),
  `enterKeyHint: string = ''` (attribute `enterkeyhint`), `spellcheck: boolean = false`,
  `autocapitalize: string = ''`, and `autocorrect: boolean = true` (attribute values `on`/`off`) —
  native editing-assistance properties forwarded to the internal filter input. Removing a
  `spellcheck` attribute after an override restores this component's declared `false` default.
  The lowercase
  mapped IDLs `inputmode` and `enterkeyhint` delegate to the corresponding camel-case native
  properties
- `inputValue: string` — the live filter input text; programmatic writes are event-silent
- `maxOptionsVisible: number = 3` (attribute `max-options-visible` — caps how many selected **tags**
  show before collapsing to `+N`; nothing to do with the suggestion list, see the three-caps note
  below)
- `visibleOptions?: number` (attribute `visible-options`, new in 11.0.0) — bounds the popup to about
  this many suggestion rows, leaving the rest reachable by scrolling. Purely presentational: every
  row is still rendered. Measured from where row N actually starts rather than computed from a
  token, because a row's height varies with `sub` lines, adornments and group labels. Unset imposes
  no bound of its own and the listbox keeps exactly its previous max-height behavior; zero,
  negative, and non-finite values normalize to unset rather than collapsing the popup

**The three caps, which are easy to confuse.** `visibleOptions` caps how many suggestion rows are
*visible* (presentation; the rest scroll). `maxRender` caps how many suggestion rows are *rendered
at all* (performance; the rest do not exist and are summarized by `option-overflow`).
`maxOptionsVisible` caps how many *selected tags* show in multi-select and never touches the
suggestion list.
- `emptyText?: string` (attribute `empty-text`) — omission displays localized `noMatches` (`"No
matches"` in the built-in English locale); any supplied string, including `''`, renders verbatim
- `loadingText?: string` (attribute `loading-text`) — shown while a `source` fetch is in flight,
  and in place of the raw value on the trigger/tag for a committed value the fetch has never yet
  resolved (see "Unknown committed values" below); omission displays localized `loading`
  (`"Loading…"` in English), while any supplied string, including `''`, renders verbatim
- `overflowText?: string` (attribute `overflow-text`) — shown when `maxRender` caps the rows;
  omission displays localized `comboboxOverflow` (`"+{n} more — refine your search"` in English).
  A supplied template wins verbatim over `.strings`, including when it equals that English
  template or is empty; `{n}` is still replaced with the locale-formatted hidden count
- `filter: OptionFilter | null = null` (attribute: false — `(option, query) => boolean`; default
  matches `label`/`searchText` case-insensitively; ignored while `source` is set)
- `source: ComboboxSource | null = null` (attribute: false — `(query: string, options: { signal:
AbortSignal; limit: number }) => Promise<readonly ComboboxSourceRow[] | { rows, total? }>`; when set, replaces the light-DOM `<lr-option>`
  list with an async lookup, debounced by `sourceDelay` ms after each keystroke and re-run on
  clear/pick. Forward `options.signal` to `fetch(url, { signal })` to cancel the request when a
  newer query supersedes it or the element disconnects. `loadingText` is shown while a call is in
  flight; a stale in-flight call that resolves after a newer one (or after disconnect) is dropped
  via a monotonic token. The exported type requires the `options` parameter; an existing
  one-parameter `(query) => …` function remains assignable under TypeScript's ordinary function
  parameter compatibility, but consumers that need cancellation should accept and forward
  `options.signal`; honor `options.limit` when practical and return `{ rows, total }` to report the
  provider-side match count. The component independently clone-normalizes the result, retains at
  most 2,000 rows/250,000 aggregate text units, skips malformed or hostile rows, and never trusts a
  provider to enforce the ceiling. A current rejection clears stale rows and renders a localized disabled
  listbox row; that visible row is not a shadow live region. The same localized message is appended
  to `[data-lr-live-region="assertive"]` in the document for each fresh post-mount rejection,
  including an identical retry, while raw caught error text stays out of the UI.)
- `sourceDelay: number = 200` (attribute `source-delay` — debounce in ms between the last keystroke
  and the `source` call; `0` fires on every keystroke. Sanitized to a finite non-negative duration,
  falling back to `200` for a non-finite value)
- `maxRender: number = 200` (attribute `max-render` — caps how many rows render at once, always
  keeping the current selection visible even if it's outside the cap; the excess renders as one
  `overflowText` row instead of being dropped silently. See "Large option lists" below for how to
  size it, and when `source` is the better answer. Runtime writes are capped at 1,000)
- `sourceTotal: number` (read-only) — provider-side total for the latest accepted response
- `sourceTruncated: boolean` (read-only) — whether the response reported or contained more rows
  than the bounded retained snapshot; the overflow row includes that hidden count
- `value: string | string[]` — a getter/setter: plain `string` in single mode, `string[]` in
  `multiple` mode. Assigning `undefined`/`null` clears the selection; every string, including `''`,
  is instead a candidate value resolved against the current local options/async rows — an
  `<lr-option value="">` (or a matching async row) is legitimate and round-trips like any other
  value. A value matching no current option/row still commits rather than being dropped or treated
  as a clear — see "Unknown committed values" below
- `customError: string | null` (attribute `custom-error`) — reflected consumer validation message
- `selectedRows` (read: `ComboboxSourceRow[]`; write: `readonly ComboboxSourceRow[]`) — structured
  rows for the current selection, including any opaque `data` payload supplied by an async source
  row or a light-DOM `<lr-option data>`, reached by reference and never deep-cloned.
  Reads return detached row snapshots. Writes select stable row values resolved against the
  current or deferred source, dropping duplicate and detached values without emitting selection
  events. Selected async rows remain available after the query changes or a later source result
  no longer contains them
- `selectionStart`, `selectionEnd`, and `selectionDirection` — selection getters/setters forwarded
  to the internal input

**Unknown committed values.** A committed value matching no current option/row (a stale value from
a removed option, or a programmatic assignment with a typo) still commits — the raw string stays
fully reachable through `value`/`selectedRows` — but renders a dashed/italic
`[part='unknown-value']` badge next to the closed single-select input, or on the relevant
`multiple`-mode tag, instead of an unexplained bare label, mirroring `<lr-model-select>`'s synthetic
"not in catalog" stale-value row — see `--lr-combobox-unknown-value-border-style`/`-color` below.
The badge is suppressed while an async `source` fetch has never yet resolved for this element —
from mount through the debounce delay and the in-flight call itself — and never shown for an
`allowCustomValue` commit, which is a sanctioned unmatched value, not a stale one. Over that same
unresolved window the raw value itself is also withheld: the trigger (and any `multiple`-mode tag
for the same value) shows the `loadingText` placeholder instead, so a value seeded before its async
catalogue has ever answered never flashes an unexplained, unbadged machine key. Once the fetch
settles — success or failure — the raw value returns, badged if it still matches nothing.

**Methods:** `focus(options?)`, `blur()`, `select()`, `setSelectionRange()`, and `setRangeText()`
forward to the internal input. `setRangeText()` synchronizes the filter query and visible options.
`show(): Promise<void>` and `hide(): Promise<void>` settle after `lr-after-show` and
`lr-after-hide`, respectively. `refresh(): void` re-runs the current `source` query without changing
the source's identity, its debounce controller, or its delay. Reassigning `source` is a provider
change and clears the fetched rows and pending-selection cache; `refresh()` does not. It queues for
the next open while the listbox is closed, and is a no-op without a `source`. `resetValidity()`
clears consumer custom validity and restores the current intrinsic constraints. `getForm()` returns the owning form, including an external owner
selected by the `form` attribute.
`setCustomValidity(message)` carries a rejection no client-side constraint can express ("that option
is no longer available"): a non-empty message raises `customError`, becomes `validationMessage`, and
blocks submission; `''` clears it and restores the control's own computed validity, so a `required`
combobox with nothing chosen goes back to `valueMissing` rather than to valid. The message survives
every selection change and a `form.reset()` — like a native control, only another
`setCustomValidity('')` or `resetValidity()` clears it — and is used verbatim, never localized.

**8.0 migration:** the former camel-case string property `autoCorrect` is not retained as a public
alias. Set the boolean `autocorrect` IDL, or use `autocorrect="on"` / `autocorrect="off"` in markup.

`ComboboxSourceRow = { readonly value: string; readonly label: string; readonly sub?: string; readonly icon?: unknown; readonly start?: unknown;
readonly end?: unknown; readonly badge?: string |
number; accessibleLabel?: string; data?: unknown; dotColor?: string; group?: string; disabled?:
boolean }` — the row shape used by the async `source` path. `start` and `end` (new in 11.0.0) are
the async counterparts of `<lr-option>`'s `start`/`end` adornment slots and render as the
`option-start` / `option-end` parts, inert and aria-hidden exactly like `icon`. `icon` renders as a decorative leading
visual whose rendered subtree stays visible but is inert and hidden from assistive technology;
put independent actions outside it. `badge` renders as trailing metadata, `accessibleLabel` can
provide richer spoken text than the visible label, and `data` is retained without being rendered
for retrieval through `selectedRows`.
`dotColor` accepts a valid CSS `color`; invalid values, declaration-breaking input, and `url()`
render a transparent dot.
The light-DOM `<lr-option>` path normalizes its supported label/sub/dot/group/data fields to the
same internal row model — `<lr-option data>` is the light-DOM counterpart of an async row's own
`data` field, reached by reference through `selectedRows` exactly the same way.

When a local option is removed or becomes disabled, or an async response shrinks, an existing
keyboard-active row clamps to the nearest enabled survivor. If every row is disabled or removed,
`aria-activedescendant` clears; an untouched list with no active row remains untouched.

**Events:** typing in the filter exposes the original bubbling/composed, non-cancelable `InputEvent`
as exactly one host `input` event (no `value` detail) and does not fire `change`. An actual user
selection mutation — pointer or keyboard selection, multiple-value toggle, tag/Backspace removal, or
clear — emits exactly one bubbling/composed, non-cancelable `input` `CustomEvent`, immediately
followed by the same shape of `change`, then a prefixed `lr-change` alias. All three carry
`detail: { value; data: readonly unknown[] }` — `value` is the new committed selection (a string in
single mode, a `string[]` in `multiple` mode); `data` is index-aligned with `value`: `data[i]`
describes `value[i]` — the opaque `data` payload of a light-DOM `<lr-option data>` or an async
source row's own `data`, reached by reference and never deep-cloned — or `undefined` in that
value's own slot when it currently matches no live row/option (see "Unknown committed values"
above; unlike `selectedRows`, which drops that entry instead). `lr-change` mirrors `<lr-checkbox>`'s namespaced alias; subscribe to it when you
want a `lr-`-prefixed event, or to the native-style `input`/`change` for parity with a native
control. Re-picking the current single value and programmatic/default/reset/restore writes are
silent (including on `lr-change`). The clear button emits one `lr-clear` after its
`input`/`change`/`lr-change` triple.
`lr-activate` (`detail: { value: string }`, bubbling/composed, non-cancelable) fires on **every**
activation of an available listbox row — a click, or Enter on the active row — whether or not the
selection actually moved. Its `value` is the activated option's own value, **always a single
string**, even in `multiple` mode, where the `input`/`change`/`lr-change` triple carries the whole
`string[]` instead. It reports that the user picked a row and gates nothing. Use it for the
single-select repeat pick that `change`/`lr-change` deliberately stay silent for — "re-run that
filter" is a real intent — which is otherwise unobservable, because the rows live in this shadow
root, so a retargeted `click` names no option and a keyboard commit produces no click at all. When
an activation _does_ move the selection, `input`/`change`/`lr-change` are emitted first, so either
listener reads the settled selection. Not fired for typing, for a committed custom value matching no
row, for the clear button, or for a programmatic `value` assignment.
`lr-filter` (`detail: { value: string }`) reports the in-progress filter text on every user-driven
keystroke — the live as-you-typed search string, deliberately _not_ `value`, which is the committed
selection. It is the supported way to read that text; reaching into the shadow root for
`[part="combobox-input"]`'s value is not. Named `lr-filter` rather than `lr-input` precisely because
`lr-input`'s detail on `<lr-input>` is the committed value, and the two must not share a name while
carrying different strings. It fires for user edits only. Picking a row, `form.reset()`, dismissing
the listbox, and a programmatic `value` write blank the filter silently. `setRangeText()` silently
replaces the requested or selected native text range, synchronizes the resulting query and visible
options, and refreshes an async source when present; it preserves the committed selection.
Activating the clear button is a user edit: when a query existed it emits `lr-filter` with
`value: ''` before the clear transaction finishes.
`lr-show` and `lr-hide` report the start of listbox visibility transitions. `lr-show` is a
cancelable veto point; `lr-hide` is cancelable while connected, but the disconnect-driven close is
non-cancelable because an already-removed control cannot honour a veto. Vetoing a connected close
is atomic: the filter query, active option, async result rows, reflected `open` state, and overlay
ownership remain unchanged, so the host can defer dismissal without reconstructing the search.
`lr-after-show` and
`lr-after-hide` fire when the corresponding transition settles. `lr-create` carries
`detail: { inputValue }` and is also cancelable: preventing it suppresses the default append/select
action so the host can normalize and commit its own option.
The internal input's `focus` and `blur` are relayed exactly once from the host as owner-realm
native `FocusEvent`s. Both bubble, cross the shadow boundary, and preserve `relatedTarget`.
`lr-invalid` (no detail) is emitted once as a bubbling/composed, **cancelable** alias when native
validity fails — see "The validity alias is cancelable in 8.0.0" above.
`lr-source-error` is non-cancelable, `detail: { error, query }` carrying the raw rejection from an
async `source` call plus the exact query string that call was made with (the rejected call's own
query, not necessarily the live `query`/`inputValue`, which may have moved on — or been cleared by
closing the listbox — by the time the rejection settles). The rendered copy stays localized and
never shows the raw error.
`lr-retry` is cancelable; the built-in failed-load action calls `refresh()`, and `preventDefault()`
leaves the failure on screen. While the failure state is the only popup content, the popup swaps
`role="listbox"` for `role="dialog"` (the input gains the matching `aria-haspopup="dialog"` and
drops `aria-activedescendant`, and the popup carries the localized failure heading as its accessible
name). `dialog` is one of the four popup roles WAI-ARIA lets a `role="combobox"` own, so the still
expanded `aria-controls` target keeps a valid owner while holding a retry `button` that is not a
legal listbox child. A successful retry restores `role="listbox"`.

**The clear button covers two axes, and announces only the one that moved.** A combobox owns both a
committed selection and an in-progress filter query, so the button renders whenever either has
something to clear, and one press clears both:

- Clearing a selection emits `input`, then `change`, then `lr-change`, then `lr-clear` — and, if the
  query was also non-empty, `lr-filter` with an empty `value`.
- A **query-only** clear (nothing selected, just typed text) emits `lr-filter` with an empty
  `value` and deliberately **no** `change` and **no** `lr-clear`. There was no selection
  transition to report, so announcing one would be a lie. Don't listen for `lr-clear` to detect
  "the user emptied the field" — listen for `lr-filter` when you care about the query.
- The query half of the render gate is scoped to states where the query is actually _visible_: an
  open listbox in single-select, or any time in `multiple` mode. A closed single-select shows the
  selected label rather than the query, so a stale query alone never surfaces a button offering to
  clear text the user cannot see.

**Large option lists: sizing `maxRender`, and why the listbox is not windowed.** Every row that
survives the filter and fits under `maxRender` is a real DOM element — the listbox renders its rows
in full rather than recycling a small window of them across a scroll. That is a deliberate
accessibility constraint, not an omission: the filter input carries `aria-activedescendant`, which
is an **IDREF**, and an IDREF only resolves within its own tree scope. Moving the rows into the
library's windowing primitive (`<lr-virtual-list>`) would place them one shadow root deeper than the
input that has to point at them, where neither the attribute nor its `ariaActiveDescendantElement`
element-reflection replacement can reach — element reflection resolves same-root or upward only. So
`<lr-virtual-list>` stays `role="list"`/`role="listitem"` and is the right tool for feeds and
viewers, not for a listbox whose active row must stay addressable.

What that means in practice:

- **Up to a few hundred rows, raise `maxRender` and move on.** A few hundred flex rows is an
  unremarkable amount of DOM; a country, currency, or time-zone list (typically 200–450 entries)
  renders comfortably with `max-render` set to cover it. The cap exists to bound pathological
  cases, not to make lists of that size expensive.
- **Past roughly a thousand rows, reach for `source` instead of a larger cap.** An async source
  narrows the candidate set before it ever becomes DOM, which is a categorically better trade than
  rendering everything and asking the browser to lay it out. Pair it with `sourceDelay` to debounce.
- **Leave the overflow row doing its job.** When the cap does bite, the excess collapses into one
  `overflowText` row (default `"+{n} more — refine your search"`) rather than disappearing
  silently, and the current selection is always kept visible even when it falls outside the cap.
  Suppressing that row by setting `maxRender` far above the real list size trades a useful "keep
  typing" affordance for layout work no user asked for.

**Slots:** default (`<lr-option>` children), `label`, `hint`, `error` (overrides the `errorText`
attribute when provided), plus two adornment slots:

- `start` — content at the inline-start of the trigger row, before the selected-value tags and the
  filter input. It is decorative chrome, **not** an option: only `<lr-option>` elements in the
  default slot are ever collected into the option list.
- `end` — content after the filter input and the built-in clear action, and before the expand icon,
  so consumer content never sits outboard of the dropdown chevron.
- `clear-icon` and `expand-icon` replace the corresponding built-in glyphs.
- `source-error` — replaces the built-in failed-`source` state, retry control included. Deliberately
  named apart from the form-control `error` slot: they are different failures and a field has to be
  able to show both.

**CSS parts:** `form-control`, `form-control-label`, `label`, `form-control-input`, `combobox`,
`start` and `end` (the two
adornment-slot wrappers, each `hidden` while nothing is slotted into it), `tags`, `tag`,
`tag-label`, `tag__content`, `tag__remove-button`, `tag__remove-button__base`, `combobox-input`,
`clear-button`, `unknown-value` (the dashed/italic badge shown next to the closed single-select
input, or on a `multiple`-mode tag, when the committed value matches no current option/row),
`expand-icon`, `listbox`,
`group-label` (the heading of an option group — rows sharing a `group` — named as on `lr-select` and
`lr-emoji-picker` so one rule styles every grouped list; it labels the `role="group"` wrapper here),
`option`,
`option-dot` (the leading status dot, when a row's `dotColor` is set), `option-icon` (the inert,
aria-hidden decorative leading visual for an async row), `option-start` and `option-end` (the inert,
aria-hidden adornments cloned from the source option's `start`/`prefix` and `end`/`suffix` slots, or
from an async row's `start`/`end`), `option-label`, `option-sub` (a row's
secondary line, when `sub` is set), `option-badge` (an async row's trailing metadata),
`option-overflow` (the "+N more" indicator from `maxRender`),
`source-error-row` (the listbox row holding the failed-`source` state), `source-error` (the shared
failed-load state itself, with `source-error-base`, `source-error-icon`, `source-error-heading`,
`source-error-description` and `source-error-actions` forwarded from the composed `<lr-empty>`),
`retry-button`, `error`, `hint`

**TypeScript:** `LyraCombobox<Multiple extends boolean = boolean>` — `value`/`defaultValue` and the
`lr-change`/`lr-input` detail `value` narrow to `string` when `Multiple` is `false` and `string[]`
(`readonly string[]` in a detail) when `true`. Types only; the runtime and the mirrored surface are
unchanged, and an untyped `<lr-combobox>` keeps `string | string[]`.

**The required marker.** `required` with a non-empty `label` paints the library's shared marker on
`[part="form-control-label"]` — the one `::after` rule described above, not a copy of it, so
`--lr-form-control-required-content`, `--lr-form-control-required-color` and
`--lr-form-control-required-offset` retune or suppress it here exactly as they do on `lr-input`.
With no label text the part is hidden and no glyph is painted.

**Themeable custom properties:** `--lr-combobox-trigger-padding`,
`--lr-combobox-trigger-min-height`, `--lr-combobox-font-size`, `--lr-combobox-tag-padding`,
`--lr-combobox-tag-font-size`, `--tag-max-size` (default `var(--lr-size-5rem)`), `--show-duration`,
`--hide-duration`, and `--lr-combobox-expand-size` (the decorative icon box; each
standard size supplies an aligned default), plus shared tokens. `--lr-combobox-gap` (default
`--lr-space-xs`, the gap inside `[part='combobox']`) and `--lr-combobox-radius` (default
`--lr-radius`, its corner radius) are both retunable without a `::part(combobox)` rule but, unlike
the properties above, do not vary by `size` — the same `--lr-button-gap`/`-radius` pattern.

The trigger row's own surface became public in 16.0.0, having been a private pair until then:
`--lr-combobox-fill` (default `var(--lr-color-surface)`) and `--lr-combobox-border-color` (default
`var(--lr-color-border)`). The `filled`/`filled-outlined` treatments default the fill to
`var(--lr-color-surface-raised)` and `filled` defaults the border to `transparent`; a value set
here wins over every treatment. `--lr-combobox-open-border-color` (default
`var(--lr-color-brand)`) recolors the row's edge while it holds focus, which was a hardcoded brand
border before. Read the name as the state the listbox opens in rather than as a synonym for `open`:
it is bound to `:focus-within`, so it also paints on a focused row whose listbox is closed — after
an Escape dismissal, say. It is named for symmetry with `lr-select`'s
`--lr-select-open-border-color`, which really is gated on `open`.

The shared field halo `--lr-form-control-focus-shadow` (default `none`) paints a `box-shadow`
while this control is focused — one name for every field-shaped control in the library, so a
halo is configured once instead of per component. It is additive: the focus outline and
border cue are the accessibility answer to focus and are never replaced by it.

`--lr-combobox-option-active-bg` (default `var(--lr-color-brand-quiet)`) recolors the background of
a hovered or keyboard-active `[part='option']` row — the same per-component indirection
`lr-select`'s identical `--lr-select-option-active-bg` uses, so a consumer can retheme just this
row state without hijacking the shared `--lr-color-brand-quiet` token library-wide.

The currently-**selected** row (`[part='option'][aria-selected='true']`) has its own matching set:
`--lr-combobox-option-selected-bg` (default `transparent`), `--lr-combobox-option-selected-border`
and `--lr-combobox-option-selected-color` (both default `var(--lr-color-brand)`), and
`--lr-combobox-option-selected-font-weight` (default `var(--lr-font-weight-semibold)`) — the same
four-token indirection `lr-select`/`lr-model-select` already provide for their own selected row.
Like the active-bg knob these are inline `var()` fallbacks, not declared on `:host`, so a consumer
can retheme the selected row without hijacking `--lr-color-brand` library-wide.

The listbox popup itself is a floating surface and paints from the **shared overlay-surface family**
(16.0.0), not from the page surface every card and input reads: `--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 `--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 popup together with every other floating surface, and none of
it touches the trigger row the popup drops from.

`--lr-combobox-unknown-value-border-style` (default `dashed`) and
`--lr-combobox-unknown-value-border-color` (default `var(--lr-color-border)`) retheme the
`[part='unknown-value']` badge described above under "Unknown committed values".
`--lr-combobox-option-badge-bg` (default `var(--lr-color-brand-quiet)`) retints the
`[part='option-badge']` trailing metadata badge on an async row, and the "not in catalog" badge
`show-unknown-option` renders on the synthetic unmatched-value listbox row — the same
per-component indirection `lr-select`'s `--lr-select-option-badge-bg` uses.

`--lr-combobox-trigger-height` pins an **exact** input-container height (both floors and caps it),
for pixel-matching an `<lr-input>` or `<lr-select>` in the same toolbar row. It is **undeclared by
default**, leaving `--lr-combobox-trigger-min-height` as a floor only and the row free to grow —
see "exact-height hatches" under `lr-input` for why `auto` is not a way to opt back out. Intended
for a single-row combobox: in `multiple` mode, a tag row long enough to wrap overflows the pinned
box visibly (nothing is clipped or made unreachable), so leave it unset there.

**CSS custom states:** `blank`, `disabled`, `required`, `optional`, `valid`, `invalid`,
`user-valid`, and `user-invalid`.

**Optional peer deps:** none.

### `lr-option`

**Properties:**

- `value: string = ''`
- `disabled: boolean = false`
- `defaultSelected: boolean = false` (attribute `selected`; property writes do not reflect) — the
  declarative and `form.reset()` default. Changing it after mount updates the parent's reset
  baseline without overwriting a dirty live selection
- `selected: boolean = false` (property only) — live selectedness. The parent combobox/select
  updates it as the current value changes; those live writes never rewrite the `selected`
  attribute or `defaultSelected`
- `group: string = ''` (section header)
- `searchText: string = ''` (attribute `search-text` — extra text the filter matches beyond the
  visible label)
- `sub: string = ''` (optional secondary line rendered under the label, e.g. a status/date summary)
- `dotColor: string = ''` (attribute `dot-color` — optional CSS color for a small leading status
  dot; invalid values, declaration-breaking input, and `url()` render the dot transparently)
- `data?: unknown` (attribute: false) — opaque application payload, e.g. the backend record this
  option represents. Never read or rendered by this component; retained by reference, never
  deep-cloned, through the owning `lr-combobox`'s `selectedRows` and the owning `lr-select`'s
  `selectedData`, and in both controls' `lr-input`/`lr-change`/`input`/`change` event details.
  Assigning it notifies the owning picker with `lr-option-change`, like `sub`/`dotColor`/`group`
- `label: string` — settable WA-compatible plain-text label. A non-empty property/attribute wins;
  otherwise it resolves to `defaultLabel`. Property writes stay property-only (no reflection)
- `defaultLabel: string` (read-only) — normalized accessibility-visible text generated from the
  flattened default slot. Hidden subtrees are excluded, visible nested `aria-label` values replace
  their descendants, and `start`/`end`/`prefix`/`suffix` adornments are excluded. Direct and
  forwarding-slot mutations update the value and notify the owning picker

**Method:** `getTextLabel(): string` returns `defaultLabel`, preserving Shoelace's content-derived
plain-text contract even when a separate WA `label` override is present.

**Events:** `lr-option-change` — bubbles when the option's label or selectable data changes so
its parent `lr-combobox` or `lr-select` can refresh its normalized option rows. It is a private
child-to-parent refresh signal, not a picker event: the owning `lr-combobox`/`lr-select` consumes
it and stops it, so it never reaches a listener on the picker host (whose own contract is
`lr-change`/`lr-input`/`change`/`input`). Listen on the `<lr-option>` itself to observe it.

**Slots:** default (visible label), `start`/`end` (WA adornments), and `prefix`/`suffix` (Shoelace
aliases). `start` and `prefix` project into one leading wrapper; `end` and `suffix` project into one
trailing wrapper.

**CSS parts:** `base`, `checked-icon`, `label`, `start`/`prefix` (same node), and `end`/`suffix`
(same node). **CSS custom property:** `--current-text-color` (default `var(--lr-color-text)`) colors
the keyboard-current row. **CSS custom states:** `current` (the host is the roving-focus target),
`selected`, `disabled`, and `hover` (pointer presence, including drag sessions).

Own-anatomy state hooks are `--lr-option-hover-bg`, `--lr-option-active-bg`,
`--lr-option-current-bg`, `--lr-option-current-color`, `--lr-option-selected-font-weight`, and
`--lr-option-checked-icon-color`. Their defaults preserve brand-quiet hover/current paint, the
shared active mix, semibold selected text, and the brand checkmark. The current-color hook falls
back through upstream `--current-text-color`, so existing themes keep working.

The stock `lr-combobox` and `lr-select` intentionally treat each option as light-DOM data and
render normalized rows in their own shadow roots. Style those rows through the parent's `option`,
`option-label`, and related parts; the parts above style an option's own anatomy when it is rendered
by a custom owner.

```html
<lr-combobox id="cb" label="Country" placeholder="Search…" with-clear>
  <lr-option value="fr">France</lr-option>
  <lr-option value="de" search-text="deutschland">Germany</lr-option>
</lr-combobox>
<script type="module">
  document
    .getElementById("cb")
    .addEventListener("change", (e) => console.log(e.target.value));
</script>
```

```html
<!-- Async data source instead of light-DOM <lr-option> children: -->
<lr-combobox id="cb2" label="Fruit (async)" with-clear></lr-combobox>
<script type="module">
  document.getElementById("cb2").source = async (query) => {
    const rows = await fetchFruit(query); // your own lookup
    return rows.map((r) => ({
      value: r.id,
      label: r.name,
      icon: renderFruitIcon(r), // decorative; hidden from assistive technology
      badge: r.category,
      accessibleLabel: `${r.name}, ${r.category}`,
      data: r, // retained in cb2.selectedRows after selection
    }));
  };
</script>
```

Multi-select submits as **repeated `FormData` entries** (not a joined string) —
`new FormData(form).getAll(name)` behaves like a native multi-value control. An unnamed multi-select
(`multiple` with no `name`) contributes nothing to the form at all, matching a nameless native
`<select multiple>`, rather than falling back to a shared literal key that could collide with
another unnamed combobox in the same form.
Session-history/autofill state is stored as a name-independent JSON string array. A valid string
array restores the selection (single mode keeps its first entry); malformed or wrong-shape state
restores an empty selection. Restored state wins even when it arrives before the first option
collection, while `form.reset()` still returns to the declarative selected default. Restoration is
synchronous and fires no `input`/`change`/`lr-change` event.

**Known gotchas:**

- `with-clear` and `clearable` are equivalent and both are supported indefinitely; prefer
  `clearable` in new code. `lr-input` and `lr-select` accept both spellings too.
- a host-level `aria-label` attribute on `<lr-combobox>` now takes priority over `label`/
  `placeholder`/`"Combobox"` when resolving the accessible name on `[part="combobox-input"]` —
  previously it was silently ignored. Matches the same fallback on `<lr-select>`.
- `aria-required` reflects `required` immediately; `aria-invalid`, by contrast, only reflects
  **after the field has been touched** (first `blur`) — a `required` field with a validity error
  doesn't look invalid to assistive tech before that, by design (avoids flashing invalid styling on
  first render). Blurring the input (Tab away) now also closes an open listbox, the same as a
  native `<select>`'s popup, not just a click outside or Escape.
- `dotColor`/`sub`/`group` are read from light-DOM `<lr-option>` children as before, but are also
  first-class fields on `ComboboxSourceRow` for the async `source` path — an async lookup can drive
  the same grouped/dot/sub-text rendering a static option list can.
- `icon`, `badge`, and `accessibleLabel` are async-source row features rather than `<lr-option>`
  properties. Icons are decorative (`aria-hidden`); use `accessibleLabel` when the visible
  label/sub/badge combination needs a fuller spoken name. `data`, by contrast, is a first-class
  `<lr-option>` property too (the light-DOM counterpart of the async row's own `data`): it is
  deliberately opaque, never rendered, and reachable through the read-only `selectedRows` getter
  (combobox), the read-only `selectedData` getter (select), and both controls' `lr-input`/
  `lr-change`/`input`/`change` event details.
- Full ARIA 1.2 combobox pattern (`role=combobox`, roving `aria-activedescendant`, real DOM focus
  kept on the input) is implemented correctly — a genuine strength, safe to build on.
  `<lr-option value="b" selected>` sets that option's `defaultSelected`, seeds the live selection,
  and supplies the `form.reset()` baseline, mirroring native `<select><option selected>`. A
  later-slotted option or post-mount `defaultSelected` change updates that baseline and updates the
  live selection only while it is pristine. A user pick or direct `option.selected` write makes the
  live selection dirty and never rewrites the declarative `selected` attribute/reset default.
- The floating listbox participates in Lyra's shared nonmodal overlay stack. Its computed
  `--lr-overlay-stack-index`, Escape owner, outside-pointer dismissal, and focus handoff follow the
  newest open Lyra overlay. Opening it above a color picker (or vice versa) therefore closes only
  the visual top layer per Escape/pointer action rather than both popups.

**Additional API surface:**

- `part="tag-label"` — The selected-tag label. Kept on one line and truncated with an ellipsis at `--tag-max-size` (default `var(--lr-size-5rem)`), rather than wrapped. Note that `lr-select`'s tag of the same name defaults to `var(--lr-size-12rem)`, so the same `--tag-max-size` value yields a wider tag there.

---
