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

# `lr-model-select`

- **Import** `import '@aceshooting/lyra-ui/components/lr-model-select.js';` (stable tag alias; registers the tag)
- **Class** `LyraModelSelect`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/model-select/model-select.class.js`
- **Family** `components/conversation/` — 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** 15 parts, 26 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-model-select`

A provider/model picker that renders as a closed dropdown when a fixed `catalog` is available, or as a
filterable free-text combobox when it isn't (or when `allow-custom` explicitly permits typing something
outside the catalog). Form-associated (hand-rolled internals via `attachInternals()` directly, not the
shared `FormAssociated` mixin — same reasoning as `lr-combobox`/`lr-select`: see the shared-foundation
notes). Built on the same trigger-button/`aria-activedescendant` listbox technique `<lr-select>` uses
and the filter-as-you-type suggestion-popup technique `<lr-combobox>` uses, without composing either
element. First-party invention (no Web Awesome equivalent).
Session-history/autofill restoration synchronously restores the model id and form entry without
emitting `lr-change`.

Removing `label`, `hint`, or `error-text` clears that optional copy in both catalog and free-text
modes. Removed string properties retain `null` readback, and later assignments restore their
content.

Composing key events (`isComposing` or legacy `keyCode === 229`) remain with the catalog query or
custom-value editor: they do not navigate options, commit a selection or custom value, or close the
popup. Ordinary keyboard behavior resumes after composition.

When `catalog`/`allowCustom` replaces a focused trigger with the free-text input or vice versa,
focus follows the available replacement. If the new owner is disabled or inert, focus returns to
the available element that led into the picker, or to the stable `form-control` owner when no
return target exists. This repair emits no action/value events and never overrides a newer external
focus move.

**Exported types:**

- `LyraCatalogEntry { id: string; label: string; disabled?: boolean }` — the shared minimum row
  vocabulary. `disabled` marks a row non-actionable: `aria-disabled="true"` replaces its
  selected/active affordances, activating it (click or keyboard) commits nothing and changes no
  state, and arrow-key/Home/End active-descendant navigation steps past it instead of landing on
  it. Omitted or `false` renders the row exactly as before this field existed.
- `LyraCatalog<T extends LyraCatalogEntry = LyraCatalogEntry> = readonly string[] | readonly T[]`
  — a homogeneous catalog shared by model-select, voice-picker, and composed controls. String
  shorthand uses the same string for both id and label; readonly tuples/arrays are accepted. Ids
  must be nonempty and unique and object rows require a nonblank string label: malformed rows and
  later duplicates are omitted first-wins before mode selection, rendering, focus reconciliation,
  selection, or preview lookup.
- `LyraModelCatalogEntry extends LyraCatalogEntry { icon?: string }` — one model row. An
  optional literal `icon` (for example, an emoji) renders decoratively before `label`; it does not
  change the option's accessible name.

**Properties:**

- `provider: string = ''` — informational only (e.g. `'ollama'`); rendered as a small leading badge.
- `catalog?: LyraCatalog<LyraModelCatalogEntry>` (attribute: false) — the full model list. Omit (or
  leave empty) to fall back to plain free-text entry. Ids use the shared unique, nonempty,
  first-wins catalog rule above. The array is clone-owned, bounded, and frozen; reassign a new
  catalog array after changing its rows.
- `allowCustom: boolean = false` (attribute `allow-custom`, reflected) — let the user type/commit a
  value that isn't in `catalog`, even when `catalog` is non-empty.
- `label: string = ''` — optional visible title above the control, rendered alongside the `label`
  slot in a `[part="form-control-label"]` `<label>` paired with the active control's id. A host
  `aria-label` remains the authoritative override by presence; otherwise either visible-label
  source names the control through the native label association. Leaving both sources empty keeps
  the original `aria-label || placeholder || 'Model'` accessible-name chain untouched.
- `hint: string = ''` — hint text below the field. Unset (the default): no hint chrome renders.
- `errorText: string = ''` (attribute `error-text`) — error text below the field (overridden by
  slotted `error` content). Unset (the default): no error chrome renders.
- `placeholder: string = ''`
- `spellcheck: boolean = true` — forwarded to the free-text mode's native `<input>`; no effect in
  closed-dropdown mode (no native text input there). `spellcheck="false"` is parsed as `false` (a
  custom converter, not Lit's presence-based `type: Boolean`, so the literal attribute string is
  honored — matches `<lr-textarea>`/`<lr-date-input>`).
- `autocapitalize: string = ''` — forwarded to the free-text mode's native `<input>`; empty omits
  the attribute.
- `autoCorrect: string = ''` (attribute `autocorrect`) — forwarded to the free-text mode's native
  `<input>`'s own `autocorrect` (Safari/WebKit-specific); empty omits the attribute. Named
  `autoCorrect` in JS purely to dodge a `lib.dom.d.ts` typing collision — the wire attribute is
  still plain `autocorrect`.
- `autocomplete: string = 'off'`, `inputMode: string = ''` (attribute `inputmode`), and
  `enterKeyHint: string = ''` (attribute `enterkeyhint`) — forwarded to the free-text input;
  they have no effect in closed-dropdown mode
- `name: string = ''` (reflected)
- `disabled: boolean = false` (reflected)
- `readonly: boolean = false` (reflected) — prevents user typing and catalog
  commits while retaining focus, popup navigation, selection/copy, submission/reset, and programmatic
  writes.
- `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
- `open: boolean = false` (reflected)
- `size: LyraSize = 'm'` (reflected) — visual size on the library-wide ladder: `2xs`/`xs`/`s`/`m`/
  `l`/`xl`, plus `small`/`medium`/`large` as accepted spellings of `s`/`m`/`l`, so markup migrated
  from Web Awesome or Shoelace needs no attribute rewrite. It scales
  `[part="trigger"]`/`[part="combobox"]`'s padding/min-height/font-size through the shared
  `--lr-form-control-*` knobs, so a model select sits at the same height as the `lr-select`,
  `lr-input` or `lr-button` beside it in a toolbar row at every tier, plus `[part="expand-icon"]`'s
  box size (see the themeable custom properties below).
- `value: string` — getter/setter (hand-rolled, not the `FormAssociated` mixin); the current model id,
  `''` when nothing is selected. Writing it calls `internals.setFormValue()` synchronously. A named,
  untouched model-select contributes `''` to `FormData` instead of omitting its key.
- `defaultValue: string = ''` (attribute `value`, reflected) — the current reset default. The live
  `value` is non-reflecting and dirty, so changing the default/attribute cannot overwrite it until
  `form.reset()` restores the current default.
- `customError: string | null = null` (attribute `custom-error`) — reflected consumer validation
  message.
- `form: HTMLFormElement | null = null` — browser-resolved owner (and an assignable external owner);
  readonly `labels: NodeList`, `validity: ValidityState`, `validationMessage: string`,
  `willValidate: boolean`, and `effectiveDisabled: boolean` expose the native FACE state.
- `input: HTMLInputElement | null` — readonly native input reference in free-text mode; `null` in
  closed-dropdown mode and before render.
- `selectionStart: number | null`, `selectionEnd: number | null`, and `selectionDirection:
LyraModelSelectSelectionDirection | null` — native caret/selection state in free-text mode;
  getters return `null` and setters are inert when no text input is rendered.

**Methods:** `click()` (override) — forwards to whichever internal control the active mode renders,
since `HTMLElement.prototype.click()` is otherwise a no-op on a custom element with no native click
semantics of its own (mirrors `<lr-button>`'s identical host `click()` forwarding, so a generic
form-automation helper or another component calling `.click()` on the host actually opens the picker
instead of silently doing nothing). Closed-dropdown mode forwards a real `.click()` to the trigger
`<button>`, whose own `@click` handler opens it. Free-text mode forwards `.click()` to the combobox
`<input>`, then explicitly calls `.focus()`: unlike a genuine pointer click,
`HTMLElement.click()` never moves focus (that's a `mousedown` side effect the browser applies only
to real pointer interaction), and this mode's open behavior is wired to the input's `focus` event
(`onInputFocus`), not a `click` handler on the input itself.

`focus(options?)` and `blur()` forward to the active semantic control in either rendering mode.
If a catalog/`allowCustom` update replaces that control while it owns focus, focus follows from the
closed trigger to the free-text input (or back again). A newer external focus destination is never
overridden.

`select()` and `setSelectionRange()` forward to the native input in free-text mode.
`setRangeText()` applies the native range edit and silently synchronizes `value`, the form entry,
and validity; none of these editing methods has an effect in closed-dropdown mode or before render.

`getForm()` returns the browser-resolved owning form. `checkValidity()` / `reportValidity()` behave
as on any form-associated control.
`setCustomValidity(message: string)` is the standard channel for a server-side rejection ("that
model was retired by the provider") that no client-side constraint can express: a non-empty
`message` raises `customError` and becomes `validationMessage`, so the control fails
`checkValidity()`, blocks submission, and matches `:state(invalid)`; `''` clears it. Clearing
restores the control's own computed validity rather than forcing it valid — a `required` picker with
no value stays `valueMissing` — and the custom error survives every intrinsic recomputation in
between (each `value`/`required` change) and a `form.reset()`, matching a native control. The
message is caller-supplied content, used verbatim and never localized.

**Mode switching:** `closedMode` (private) is `true` whenever `normalizedCatalog.length > 0 &&
!allowCustom` — a non-empty `catalog` with `allowCustom` left `false` renders the closed dropdown
trigger-button UI (`[part="trigger"]`, `role="combobox"` on a `<button>`, no typing). Any other
combination (`catalog` empty/unset, or `allowCustom` true) renders the free-text `<input>` UI
(`[part="combobox"]`/`[part="combobox-input"]`) with live substring filtering against the catalog (id or
label, case-insensitive). The mode is re-evaluated on every render, so toggling `allowCustom` or clearing
`catalog` at runtime switches modes live, repositioning the shared `[part="listbox"]` popover against
whichever element is the active anchor. Replacing `catalog` while free-text mode remains open
refilters suggestions without erasing the user's current draft; controlled `value` changes and
actual mode switches still rebase the input to the committed value.

A `value` that isn't present in `catalog` (e.g. a model id saved from a provider whose live catalog has
since changed) is never silently dropped: it's appended to the rendered option list as a synthetic,
visually-distinct row (dashed border, italic label, "not in catalog" badge) computed fresh from
`catalog` + `value` on every access, without ever mutating the `catalog` property itself.

**Events:**

- `lr-change` (`detail: { value: string; inCatalog: boolean }` — fired when a value is selected
  from the listbox or committed in free-text mode; `inCatalog` reflects whether that value was
  actually present in `normalizedCatalog`, so a consumer can flag a freshly-typed custom value
  distinctly from a real catalog pick)
- `change` (`Event`, no detail) — an owner-realm native event fired on a committed value alongside
  `lr-change`, mirroring `<lr-select>`/`<lr-combobox>`'s value-change pair so native form bindings
  and framework `v-model` handlers behave consistently across the picker family.
- `input` — a payload-preserving owner-realm `InputEvent` for each free-text edit, and a plain
  owner-realm `Event` fired immediately before `change` when either mode commits a value.
- `blur` / `focus` (no detail) — one owner-realm native `FocusEvent` re-dispatched from the active
  control in either mode (the closed trigger button or free-text input), retaining `relatedTarget`
  and bubbling/composed unlike the shadow-internal original.
- `lr-invalid` (no detail) — the single bubbling/composed alias of a failed native validity check.

**Slots:** `label` (custom visible label content), `hint` (custom hint content), `error` (custom
error content).

**The required marker and barred validity.** With `required` set and either visible-label source
non-empty,
`[part="form-control-label"]` paints the library's shared required marker — the same `::after` rule
every labelled control in the library uses, 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` (see `llms/shared.md` → "The required-field marker"). With no visible label there
is nothing to mark and no stray glyph is rendered. Correspondingly, while the picker is barred from
constraint validation — its own `disabled` or `readonly`, or an ancestor `<fieldset disabled>` — it
reports no violation and publishes neither `:state(invalid)` nor
`:state(user-invalid)`, matching native `:invalid`. `required`/`optional` keep publishing.

**CSS parts:** `form-control` (the complete label, control, hint, error, and listbox frame),
`form-control-label` (the `<label>` element containing the `label` property and slot), `trigger` (closed-dropdown mode's
`<button role="combobox">`, also its positioning anchor), `combobox` (free-text mode's input
container, also its positioning anchor), `combobox-input` (the free-text `<input>`),
`provider-badge` (the optional leading `provider` label), `listbox` (the options popover, shared by
both modes), `option`, `option-icon` (an object-shaped catalog row's optional decorative leading
icon), `option-label`, `option-badge` (the "not in catalog" badge on a synthetic stale-value row),
`empty` (the no-matching-models message), `expand-icon` (the dropdown chevron, present in both modes), `hint` (the hint
message), `error` (the error message)

**Themeable custom properties:** `--lr-model-select-trigger-padding` (default
`var(--lr-form-control-padding-block) var(--lr-form-control-padding-inline)`) —
`[part="trigger"]`/`[part="combobox"]`'s padding shorthand. `--lr-model-select-trigger-min-height`
(default `var(--lr-form-control-height)`) — their block-size floor, and
`--lr-model-select-trigger-height` (unset by default) — an _exact_ block size that both floors and
caps them, taking precedence over the floor, for pixel-matching a sibling field in the same toolbar
row. `--lr-model-select-font-size` (default `var(--lr-form-control-font-size)`) — their font size.
`--lr-model-select-trigger-padding`, `--lr-model-select-trigger-min-height` and
`--lr-model-select-font-size` are indirections onto the shared `--lr-form-control-*` scale rather
than literal values: the public property surface is unchanged, but the numbers come from the one
ladder every other control sizes against, so a tier is restated in exactly one place.
`--lr-model-select-expand-size` (default `var(--lr-size-1-75rem)`) — `[part="expand-icon"]`'s
decorative box size (clamped against `--lr-icon-button-size` via `min()`); this one is a glyph box
rather than a control metric, so the shared ladder has no equivalent and its per-tier values stay
local. `size` is the primary lever; override a cssprop directly only to retune a single element or
step outside the scale entirely. `--lr-model-select-gap` (default `var(--lr-space-xs)`) controls the
child gap in the trigger, combobox, and option rows; `--lr-model-select-radius` (default
`var(--lr-radius)`) controls the corner radius of the trigger, combobox, listbox, and option rows.
Both remain inheritable fallback arms, so set them on an ancestor to retheme a group without
changing unrelated controls. `--lr-model-select-trigger-border-color` (default
`var(--lr-color-border)`) and `--lr-model-select-trigger-fill` (default `var(--lr-color-surface)`)
control the trigger/combobox's resting border and background, independent of the open-state border
below. `--lr-model-select-open-border-color` (default `var(--lr-color-brand)`)
controls the trigger border while the listbox is open. A synthetic stale-value row has independent
`--lr-model-select-option-synthetic-border-style` (default `dashed`) and
`--lr-model-select-option-synthetic-border-color` (default `var(--lr-color-border)`) hooks.
`--lr-model-select-option-active-bg` (default `var(--lr-color-brand-quiet)`) — background of a
hovered or keyboard-active `[part="option"]` row; declared as a `var()` fallback at the point of
use, not on `:host`, so it isn't tied to `size`. The selected row
(`[part="option"][aria-selected="true"]`) has the matching set
`--lr-model-select-option-selected-bg` (default `transparent`),
`--lr-model-select-option-selected-border` and `--lr-model-select-option-selected-color` (both
`var(--lr-color-brand)`), and `--lr-model-select-option-selected-font-weight`
(`var(--lr-font-weight-semibold)`), all inline `var()` fallbacks so the selected row is rethemeable
without hijacking `--lr-color-brand`. `--lr-model-select-option-disabled-opacity` (default `0.5`)
— opacity of an option row whose catalog entry sets `disabled`. `--lr-model-select-max-inline-size` (default
`var(--lr-size-24rem)`) publishes the host's own width ceiling, which was previously a hard-wired
literal: set a length to retune it, or `none` to let the control fill its container the way
`lr-select` does. Like every other name here it is read as a `var()` fallback and never declared on
`:host`, so a value set on `:root` or any ancestor still reaches it. Plus shared tokens —
`--lr-space-xs/-s`, `--lr-color-border/-surface/-brand/-brand-quiet/-text-quiet`, `--lr-radius`,
`--lr-shadow`, `--lr-focus-ring-width/-color/-offset`, `--lr-icon-button-size`,
`--lr-transition-fast`, `--lr-opacity-disabled`.

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)`) 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)`) reaches it only as the middle arm of
`--lr-model-select-radius`, which still wins when set.

**Optional peer deps:** none.

```html
<lr-model-select provider="openai" value="gpt-4o" placeholder="Choose a model…"></lr-model-select>

<!-- No fixed catalog yet: falls back to free-text entry -->
<lr-model-select provider="ollama" placeholder="Type a model id…" allow-custom></lr-model-select>
<script type="module">
  const select = document.querySelector("lr-model-select");
  select.catalog = [
    { id: "gpt-4o", label: "GPT-4o", icon: "✦" },
    { id: "gpt-4o-mini", label: "GPT-4o mini" },
  ];
  select.addEventListener("lr-change", (e) => setModel(e.detail.value, e.detail.inCatalog));
</script>
```

**Known gotchas:**

- `catalog` must be homogeneous — an array of plain strings, or an array of `{ id, label, icon? }`
  objects, not a mix; `LyraCatalog<T>` is a union of two readonly array _types_, not an array of a
  union item type.
- The synthetic "not in catalog" row only ever appears when `catalog` is non-empty and `value` isn't one
  of its ids — with no `catalog` at all, there's no catalog list to diff `value` against, so no badge.
- `value`/form-association here is hand-rolled via `attachInternals()` directly, not the shared
  `FormAssociated` mixin — mirrors `lr-combobox`/`lr-select`'s identical divergence.
- `aria-invalid`/`data-invalid` only reflect once the control has been blurred (touched) at least once,
  matching `lr-select`'s identical pattern — validity styling never flashes on first render.
- In free-text mode, the input's displayed text is `query` only while `open`; while closed it shows the
  committed value's label — so setting `.value` programmatically doesn't require also touching the live
  typed text.
- `hint`/`errorText` mirror `<lr-select>`'s identical chrome, including the `aria-describedby` wiring
  to the rendered `hint`/`error` ids — set on whichever element (`trigger` or `combobox-input`) owns
  `role="combobox"` for the active mode.

---
