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

# `lr-select`

- **Import** `import '@aceshooting/lyra-ui/components/lr-select.js';` (stable tag alias; registers the tag)
- **Class** `LyraSelect`, also available unregistered from `@aceshooting/lyra-ui/components/forms/select/select.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** 34 parts, 35 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-select`

A plain closed-list dropdown — a direct `<lr-*>` counterpart to `<wa-select>`/`<wa-option>`.
**Form-associated** (hand-rolled internals, not the shared `FormAssociated` mixin — same reasoning
as `lr-combobox`: `multiple` re-shapes the committed `value` into a `string[]`, which the shared
mixin — built for a single string value — can't model, so both controls attach their own
`ElementInternals` and drive `setValidity()` directly instead). The trigger is a `<button>`, not a text
input: click/Enter/Space/ArrowDown opens it, and there's no typing-to-filter. Options are
`<lr-option value>` children — the same element `<lr-combobox>` uses — reconciled the same way
combobox does. The popup reuses `internal/positioner.ts` for placement and participates in Lyra's
shared nonmodal overlay stack.
Session-history/autofill restoration assigns the stored string through the same synchronous
value/form/validity path as a programmatic value write and does not emit `input`, `change`, or
`lr-change`.

Mounted `option.selected` assignments update the picker value and form submission immediately,
including deselection and equal-value writes. Duplicate-valued options retain their individual
occurrence identities. These programmatic writes emit no user input/change event.

Host `aria-describedby` resolves external guidance onto the trigger before internal value, error,
and hint guidance. References track missing targets, replacement, removal/reinsertion, reconnect,
and adoption. Removing `label`, `hint`, `help-text`, or `error-text` safely omits the content
without changing native `null` property readback; explicit empty strings remain empty.

There is no typing-to-filter and no `filter`/`source`/`empty-text`/`max-render` surface — reach for
`<lr-combobox>` instead whenever any of those apply. Everything else a closed list needs is here:
`multiple`, `max-options-visible`, `with-clear`, `getTag`, `placement`, `appearance`, and `pill`.

The trigger and its overlaid multi-select tag row accept constrained allocation. Long selected
labels ellipsize; long built-in tags wrap and cap their labels, so single and multiple selections
stay inside exact-320px LTR and RTL containers alongside start/end adornments.

**Multi-select (`multiple`, new in 8.0.0, default `false`).** Setting it re-shapes `value` from a
`string` into a `string[]` and renders one chip per selection. The `[part="tags"]` row is a sibling
overlaid on the real trigger, so every built-in tag remove button is valid independently-focusable
interactive content rather than a button nested inside another button. Picking a selected row,
Backspace/Delete on the focused trigger, and the `with-clear` action remain equivalent removal
paths. Selection identity is the option occurrence, not just its string: two same-valued rows may
both remain selected, render their own labels, submit duplicate entries, and be removed separately.
The trigger retains one genuinely visually-hidden current-value node containing **every**
selected label, even past `max-options-visible`; painted built-in chip labels and the overflow chip
are hidden from the accessibility tree so that value is announced once rather than truncated or
duplicated. Turning `multiple` back off collapses
the selection to its first entry, so the single-mode string and the submitted entry can never
disagree with what the trigger shows.

A `multiple` select submits **one form entry per selected value** under its `name`, so
`new FormData(form).getAll(name)` behaves like a native multi-value control rather than returning a
joined string. An unnamed multi-select contributes nothing to the form at all, matching a nameless
native `<select multiple>`. Session-history/autofill state is a JSON string array in `multiple` mode
and the plain submitted string in single mode; malformed state restores an empty selection. The
listbox renders `aria-multiselectable` in **both** states (`"true"` and `"false"`), never omitting it.

**Breaking in 8.0.0:** `value` is now typed `string | string[]` even in single mode. A TypeScript
consumer that read it as a plain string needs a narrowing step —
`const v = el.value; const single = typeof v === 'string' ? v : (v[0] ?? '');`

**Single-option auto-commit.** Opt-in via `autoCommitSingleOption` (default `false` — a select always
renders the normal combobox/listbox/chevron trigger unless enabled, matching pre-1.3.0 behavior).
When set and exactly one option is available (neither disabled nor inert, including through an
inert ancestor), the popup never opens at all: a click, Enter, Space, ArrowDown, or ArrowUp on the
trigger
commits that sole option directly, and the trigger renders as a plain `role="button"` with no
chevron/`aria-haspopup`/`aria-expanded`/`aria-controls`/`aria-activedescendant` rather than a
combobox with a permanently inert popup state — opening a one-row list to pick the only available
choice is pure friction with no real decision behind it. It never changes `value`/validity defaults
on its own: an unselected single-option select stays unselected (and a `required` one stays invalid)
exactly like the multi-option case, until the trigger is actually activated.

**Properties:**

- `placeholder: string = ''` — text shown on the trigger while nothing is selected, and the
  trigger's accessible name when neither a host `aria-label` nor a `label` supplies one. One
  exception to "an empty selection always shows this": while `loading` is `true` the trigger shows
  the localized `loading` text instead — see `loading` below. The accessible name is unaffected
  either way
- `disabled: boolean = false` (reflected)
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
- `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). Direct or fieldset-cascaded disablement synchronously forces
  it closed; every later property or attribute attempt to open remains normalized to `false` until
  the control is enabled again
- `size: LyraSize = 'm'` (reflected — the shared control ladder, same scale as
  `lr-input`/`lr-combobox`/`lr-button`, for compact toolbar placements that don't fit the default
  trigger height. Both spellings of every tier are accepted: `2xs`/`xs`/`s`/`m`/`l`/`xl` and
  `small`/`medium`/`large`)
- `appearance: 'accent' | 'filled' | 'outlined' | 'filled-outlined' | 'plain' = 'outlined'`
  (reflected) — the library's shared field-surface vocabulary. `outlined` (the default) is a
  bordered surface; `filled` swaps the border for a raised fill; `filled-outlined` keeps both;
  `plain` drops both; `accent` paints the loud brand fill with on-brand text (the placeholder,
  expand icon, adornments and chips all ride that on-brand color rather than the quiet-text
  tokens). Every value keeps the same box, border width and radius, and each restates its own
  `:hover` feedback. `lr-input` and `lr-textarea` use the same mapped `outlined` default.
- `pill: boolean = false` (reflected) — fully-rounded trigger corners. It changes the private
  radius default to `--lr-radius-pill`, so an inherited or direct `--lr-select-radius` remains
  authoritative
- `placement: Placement = 'bottom'` (reflected) — preferred listbox placement, from the
  Floating UI vocabulary (`'top'`, `'bottom-end'`, …). `flip`/`shift` may still move the popup to
  keep it in view, and the `left`/`right` component is swapped under RTL. Assignment while open
  refreshes positioning in place without closing, firing lifecycle events, or changing stack order
- `hoist: boolean = false` (reflected) — switches Floating UI from its mapped absolute strategy to
  fixed positioning, escaping clipping containers. It also switches live while open; an effective
  direction change refreshes logical left/right placement by the same path
- `positioningStrategy: PlaceStrategy = 'absolute'` (attribute `positioning-strategy`, reflected) —
  see `<lr-popover>` (`llms/components/lr-popover.md`). `hoist: boolean = false` is its retained
  exact alias; writing either spelling updates the other. This property always reports the
  instance's own authored value (or the mirrored `absolute` default); when neither spelling is
  authored, the listbox is actually placed with the cascading `--lr-positioning-strategy` custom
  property honored ahead of that default — see the listbox's own **Themeable custom properties**
  below
- `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
  spelled and typed the same as on `lr-popup`/`lr-popover`/`lr-dropdown`/`lr-combobox`
  (`'width' | 'height' | 'both'`). Unset (the default), the listbox sizes to its own content,
  clamped between `--lr-size-12rem` and `min(--lr-popover-viewport-clamp, --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 trigger, which still keeps an over-wide trigger
  from pushing it off-screen; `--lr-popover-viewport-clamp` does **not** apply to a synced listbox,
  and still does with `sync` unset. Assignment while open repositions in place without closing;
  unsetting it releases the inline width the positioner wrote
- `showUnknownOption: boolean = false` (attribute `show-unknown-option`, reflected) — appends every
  committed value that no `<lr-option>` claims to the end of the listbox as a synthetic, badged,
  keyboard-reachable, re-selectable row. Off by default
- `getUnknownLabel?: (value: string) => string` (attribute: false) — renders the label for a
  committed value that matches no option, 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 `loading` suppresses that same value —
  see below
- `loading: boolean = false` (reflected) — whether a committed value's real label may still be
  pending because its `<lr-option>` catalog hasn't arrived yet (e.g. an async fetch still in
  flight). While `true`, a committed value that currently matches no option renders the localized
  `loading` placeholder in the trigger label or the relevant `multiple` tag instead of the raw
  value, with no `notInCatalog`/`[part='unknown-value']` badge and no synthetic
  `showUnknownOption` listbox row — "not yet resolved" is a different state from "known to be
  missing". It covers an **empty** selection too: with nothing selected — a create form whose
  catalogue is still being fetched, or an edit form whose saved selection is legitimately empty —
  the trigger renders that same localized text in place of `placeholder`, from the same `loading`
  message key, so both halves of a pending state read the same words and one
  `registerLyraLocale()` translation reaches both. Nothing to re-localize in the consuming app.
  With `loading` false an empty selection renders `placeholder` exactly as before. The trigger's
  accessible name never changes for this: a host `aria-label` wins, then `label`, then
  `placeholder`, then the localized `select` fallback. A value already matching a live option is
  unaffected. Never mutates `value`/`selectedOptions` itself, and does not itself disable the
  trigger — pair it with `disabled` when the control should also be non-interactive while pending
- `filled: boolean = false` (reflected) — Shoelace alias for the filled trigger treatment
- `autofocus: boolean = false` / `title: string = ''` — forwarded to the internal trigger
- `multiple: boolean = false` (reflected) — several options selectable at once; see "Multi-select"
  above. Flipping it re-shapes `value` and the submitted form entry, so it is normally set once
  declaratively
- `maxOptionsVisible: number = 3` (attribute `max-options-visible`) — how many chips render in
  `multiple` mode before the rest collapse behind a localized "+N" chip. `0` removes the cap
  entirely. Sanitized to a finite, non-negative integer: a fractional value truncates, a negative
  one clamps to `0` (i.e. uncapped), and a non-finite one falls back to `3`
- `withClear: boolean = false` (attribute `with-clear`, reflected) — renders a clear button while
  anything is selected (and nothing at all while the selection is empty). It sits in the trigger's
  inline-end band as a **sibling** of the trigger rather than a child of it — same nesting reason as
  the chips — so pressing it clears the selection without opening the listbox
- `clearable: boolean = false` — Shoelace's spelling of `withClear`; either one renders the same
  button. Present so a mechanical `sl-select` → `lr-select` rename keeps the clear control
- `helpText: string = ''` (attribute `help-text`) — Shoelace alias for `hint`; `hint` wins if both
  are present. `withLabel`/`withHint` (`with-label`/`with-hint`) are SSR slot-presence hints
- `getTag?: LyraSelectTagRenderer` (attribute: false) — `(option: LyraOption, index: number) =>
unknown`, exported under that name from the component's own module, renders one
  selected option's chip in `multiple` mode. Whatever it returns replaces the whole built-in
  `[part="tag"]` element, so re-declare `part="tag"` on your own root node to keep the default
  styling hooks. A returned **string renders as text, never as markup** (it lands in an ordinary
  Lit child position). A custom tag replaces the built-in remove control too, so it owns any custom
  removal affordance. Overflow past `max-options-visible` still collapses into the built-in "+N" chip
- `autoCommitSingleOption: boolean = false` (attribute `auto-commit-single-option`) — opts in to the
  single-option auto-commit behavior described above
- `value: string | string[]` — a getter/setter: a plain `string` in single mode (empty when nothing
  is selected), a `string[]` in `multiple` mode
- `defaultValue: string | string[]` (attribute `default-value` accepts the single string form) —
  reset selection; changing it updates the live value only while the control is pristine. Assigning
  `undefined`/`null` to either clears the selection; every string, including `''`, is a candidate
  value resolved against the current `<lr-option>`s instead — an `<lr-option value="">` is a
  legitimate row and round-trips like any other value. A value matching no current option still
  commits — see "Unknown committed values" below
- `selectedOptions: LyraOption[]` — a writable, fresh snapshot of the live selected occurrences.
  Assigning live child options commits their exact occurrences through the same event-silent path
  as `value`; foreign/detached options are ignored, and single mode keeps only the first. Mutating
  an array returned by the getter never mutates the control
- `selectedData: readonly unknown[]` (read-only) — the opaque `data` payload of each committed
  value, index-aligned with `value`: `selectedData[i]` describes `value[i]` (or `value` itself in
  single mode), and stays that value's own slot — `undefined`, never shifted or dropped — when
  that value currently matches no live option (see "Unknown committed values" below). Always an
  array the same length as `value`, in both single and `multiple` mode. Reached by reference,
  never deep-cloned
- `customError: string | null` (attribute `custom-error`) — reflected consumer validation message

**Unknown committed values.** A committed value matching no current `<lr-option>` (a stale value
from a removed option, or a programmatic assignment with a typo) still commits — the raw string
stays fully reachable through `value`/`selectedOptions` — but renders a dashed/italic
`[part='unknown-value']` badge next to the trigger label, 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-select-unknown-value-border-style`/`-color` below. Set `loading` while
that same value's own `<lr-option>` catalog simply hasn't arrived yet (unlike `<lr-combobox>`,
`<lr-select>` has no async `source` of its own, so this is consumer-driven rather than automatic):
a still-unmatched value then renders the localized `loading` placeholder instead of the raw value,
with no `unknown-value` badge and no synthetic `showUnknownOption` row, since it is not yet known
to be missing. Once the matching option mounts, the real label renders on the next render with no
`value`/`selectedOptions` re-assignment needed, whether or not `loading` is also flipped back to
`false`. The same flag covers the other half of that state: with **nothing** selected the trigger
renders the same localized `loading` text in place of `placeholder`, so a consumer never has to
hand-write a conditional placeholder bound to the same flag and re-localize, in its own catalogue,
the string this control already owns.

**Methods:** `focus(options?)`, `blur()`, and `click()` forward to the internal trigger button.
`show()` and `hide()` return `Promise<void>` and resolve after `lr-after-show`/`lr-after-hide` once
the matching transition settles. `getForm()` returns the browser-resolved form owner, including an
external owner selected by `form`.
`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`
select 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.
`resetValidity()` changes only that consumer error layer: it restores the current intrinsic
required/selection validity without changing the selection/default or clearing prior interaction
state.

**Events:** each real selection change emits, in order, a native `InputEvent` named `input`,
`lr-input`, a native `Event` named `change`, then `lr-change`. The native events carry no detail;
read `event.target.value`. Both
prefixed aliases carry `detail: { value: string | string[]; data: readonly unknown[] }` — `value`
is the new committed selection, a string in single mode and a `string[]` in `multiple` mode; `data`
is index-aligned with `value` exactly like `selectedData` above (the same reference, `undefined`
for a value matching no live option), reached by reference and never deep-cloned. The complete sequence is silent for a
programmatic `value` write, `form.reset()`, or session-state restoration. Plus
`lr-clear` (no detail; emitted by the `with-clear` button _after_ its
`input`/`lr-input`/`change`/`lr-change` run, and never when there was nothing to clear, so it never
announces a no-op),
`lr-show`, `lr-hide`, and bubbling, composed `focus`/`blur` events re-dispatched from the internal
trigger. `lr-show` is cancelable; `lr-hide` is cancelable while connected and
non-cancelable only for the disconnect-driven close, where a veto cannot be honoured. A direct or
fieldset-cascaded disablement is a policy closure rather than a user-requested transition: it
synchronously closes without the vetoable `lr-hide` or settled `lr-after-hide` lifecycle, and a
listener cannot hold a disabled popup open.
`lr-after-show` and `lr-after-hide` fire after the corresponding listbox transition has settled; an
interrupted transition drops its stale after-event.
`lr-invalid` (no detail, cancelable) fires when a validity check finds the control invalid.
`lr-activate` (`detail: { value: string }`, bubbling, composed, non-cancelable) fires on **every**
activation of an available listbox row — a click, or Enter/Space 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 `lr-input`/`lr-change` carry 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 (matching a native `<select>`) — "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`/`lr-input`/`change`/`lr-change` are emitted first,
so either listener reads the settled selection. It is not fired by a programmatic `value`
assignment, nor by the `with-clear` button.

**Slots:** default (`<lr-option>` children), `label`, `hint`, `help-text` (alias), `error` (overrides
the `errorText` attribute when provided), `start`/`prefix` (aliases before the selected-value label),
`end`/`suffix` (aliases after the label), plus `clear-icon` and `expand-icon`. Because the adornments
live inside the native trigger `<button>`, both wrappers are unconditionally inert,
`aria-hidden="true"`, and non-hit-testable. The names remain mirrored for decorative glyphs and
text, but links, buttons, inputs, and other supplied controls cannot become nested interaction or
accessibility stops.

In populated multiple mode, the trigger's `aria-describedby` first references the complete
visually-hidden selected-value node. When hint/error content is present it then references stable
shadow-local IDs for both messages (error before hint), so the current value and visible supporting
text are part of the focused control's accessible description.

**CSS parts:** `form-control`, `form-control-label`, `label`, `form-control-input`, `combobox`,
`trigger`, `display-input`, `start`, `prefix`, `end`, `suffix`, `tags` (the legal sibling
`multiple`-mode chip row), `tag`/`tag__base` (one selected-value chip), `tag-label`/`tag__content`,
`tag__remove-button`/`tag__remove-button__base`, and `tag-overflow` (the "+N" chip standing in for the selections past
`max-options-visible` — it carries **both** `tag` and `tag-overflow`, so `::part(tag)` styles every
chip while `::part(tag-overflow)` reaches only that one; state after `::part()` never matches, so it
is encoded in the part name instead), `unknown-value` (the dashed/italic badge shown next to the
trigger label, or on a `multiple`-mode tag, when the committed value matches no current
`<lr-option>`), `clear-button` (the `with-clear` button, present only while
there is a selection to clear), `listbox` (the managed nonmodal popup, layered by
`--lr-overlay-stack-index` with `--lr-layer-dropdown` as its standalone fallback),
`group-label` (a heading row emitted inside the listbox whenever an option's `group` differs from
the previous one's — its stable ID labels a `role="group"` wrapper that semantically owns the
following option rows; options with an empty `group` get no heading or group wrapper),
`option`, `option-dot` (the leading status dot, when a row's `dotColor` is set),
`option-start`/`option-end` (an option row's leading/trailing adornment, cloned from the source
`<lr-option>`'s `start`/`prefix`/`end`/`suffix` slot — inert and `aria-hidden`, exactly like
`lr-combobox`'s identical parts), `option-label`,
`option-sub` (a row's secondary line, when `sub` is set),
`option-badge` (the localized "not in catalog" badge on a synthetic unmatched-value row, rendered
only while `show-unknown-option` is set), `expand-icon`, `error`, and
`hint`/`form-control-help-text` (compatibility names on the same supporting-text node).

**TypeScript:** `LyraSelect<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-select>` 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-select-trigger-padding`, `--lr-select-trigger-min-height`,
`--lr-select-font-size`, `--lr-select-expand-size` — all four have private defaults that follow
`size` (`xs`…`xl`), while inherited or direct public values remain authoritative; the same pattern
`lr-toast-item`'s `--lr-toast-padding`/`--lr-toast-font-size` use. `--lr-select-gap` (default
`--lr-space-xs`, the gap inside `[part='trigger']`) is retunable without a `::part(trigger)` rule
and does not vary by `size` — the adornment gap a field wants is looser than the icon-beside-label
gap the ladder is tuned for. `--lr-select-radius` (default `--lr-form-control-radius`, the corner
radius) is retunable the same way but _does_ follow the tier: the two tightest tiers take a smaller
radius, since a 6px corner on a 20px-tall control reads as a lozenge. `pill` changes its private
default to `--lr-radius-pill`. `--lr-select-tag-padding`
(default `var(--lr-space-2xs) var(--lr-space-xs)`) and `--lr-select-tag-font-size` (default
`var(--lr-font-size-sm)`) size a `multiple`-mode chip; like gap and radius their private defaults do
**not** vary by `size` tier, and inherited or direct public values win.
Mapped hooks `--tag-max-size` (default `var(--lr-size-12rem)`), `--show-duration`, and
`--hide-duration` cap one tag and independently retime the two popup directions.

The listbox 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)`), `--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 listbox together with every
other floating surface; the trigger it drops from is untouched and keeps the hooks below.

`--lr-positioning-strategy` (16.0.0) is the same cascading `absolute`/`fixed` override
`<lr-popover>` (`llms/components/lr-popover.md`) documents, read from computed style each time the
listbox is (re)positioned. An explicit `positioning-strategy`/`hoist` on the instance always wins
over it; otherwise it wins over this control's own mirrored `absolute` default. One declaration on
`:root`, a theme, or a single clipping ancestor (an `overflow: hidden` card or scroller) changes
every unset select beneath it instead of authoring `positioning-strategy`/`hoist` on each instance.

The trigger's own resting surface has hooks as of 16.0.0: `--lr-select-trigger-fill` (default
`var(--lr-color-surface)`) and `--lr-select-trigger-border-color` (default
`var(--lr-color-border)`). Both are read by **every** appearance, each falling back to that
appearance's own default (`var(--lr-color-surface-raised)` for `filled`/`filled-outlined`,
`transparent` for `plain`, `var(--lr-color-brand)` for `accent`), so one value retints the trigger
whichever treatment it is wearing. `--lr-select-trigger-hover-border-color` (default: the resting
`--lr-select-trigger-border-color`) moves the edge under the pointer; unset, the hovered border
stays exactly where the resting state left it, as it always has.

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.

The trigger's pointer/open states have component-scoped hooks too:
`--lr-select-trigger-hover-bg` (default `var(--lr-color-brand-quiet)` for the quiet appearances),
`--lr-select-trigger-active-bg` (default a deeper mix from the hover background), and
`--lr-select-open-border-color` (default `var(--lr-color-brand)`). Accent keeps its louder mixed
hover fallback when the hook is unset. These inline fallbacks let one select be rethemed without
changing the shared brand tokens used by other controls.

`--lr-select-trigger-min-height` is live at **every** tier, the default `m` included, where it is
`2.5rem` — byte-identical to `lr-input`'s and `lr-combobox`'s own `m` floor, so the three controls
agree at that tier. It used to be dead code: the component declared `--lr-select-trigger-height:
auto` on `:host`, and a _declared_ value (`auto` is one) wins over the `var()` fallback arm that
the floor lives in, so the floor never applied and four extra specificity rules existed only to
patch it back for four of the tiers. Those rules are gone.

`--lr-select-trigger-height` pins an **exact** trigger height — both a floor and a cap — e.g. to
pixel-match a sibling text field in the same toolbar row. It is **undeclared by default**, which is
exactly what keeps the per-tier floor alive; see "exact-height hatches" under `lr-input`. Because
the component never declares it, it can be set inline, from an ancestor, or from an outer-tree
rule. One consequence worth knowing when testing:
`getComputedStyle(el).getPropertyValue('--lr-select-trigger-height')` now reads `''` rather than
`'auto'` — assert the rendered `min-block-size`/`block-size` instead of the property string.

`--lr-select-option-active-bg` (default `var(--lr-color-brand-quiet)`) recolors the background of a
hovered or keyboard-active `[part='option']` row. Not declared on `:host`, so a value set on any
ancestor is never shadowed — retheme just this row state without hijacking the shared
`--lr-color-brand-quiet` token every other component's own hover/active state also reads. Same
knob `lr-combobox`'s own `--lr-combobox-option-active-bg` provides.

The currently-**selected** row (`[part='option'][aria-selected='true']`) has its own matching set:
`--lr-select-option-selected-bg` (default `transparent`), `--lr-select-option-selected-border` and
`--lr-select-option-selected-color` (both default `var(--lr-color-brand)`), and
`--lr-select-option-selected-font-weight` (default `var(--lr-font-weight-semibold)`). 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. Note the shadow-parts
spec forbids an attribute selector after `::part()` — `::part(option)[aria-selected='true']` is
invalid CSS and never matches — which is exactly why these tokens exist.

`--lr-select-unknown-value-border-style` (default `dashed`) and
`--lr-select-unknown-value-border-color` (default `var(--lr-color-border)`) retheme the
`[part='unknown-value']` badge described above under "Unknown committed values".
`--lr-select-option-badge-bg` (default `var(--lr-color-brand-quiet)`) retints the
`[part='option-badge']` "not in catalog" badge `show-unknown-option` renders on the synthetic
listbox row, independent of the unknown-value chip's border above.

**Optional peer deps:** none.

```html
<lr-select id="sel" label="Fruit" placeholder="Pick one…">
  <lr-option value="a">Apple</lr-option>
  <lr-option value="b" selected>Banana</lr-option>
</lr-select>
<script type="module">
  document
    .getElementById("sel")
    .addEventListener("change", (e) => console.log(e.target.value));
</script>
```

```html
<!-- Multi-select with chips, a cap, and a clear button: -->
<lr-select
  id="tags"
  label="Labels"
  multiple
  with-clear
  max-options-visible="2"
  appearance="filled"
  pill
>
  <lr-option value="bug">Bug</lr-option>
  <lr-option value="docs">Docs</lr-option>
  <lr-option value="perf">Performance</lr-option>
</lr-select>
<script type="module">
  import "@aceshooting/lyra-ui/components/forms/select/select.js";
  const sel = document.getElementById("tags");
  // A custom chip: return a node, and re-declare part="tag" to keep the built-in styling hooks.
  sel.getTag = (option, index) => `${index + 1}. ${option.label}`; // a string renders as text
  sel.addEventListener("change", (e) => console.log(e.currentTarget.value)); // string[] in multiple mode
  sel.addEventListener("lr-clear", () => console.log("selection emptied"));
</script>
```

**Known gotchas:**

- The trigger keeps real DOM focus throughout — the listbox's "active" row is conveyed via
  `aria-activedescendant`, never actual focus, matching the WAI-ARIA "select-only combobox" pattern
  (as opposed to `lr-combobox`'s editable-input pattern).
- While open, live option reorders preserve the active row by option identity. Removing or
  disabling/inerting that option rehomes activity to the nearest available survivor (preferring the
  following row on a tie); removing every available option clears `aria-activedescendant`.
- One availability rule governs keyboard navigation, type-ahead, single-option auto-commit,
  pointer selection, and each proxy row's `aria-disabled`: an option is unavailable when it is
  disabled, inert itself, or inside an inert ancestor. Pointer activation of such a row is a no-op.
- The floating listbox participates in Lyra's shared nonmodal overlay stack. Its computed
  `--lr-overlay-stack-index`, Escape owner, capture-phase outside-pointer dismissal, and focus
  handoff follow the newest open overlay rather than DOM order. A single dismissal therefore closes
  only the visual top layer, even when target code stops pointer bubbling.
- No typing-to-filter, but a printable keypress still jumps to (while open) or directly selects
  (while closed) the next available option whose label starts with what's been typed, matching a
  native `<select>`'s own type-ahead; the buffer resets ~500ms after the last keystroke. In closed
  `multiple` mode the bounded search skips already-selected option occurrences and continues to a
  later unselected match, including a distinct row that carries the same public string value.
- `<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>`. Later
  `defaultSelected`/attribute changes update that reset baseline without clobbering a dirty live
  selection; user picks and direct `option.selected` writes remain live-only and never rewrite the
  attribute/default.
- `aria-required` on the trigger reflects `required` immediately; `aria-invalid` only reflects once
  the trigger has been blurred (touched) at least once, mirroring `lr-combobox`'s own input.
  Blurring the trigger (Tab away) closes an open listbox, the same as a native `<select>`'s popup,
  without restoring focus and undoing the browser's native Tab/Shift+Tab destination.
- The trigger's accessible name now checks a host-level `aria-label` attribute first, before falling
  back to `label`/`placeholder`/`"Select"` — a plain `aria-label` on `<lr-select>` is no longer
  silently ignored. Precedence is presence-based: `aria-label=""` remains an explicit empty
  override rather than restoring any fallback.
- With `autoCommitSingleOption` set, a select with exactly one available option never exposes
  `role="combobox"`/opens a listbox at all — see "Single-option auto-commit" above.
  Testing/automation code that always expects a `role="combobox"` trigger, or that opens the
  listbox before asserting on a row, either needs at least two available options or should leave
  `autoCommitSingleOption` unset to observe the normal dropdown chrome.

---
