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:

  • Enter submits — but it calls your own onKeyDown first, and if that handler calls preventDefault() the internal behaviour does not run at all. That is the supported way to change what Enter does, on a phone or anywhere else.
  • Shift+Enter inserts 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.
  • Backspace in 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-label is "Submit", or "Stop" while status is submitted or streaming.
  • Its type is submit normally, and button while generating if you passed onStop — 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

diagram.pngimage/png

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.

  • AttachmentRemove renders nothing at all unless the surrounding Attachment was given an onRemove.
  • It sets aria-label (default "Remove") and repeats it in an sr-only span, so it always has a name.
  • In the grid and inline variants it is opacity-0 until the attachment is hovered, and there is no focus-visible reveal. A keyboard user can land on a control they cannot see. The list variant has no opacity gate.
  • The label a chip shows falls back through filename, then "Image" or "Attachment"; a source-document falls back through title, filename, "Source".

Model selector

Closed

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"> with display: 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>.
  • PromptInput renders a real <form> — but onSubmit is required and its handler calls preventDefault() unconditionally, so nothing here degrades without JavaScript. What the form buys you is one path: requestSubmit() runs native constraint validation, and Enter and the send button both go through it.
  • PromptInputButton — the inline tool row — is classified dense on purpose. See below.

What this system changed

PatchWhat changed, and why
button-floorprimary — 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-floorprimary — 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-areaprimary — 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-densedense — 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.