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

# `lr-chat-composer`

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

---

## `lr-chat-composer`

The message input for a chat/agent conversation surface: an auto-resizing `<textarea>` plus a
built-in send/stop button. Deliberately no label/hint/error chrome — a composite chat-input
control, not a labeled form field; wrap it in your own layout for that context. **Form-associated**
via the shared `FormAssociated` mixin (same shape as
`<lr-date-input>`) — `name: string = ''`, `value: string = ''`, `disabled: boolean = false`
(reflected), `required: boolean = false` (reflected) are all inherited, along with
`defaultValue: string = ''`, `customError: string | null = null` (`custom-error`), readonly
`effectiveDisabled: boolean`, `form: HTMLFormElement | null = null`, readonly `labels: NodeList`,
`validity: ValidityState`, `validationMessage: string`, and `willValidate: boolean`, plus
`getForm()`, `checkValidity()`/`reportValidity()`, and `setCustomValidity()`, so it participates in
native `<form>` submission/validation/reset like any other text control.

The inner textarea mirrors `required` through native `required`/`aria-required`. Its
`aria-invalid` remains false until the textarea has been blurred, then follows the host's
`ElementInternals` validity as `value` or `required` changes. A native validation attempt also
reveals the invalid state, and `form.reset()` clears the touched presentation.

**Properties (own):**

- `placeholder: string = ''`
- `minRows: number = 1` (attribute `min-rows`) — floored to `1` at render time
- `maxRows: number = 8` (attribute `max-rows`) — floored to at least `minRows`
- `status: ChatComposerStatus = 'idle'` (reflected) — `'idle' | 'sending' | 'streaming'`; drives the
  built-in button's icon/label (send vs. stop) and whether Enter still submits. Invalid direct or
  attribute values normalize and reflect as `idle`.
- `frame: ChatComposerFrame = 'card'` (reflected) — container treatment, in the library-wide `frame`
  vocabulary (`'card' | 'plain'`; `ChatComposerFrame` is an alias of the shared `LyraFrame`).
  `'plain'` drops `[part="base"]`'s border, background, padding and corner radius so a composer
  docked inside a chat panel, dialog footer or toolbar that already draws its own border doesn't
  double it. Named `frame`, not `appearance`: `appearance` is the library's vocabulary for how a
  _control fills itself_, and one property name cannot mean both. The focus affordance is swapped,
  not dropped — see **Known gotchas**
- `actionsLayout: ChatComposerActionsLayout = 'inline'` (reflected, attribute `actions-layout`) —
  `'inline' | 'stacked'`. `'inline'` (the default) keeps today's single flex row. `'stacked'`
  arranges the `start` and `end` action slots as a compact one-column rail (`start` above `end`)
  beside a `textarea` that spans both rows — for a multi-row composer (a taller `min-rows`) where
  stretching the action buttons across the row's full height would otherwise look wrong. Layout
  only; slot content, empty-slot hiding and the built-in button are unchanged. Invalid direct or
  attribute values normalize and reflect as `'inline'`.
- `submitOnEnter: boolean = true` (reflected, attribute `submit-on-enter`) — when `false`, Enter
  always inserts a newline instead of submitting
- `submitDisabled: boolean = false` (reflected, attribute `submit-disabled`) — consumer-controlled
  validation gate; while idle, disables the built-in Send button and suppresses Enter/click
  submission without disabling the textarea or a busy-state Stop action
- `stoppable: boolean = true` (reflected) — when false, busy states keep a disabled Send button
  instead of exposing a Stop action
- `readOnly: boolean = false` (attribute `readonly`, reflected) — native read-only editing state;
  intrinsic required/length constraints are barred while set
- `minLength?: number` (attribute `minlength`) and `maxLength?: number` (attribute `maxlength`) —
  forwarded native text-length constraints; invalid/unset values impose no bound
- `accessibleLabel: string | null = null` (attribute `aria-label`) — names the internal textarea;
  wins over placeholder and the localized composer label
- `spellcheck: boolean = true` — forwarded to the internal `<textarea>`
- `autocapitalize: string = ''` — forwarded to the internal `<textarea>`; empty omits the attribute
- `autocorrect: boolean = true` — forwarded to the internal `<textarea>` and reflected canonically
  as `autocorrect="on"|"off"`; JavaScript writes also accept legacy `'off'`/`'false'` strings
- `wrap: 'hard' | 'soft' | 'off' = 'soft'`, `autocomplete: string = ''`, `inputMode: string = ''`
  (attribute `inputmode`), and `enterKeyHint: string = ''` (attribute `enterkeyhint`) — forwarded to
  the native textarea
- `input: HTMLTextAreaElement | null` — readonly reference to the rendered native textarea
- `selectionStart: number | null`, `selectionEnd: number | null`, and `selectionDirection:
'forward' | 'backward' | 'none' | null` — native selection getters/setters

**Methods (own):** `focus(options?)`, `blur()`, `select()`, `setSelectionRange()`, and
`setRangeText()` forward to the textarea; `click()` focuses it when the composer is not effectively
disabled. `setRangeText()` synchronizes reactive/form value and auto-sizing.
`checkValidity()`/`reportValidity()` remain inherited; `resetValidity()` clears consumer custom
validity and recomputes the current intrinsic constraints.

**Events:**

- `input` / `change` — one realm-correct native event relayed from the textarea per native edit or
  commit; `focus` / `blur` similarly preserve `relatedTarget`
- `lr-input` (`detail: { value }`) — fired on every user-driven edit of the textarea, not a
  programmatic `.value` assignment
- `lr-change` (`detail: { value }`) — paired with the native `change` event
- `lr-submit` (`detail: { value }`) — fired by Enter (per `submit-on-enter`) or the built-in
  button while `status="idle"` and `submitDisabled` is false. `detail.value` is always the exact, untrimmed current value;
  trimming is left to the consumer. Submitting does **not** clear `value`
- `lr-stop` (no detail) — fired by the built-in button while `status` is `"sending"` or
  `"streaming"`
- `lr-invalid` (no detail) — one bubbling/composed, cancelable alias when native validity fails;
  preventing it also prevents the native `invalid` event that produced it

**Slots:** `toolbar` (auxiliary controls, e.g. a model or provider picker, rendered inside the
frame above the chips tray and input row, sharing `[part="base"]`'s `:focus-within` affordance),
`start` (content before the textarea, e.g. an attach-file trigger button), `end` (overrides the
built-in send/stop button entirely when it has assigned content), `chips` (an attachment tray
rendered above the input row).

**CSS parts:** `base`, `toolbar`, `chips`, `row`, `start`, `textarea`, `end`, `send-glyph`,
`stop-glyph`, `action-button`

**Themeable custom properties:** `--lr-chat-composer-busy-bg` (default `var(--lr-color-text-quiet)`)
— `[part="action-button"]`'s background while `status` is `"sending"` or `"streaming"` (the busy/stop
treatment). Scoped separately from the shared `--lr-color-text-quiet` token, which
`[part="textarea"]`'s placeholder color also reads — overriding this cssprop recolors only the busy
button, not the placeholder too (the same shared-token-collision fix `<lr-chat-message>`'s own
user-bubble background pair documents). `--lr-chat-composer-background` (default
`var(--lr-color-surface)`), `--lr-chat-composer-border-color` (default `var(--lr-color-border)`) and
`--lr-chat-composer-radius` (default `var(--lr-radius)`) retune `[part="base"]`'s card chrome so a
composer docked into a themed panel can match it, with no `::part(base)` override. `--lr-chat-composer-padding`
(default `var(--lr-space-s)`) and `--lr-chat-composer-gap` (default `var(--lr-space-xs)`) retune
`[part="base"]`'s padding and the row gap between its stacked `toolbar`/`chips`/`row` sections; like
the chrome hooks above, `frame="plain"` still zeroes the padding. `--lr-chat-composer-focus-shadow`
(default `inset 0 calc(-1 * var(--lr-focus-ring-width)) 0 0 var(--lr-focus-ring-color)`) is the
`frame="plain"` focus underline painted on `[part="base"]:focus-within` — override it to reshape the
underline, or set it to `none` to cede focus chrome entirely to a wrapper you draw yourself. The
`:focus-within` border keeps its `--lr-color-brand` shift — that is state paint, not card chrome —
and `frame="plain"` still removes the border, radius and fill outright. Plus shared tokens
`--lr-space-xs`, `--lr-space-s`,
`--lr-color-border`, `--lr-color-surface`, `--lr-color-brand`, `--lr-color-on-brand`,
`--lr-color-text-quiet`, `--lr-radius`, `--lr-icon-button-size`, `--lr-transition-fast`,
`--lr-opacity-disabled`, `--lr-focus-ring-width`, `--lr-focus-ring-color`, `--lr-focus-ring-offset`.

**Optional peer deps:** none.

```html
<lr-chat-composer
  id="composer"
  name="message"
  placeholder="Message the assistant…"
  min-rows="1"
  max-rows="8"
></lr-chat-composer>
<script type="module">
  const composer = document.getElementById("composer");
  composer.addEventListener("lr-submit", (e) => {
    sendMessage(e.detail.value);
    composer.value = ""; // the composer never clears itself
    composer.status = "sending";
  });
  composer.addEventListener("lr-stop", () => stopGeneration());
</script>
```

Auto-resize (`resizeTextarea()`) reads the textarea's own _computed_ line-height/padding/border at
call time rather than assuming a fixed px-per-row constant, so it stays correct under a consumer's
own font-size/line-height overrides; it grows between `min-rows` and `max-rows`, then switches to
internal scrolling (`overflow-y: auto`) past `max-rows`. A `ResizeObserver` on the textarea itself
also re-runs this fit (one animation frame later, to avoid a `ResizeObserver`-loop console error)
whenever the textarea's own _width_ changes — a sidebar collapsing, a responsive breakpoint, a
window resize — even though `value`/`min-rows`/`max-rows` never did, since the same text now wraps
across a different number of lines. Enter-to-send only fires while
`submit-on-enter` is `true` (the default): plain Enter submits and prevents the default newline;
Shift+Enter always inserts a newline regardless of `submit-on-enter`; an IME composition step
(checked via `isComposing`, with `keyCode === 229` as a defense-in-depth fallback for browsers that
report `isComposing` inconsistently) is never treated as a submit trigger; and while `status` isn't
`"idle"`, Enter is left alone to insert a newline instead of resubmitting — the textarea itself is
**not** auto-disabled during `sending`/`streaming`, so a user can keep composing their next message
while a previous one is in flight. While idle, `submitDisabled` suppresses Enter/click submission
and disables only the built-in Send button; editing and busy-state Stop behavior remain available.

**Known gotchas:**

- `lr-submit` never clears `value` — the consumer must clear it once a submission is actually
  accepted, so a failed send can leave the text in place for retry.
- While `status !== 'idle'`, only the built-in button's behavior changes (it emits `lr-stop`
  instead of `lr-submit`); the textarea keeps accepting input and Enter keeps inserting newlines
  rather than being blocked.
- Auto-resize requires a concrete, unitless `line-height` on the textarea (the component sets
  `line-height: 1.5` in its own styles) — the UA default of `normal` has no single resolved px
  figure to measure rows against, so overriding `line-height` to a keyword breaks row sizing.
- The `end` slot fully replaces the built-in action button rather than rendering alongside it —
  once it has assigned content, the library's send/stop icon, its `aria-label`, and its
  `status`-driven busy styling all disappear, so a custom end control needs its own send/stop
  handling.
- `[part="toolbar"]`/`[part="chips"]`/`[part="start"]` are hidden via a JS-tracked `[hidden]`
  attribute rather than a CSS `:empty` selector, because each always contains a literal `<slot>`
  child regardless of assigned content.
- Under `frame="card"` the only focus affordance is a border-color shift on `[part="base"]`
  (the internal `<textarea>` sets `outline: none`). `frame="plain"` removes that border, so it
  swaps in a different affordance rather than losing focus visibility: an underline across the whole
  input row, drawn as an inset `box-shadow` from `--lr-focus-ring-width`/`--lr-focus-ring-color`,
  reachable as one unit through `--lr-chat-composer-focus-shadow`, so it costs no layout. If you
  restyle `[part="base"]` under `plain` and want to draw your own focus cue instead (e.g. on a
  wrapper you place the composer inside), set `--lr-chat-composer-focus-shadow: none` rather than
  fighting the default with a higher-specificity override.
- `actions-layout="stacked"` only changes `[part="row"]`'s layout (a CSS grid instead of a flex
  row); it does not change which slot content is hidden or when the built-in button replaces
  `end`.

---
