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

# `lr-voice-picker`

- **Import** `import '@aceshooting/lyra-ui/components/lr-voice-picker.js';` (stable tag alias; registers the tag)
- **Class** `LyraVoicePicker`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/voice-picker/voice-picker.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** 17 parts, 28 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-voice-picker`

A TTS voice selector over a host-supplied `catalog`, mirroring `lr-model-select`'s
closed-dropdown/free-text-combobox dual mode, stale-value handling, and form-association verbatim
(see that section for the full mode-switching contract this one shares), extended with a
TTS-agnostic preview affordance. Each new target is event-first: its cancelable
`lr-preview-request` fires before that target can start. Left un-prevented, a `previewUrl` plays
through one internal native `<audio>` (validated by `safeMediaSrc()` first); `preventDefault()` or no
URL leaves playback to the host's own TTS. Requesting the same voice while it's already playing
internally stops it instead of re-requesting; requesting a different voice retires the old resource
and publishes its terminal change before dispatching the new request. Internal playback becomes
public only after the still-current `audio.play()` promise
fulfills; a rejected pending play emits neither a false start nor a false stop. Committed-value,
active-option, and catalog changes likewise retire an internal preview before the visible preview
control changes target; closing or filtering also retires a row-owned preview once no rendered
control represents it. Does not synthesize speech, fetch catalogs, or persist selection; not a
persona picker; `lr-model-select` stays for LLMs.

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.

**Exported types:** `LyraVoiceCatalogEntry extends LyraCatalogEntry { language?: string;
description?: string; previewUrl?: string }` — `language`/`description` render as a quiet
`[part="option-meta"]` second line. Voice catalogs use the shared
`LyraCatalog<LyraVoiceCatalogEntry>` homogeneous readonly union documented under `lr-model-select`,
including the shared `disabled` field: a disabled voice row cannot be selected by click or keyboard
and is stepped over by arrow-key/Home/End navigation, but its own `[part="option-preview"]` stays
independently clickable, since previewing a voice is a separate affordance from selecting it. The
public `size` property uses `LyraSize`, including the long-form aliases.
`LyraVoicePickerSelectionDirection = 'forward' | 'backward' | 'none'` is the native
selection direction exposed in free-text mode.

**Properties:** `provider: string = ''` — informational only (e.g. `'elevenlabs'`); rendered as a
small leading badge. `catalog?: LyraCatalog<LyraVoiceCatalogEntry>` (attribute: false) — the full
voice list; omit (or leave empty) to fall back to plain free-text entry; replacing it retires any
internal preview before the rendered candidate changes. Ids use the shared unique, nonempty,
first-wins catalog rule documented under `lr-model-select`, including preview lookup. Assignments
become bounded clone-owned frozen snapshots; create and reassign a new array after row changes.
`allowCustom: boolean = false` (attribute
`allow-custom`, reflected) — let the user type/commit a value that isn't in `catalog`. `preview:
boolean = true` (reflected) — whether to render preview affordances at all. `label: string = ''`,
`hint: string = ''`, `errorText: string = ''` (attribute `error-text`), `placeholder: string = ''`,
`spellcheck: boolean = true` (string-aware converter, same as `lr-model-select`), `autocapitalize:
string = ''`, `autoCorrect: string = ''` (attribute `autocorrect`), `autocomplete: string = 'off'`,
`inputMode: string = ''` (attribute `inputmode`), `enterKeyHint: string = ''` (attribute
`enterkeyhint`), and `open: boolean = false` (reflected) — all mirror `lr-model-select`'s
identically-named properties. `size: LyraSize = 'm'` (reflected) selects the shared
`2xs`/`xs`/`s`/`m`/`l`/`xl` control ladder; `small`/`medium`/`large` render as aliases of
`s`/`m`/`l`. It scales both closed and free-text field chrome through the shared
`--lr-form-control-*` metrics. The separate preview button retains the library-wide 40px minimum
hit area at compact tiers and grows with `l`/`xl`.

`readonly: boolean = false` (reflected) mirrors `lr-model-select`: it blocks user typing and catalog
commits while preserving focus, popup navigation, selection/copy, reset, submission, programmatic
writes, and the preview actions.

The `label` property and `label` slot share one native label in the standard `form-control` frame.
Slotted label content participates in the accessible name in both closed-dropdown and free-text
modes; an explicit host `aria-label` remains the highest-precedence name.

**Form association:** hand-rolled via `attachInternals()`, mirroring `lr-model-select`: live,
non-reflecting `value: string = ''` (the current voice id), reflected
`defaultValue: string = ''` (attribute `value`, the current reset default), reflected
`customError: string | null = null` (`custom-error`), `name`, `disabled`
(reflected), and `required` (reflected — enforced via `internals.setValidity()`). Their exact
signatures are `name: string = ''`, `disabled: boolean = false`, and `required: boolean = false`.
It also exposes `form: HTMLFormElement | null = null`, readonly `labels: NodeList`, `validity:
ValidityState`, `validationMessage: string`, `willValidate: boolean`, and `effectiveDisabled:
boolean`, plus
`checkValidity()`/`reportValidity()` and `setCustomValidity(message: string)`. The last is the
standard channel for a server-side rejection ("that voice is not enabled for your account") 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 only that consumer layer, leaving a `required` picker with no value
still `valueMissing`. The custom error survives every intrinsic recomputation in between and a
`form.reset()`, matching a native control, and the message is used verbatim, never localized.
`getForm()` returns the browser-resolved owning form.

**Methods:** `click()` (override) — closed-dropdown mode forwards a real `.click()` to the trigger
`<button>`, whose own `@click` handler opens it; free-text mode calls `.focus()` on the combobox
`<input>`, since a synthetic `.click()` on a text input never dispatches `focus` the way a real
click's `mousedown` default action does, and this mode's open behavior is wired to the input's
native `focus` event, not a `click` handler on the input itself. Mirrors `<lr-button>`'s host
`click()` forwarding while retaining voice-picker's focus-only free-text behavior.
`focus(options?)` and `blur()` forward to whichever internal control the active mode renders.
In free-text mode, `input: HTMLInputElement | null`, `selectionStart: number | null`,
`selectionEnd: number | null`, and `selectionDirection: LyraVoicePickerSelectionDirection | null`
mirror the native input. `select()`, `setSelectionRange(start, end, direction?)`, and overloaded
`setRangeText(replacement[, start, end, selectMode])` likewise forward native editing operations;
`setRangeText()` synchronizes the picker `value`, form entry, and validity without emitting user
`input`/`change` events. Those text APIs return `null` or are no-ops in closed-dropdown mode and
before the input renders.

**Events:** `lr-change` — `detail: { value, inCatalog }`. `lr-preview-request` — `detail: {
voiceId, previewUrl? }`, cancelable. `lr-preview-change` — `detail: { voiceId }`, internal playback
started (`voiceId`, only after `play()` fulfills) or stopped (`null`); a pending rejection emits
neither. Plus owner-realm native `input`/`change` (retaining each free-text `InputEvent` payload)
and native `FocusEvent` `focus`/`blur` (retaining `relatedTarget`). The trigger/input, listbox popup,
and sibling preview control form one focus boundary, so
moving within them does not close or touch the picker and only leaving the component emits the
outer pair. One bubbling/composed `lr-invalid` alias fires when native validity fails.

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

**The required marker and barred validity.** Identical to `lr-model-select`'s (see that section): a
`required` picker with a non-empty `label` paints the shared required marker on
`[part="form-control-label"]`, retunable or suppressible through
`--lr-form-control-required-content`, `--lr-form-control-required-color` and
`--lr-form-control-required-offset`; and while the picker is barred from
constraint validation (own `disabled` or `readonly`, or an ancestor `<fieldset disabled>`) it
reports no violation and publishes neither `:state(invalid)` nor
`:state(user-invalid)`.

**CSS parts:** `form-control` (the complete field frame), `form-control-label`, `trigger` (closed-dropdown mode), `combobox`/`combobox-input`
(free-text mode), `provider-badge`, `listbox`, `option`, `option-label`, `option-meta` (the quiet
`language · description` second line), `option-badge` (the "not in catalog" badge on a synthetic
stale-value row), `option-preview` (a pointer-only per-row preview icon, `tabindex="-1"`,
`aria-hidden`), `preview-button` (the standalone, keyboard-reachable preview toggle beside the
trigger), `expand-icon`, `empty`, `hint`, `error`.

```html
<lr-voice-picker provider="elevenlabs" allow-custom></lr-voice-picker>
<script type="module">
  const picker = document.querySelector("lr-voice-picker");
  picker.catalog = voices;
  picker.addEventListener("lr-change", (e) => setVoice(e.detail.value));
  picker.addEventListener("lr-preview-request", (e) => {
    if (!e.detail.previewUrl) {
      e.preventDefault();
      playSample(e.detail.voiceId);
    }
  });
</script>
```

**Known gotchas:**

- Listbox options must not contain tab-focusable controls, so preview is accessible via the
  standalone `[part="preview-button"]` beside the trigger (previews the active option while open,
  else the committed value) — the per-row `[part="option-preview"]` icon is a pointer-only
  duplicate (`tabindex="-1"`, `aria-hidden="true"`).
- `catalog` must be homogeneous — the same shared `LyraCatalog<T>` constraint documented for
  `lr-model-select`.

**Additional API surface:**

- `--lr-voice-picker-max-inline-size` — The host's own width ceiling, previously a hard-wired
  literal. Set a length to retune it, or `none` to fill the container the way `lr-select` does.
  Default: `var(--lr-size-24rem)`.
- `--lr-voice-picker-trigger-min-height` — Trigger/combobox block-size floor, reading the shared
  form-control height ladder so retuning `--lr-theme-form-control-height-*` moves this control and
  every sibling field together. Default: `var(--lr-form-control-height)`.
- `--lr-voice-picker-trigger-height` — An _exact_ trigger/combobox height that both floors and caps
  the control, for pixel-matching a sibling field in the same toolbar row. Takes precedence over
  `--lr-voice-picker-trigger-min-height`. Unset by default.
- The `[part="preview-button"]` action follows whichever of those two names is in play, not just
  the exact height: `.control-row` is `align-items: stretch`, and stretch never applies to an item
  with a definite cross size, so an action that tracked only `--lr-voice-picker-trigger-height`
  would sit short and top-aligned beside a field whose floor had been raised. Its own WCAG
  hit-area floor (`--lr-icon-button-size`) still wins below that, so pinning a short field cannot
  shrink the action's hit area.
- `--lr-voice-picker-gap` — Gap between the field and preview action, and between trigger,
  combobox, and option children. Default: `var(--lr-space-xs)`.
- `--lr-voice-picker-radius` — Trigger, combobox, listbox, option, and preview-action corner radius.
  Default: `var(--lr-form-control-radius)`.
- `--lr-overlay-surface` — Shared floating-surface fill, on the listbox. Default:
  `var(--lr-color-surface-overlay)`.
- `--lr-overlay-border` — Shared floating-surface edge colour, on the listbox. Default:
  `var(--lr-color-border)`.
- `--lr-overlay-radius` — Shared floating-surface corner radius, reached only as the middle arm of
  `--lr-voice-picker-radius`, which still wins when set. Default: `var(--lr-radius)`.
- `--lr-overlay-shadow-anchored` — Elevation of the anchored listbox. Default: `var(--lr-shadow-m)`.
  None of the four 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 in the library.
- `--lr-voice-picker-preview-active-border` — Active preview border. Default: `var(--lr-color-brand)`.
- `--lr-voice-picker-preview-active-color` — Active preview icon. Default: `var(--lr-color-brand)`.
- `--lr-voice-picker-trigger-border-color` — Resting trigger/combobox border color, independent of
  the open-state color below. Default: `var(--lr-color-border)`.
- `--lr-voice-picker-trigger-fill` — Resting trigger/combobox background. Default:
  `var(--lr-color-surface)`.
- `--lr-voice-picker-open-border-color` — Open trigger border color. Default: `var(--lr-color-brand)`.
- `--lr-voice-picker-option-active-bg` — Active option fill. Default: `var(--lr-color-brand-quiet)`.
- `--lr-voice-picker-option-selected-border` — Selected option border. Default: `var(--lr-color-brand)`.
- `--lr-voice-picker-option-selected-color` — Selected option text. Default: `var(--lr-color-brand)`.
- `--lr-voice-picker-option-selected-bg` — Selected option fill. Default: `transparent`.
- `--lr-voice-picker-option-selected-font-weight` — Selected option label weight. Default: `var(--lr-font-weight-semibold)`.
- `--lr-voice-picker-option-synthetic-border-style` — Synthetic stale-value row border style. Default: `dashed`.
- `--lr-voice-picker-option-synthetic-border-color` — Synthetic stale-value row border color. Default: `var(--lr-color-border)`.
- `--lr-voice-picker-option-disabled-opacity` — Opacity of an option row whose catalog entry sets `disabled`. Default: `0.5`.
- `--lr-voice-picker-option-synthetic-font-style` — Synthetic stale-value option-label font style. Default: `italic`.
- `--lr-voice-picker-preview-hover-bg` — Preview hover fill. Default: `var(--lr-color-brand-quiet)`.
- `--lr-voice-picker-preview-hover-color` — Preview hover icon. Default: `var(--lr-color-brand)`.
