Prompt input
The composer: an auto-growing textarea inside a real <form>, a row of inline tool buttons,
a send control that becomes a stop control, attachment handling that produces the
FileUIPart shape sendMessage accepts, and a model picker.
Import
import {
PromptInput,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '@elirobinson/ai-elements/components/prompt-input';
import {
Attachment,
AttachmentRemove,
Attachments,
} from '@elirobinson/ai-elements/components/attachments';
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorTrigger,
} from '@elirobinson/ai-elements/components/model-selector';The keyboard contract
PromptInputTextarea handles Enter itself:
Entersubmits — but it calls your ownonKeyDownfirst, and if that handler callspreventDefault()the internal behaviour does not run at all. That is the supported way to change whatEnterdoes, on a phone or anywhere else.Shift+Enterinserts a newline.- An IME composition swallows
Enter, so composing a character never sends the message. - Before submitting, it looks for a
button[type="submit"]in the form and does nothing if that button is disabled — so a disabled send is disabled for the keyboard too. Backspacein an empty textarea removes the last attachment. Worth knowing before a reader discovers it.
Send and stop
PromptInputSubmit is one control with two jobs, keyed to status:
aria-labelis"Submit", or"Stop"whilestatusissubmittedorstreaming.- Its
typeissubmitnormally, andbuttonwhile generating if you passedonStop— a stop that would otherwise submit the form is the failure this avoids. - The icon follows the same state: enter arrow, spinner, square, and a cross on
error.
It carries no dense classification, so button-floor applies to it and it is the 44×44
control in a footer of 32px ones — the size difference is what says which one sends the
message.
Attachments
The demo is the list variant. Attachments defaults to grid, which is a 96×96 tile whose
contents are all opt-in — no preview, no name, and an AttachmentRemove that renders nothing
until you supply an onRemove.
AttachmentRemoverenders nothing at all unless the surroundingAttachmentwas given anonRemove.- It sets
aria-label(default"Remove") and repeats it in ansr-onlyspan, so it always has a name. - In the
gridandinlinevariants it isopacity-0until the attachment is hovered, and there is no focus-visible reveal. A keyboard user can land on a control they cannot see. Thelistvariant has no opacity gate. - The label a chip shows falls back through
filename, then"Image"or"Attachment"; asource-documentfalls back throughtitle,filename,"Source".
Model selector
The demo is the closed state — open it, and the palette is a modal dialog.
ModelSelector is not a select. It is a dialog containing a command palette — filter as you
type, arrow keys to move, Enter to choose — which is why it takes ModelSelectorInput and
ModelSelectorList rather than options. ModelSelectorContent gives the dialog an sr-only
title (default "Model Selector") so it is never an unnamed dialog, and clears
aria-describedby because there is no description to point at.
PromptInputSelect in the same module is a different thing: the shadcn/Radix Select, for
picking something small inline in the composer.
Accessibility
- The file input is a real
<input type="file">withdisplay: none. It is not a tab stop and not in the accessibility tree; the visible button that opens it is the control, and that button is a<button>. PromptInputrenders a real<form>— butonSubmitis required and its handler callspreventDefault()unconditionally, so nothing here degrades without JavaScript. What the form buys you is one path:requestSubmit()runs native constraint validation, andEnterand the send button both go through it.PromptInputButton— the inline tool row — is classified dense on purpose. See below.
What this system changed
| Patch | What changed, and why |
|---|---|
button-floor | primary — ui/button.tsx — every <Button> in the tree. Measured default 36px, sm 32px, icon 36x36, icon-sm 32x32, icon-lg 40x40. The floor every vendored button gets unless it is classified dense elsewhere in this list. shadcn sizes at 32-40px, which clears AA and misses this system’s AAA default; the `not-data-[touch-target=dense]` guard is what keeps that a default rather than a blanket, and it cannot be undone by a consumer’s className the way a merged utility could. |
command-item-floor | primary — ui/command.tsx — CommandItem (model, voice and mic pickers). Measured 32px tall. Choosing a model or a microphone is the whole point of the surface it sits on. |
dialog-close-hit-area | primary — ui/dialog.tsx — the built-in close button (model selector, voice selector). Measured 16x16. The painted glyph stays 16px and the hit area grows around it, which is what contracts.json asks for — the button has no fill at rest, so nothing visible changes. 16x16 misses 24x24 as well, so declaring it dense was never available. |
prompt-input-tool-button-dense | dense — prompt-input.tsx — PromptInputButton (the composer’s inline tool row). Measured 32x32 icon-only and 88x32 labelled, both clear 24x24. The one place in this file where two controls in the same row get different answers, and deliberately: PromptInputSubmit is the surface’s primary action and is floored to 44 by button-floor, while the tool row beside it — add an attachment, toggle search — is a strip of compact affordances at shadcn’s InputGroupButton scale. Flooring the strip too would make the whole composer footer 44px tall and remove the size difference that says which one sends the message. |
Read from vendoredElementTargets in @elirobinson/ai-patterns/contracts, which the browser audit writes.