← All proposals

Chat Input

A labelled textarea with a toolbar inside the same bordered box:

Net-new componentOpen in Storybook

proposals/ChatInput/HANDOFF.md125 lines · View on GitHub

Handoff: ChatInput

What and why

The field for composing a prompt on chat and agent screens. PDS has Textarea, but its border sits on the <textarea> itself, so there is nowhere to put a toolbar (add menu, model selector, voice, Send) inside the box. ChatInput moves the border, background, focus ring and resize handle to a wrapper (the "field") that holds the textarea and the toolbar. It is the largest of the chat components: four slots (aboveField, fieldHeader, actionsStart, actionsEnd), two variants (default, compact) and opt-in file drag and drop. It holds no buttons or text of its own; the screen fills the slots. Figma: Chat Input page (the designer's hand-built components, not a capture). Context: context.md.

Exploration and decisions

  • A new component, not a Textarea prop: the wrapper has to own the border so the toolbar sits inside it. Label, helper message, focus ring, size and isResizable mirror Textarea, and the markup reuses the package's pds-input, pds-input-label and pds-input-message classes (InputLabel and InputMessage are not exported).
  • Slots, not built-in buttons. The story composes IconButton, MenuButton (add menu), the bare Select (model) and a primary IconButton (Send). The model selector was a separate ModelSelector proposal; it was replaced by Select with variant='bare' on 2026-10-19.
  • aboveField (a grey context bar on the top edge, outside the border) and fieldHeader (attachments, inside the border) are named by position. Both work in both variants. Attachments go in fieldHeader; the idea of putting them in aboveField was dropped (2026-10-21).
  • compact is a single-line bar with the controls on the same row, standard input radius (not a pill), not resizable. The DOM follows the visual order so keyboard order matches. It keeps the rounded-square Send button, not the reference's circle.
  • Label hidden by default (showLabel is false), still required and rendered for screen readers.
  • Drag and drop is opt-in through onFilesDrop; the component only reports the dropped File[]. Only file drags are intercepted, so dragging text into the textarea still works. The drag-over look is a dashed focus border on the info-blue fill.
  • Left out of the first version (present in Textarea): required marker, label tooltip, character counter, read-only, inputWidth, translation strings, validation states. Chat prompts have nothing to validate; send failures belong in the conversation.
  • No separate attach button in the samples; the add menu has "Add photos & files".

Full list and reasons: decisions.md.

API

ChatInput (a div; extends div props except defaultValue, onChange and placeholder; the ref goes to the <textarea>):

Prop Type Default Purpose
aboveField ReactNode none Bar on the top edge, outside the border (for example the branch the prompt applies to).
actionsEnd ReactNode none Toolbar controls at the end, inside the field (model selector, voice, Send as a primary IconButton).
actionsStart ReactNode none Toolbar controls at the start, inside the field (add menu).
className string none Additional class names.
defaultValue string none Initial value (uncontrolled); not with value.
disabled boolean false Disables the textarea and turns off drop.
dropLabel string none Text over the field while files are dragged onto it; without it only the border and fill change.
fieldHeader ReactNode none Inside the border above the text (attached files). In compact it sits above the row.
id string none (req.) ID of the textarea; also the name, and the base of the label and message ids.
isResizable boolean true Drag the bottom corner to resize. Ignored in compact.
label string none (req.) Label of the field.
message ReactNode none Helper message below the field; linked with aria-describedby.
onChange (event: ChangeEvent<HTMLTextAreaElement>) => void none Change handler (controlled use).
onFilesDrop (files: File[]) => void none Turns on file drop; receives the dropped files.
placeholder string none Text shown when empty.
rows number 3 Visible rows above the toolbar (always 1 in compact).
showLabel boolean false Show the label; otherwise it is visually hidden.
size 's' | 'm' 's' Padding (--chat-input-padding).
textareaProps ComponentPropsWithoutRef<'textarea'> none Extra props for the <textarea>; spread last, so they override.
value string none Value (controlled); not with defaultValue.
variant 'default' | 'compact' 'default' default: tall field, toolbar underneath. compact: one row.

Variants and states: default and compact; sizes s and m; disabled; resizable or not; drag-over (pds-is-drag-over, with and without dropLabel); each slot filled; failed attachments with a helper message. Stories: Default, Compact, With Above Field, With Field Header, With Attachment Errors, With All Properties, Slots (Usage page only).

Tokens used

  • --pds-border-radius-input
  • --pds-border-width-default
  • --pds-border-width-outline
  • --pds-color-border-default-secondary
  • --pds-color-border-input
  • --pds-color-foreground-default
  • --pds-color-foreground-default-secondary
  • --pds-color-input-placeholder
  • --pds-color-interactive-focus
  • --pds-color-status-critical-foreground
  • --pds-color-status-info-background
  • --pds-color-status-info-foreground
  • --pds-color-surface-default
  • --pds-color-surface-default-secondary
  • --pds-spacing-2xl
  • --pds-spacing-2xs
  • --pds-spacing-4xs
  • --pds-spacing-s
  • --pds-spacing-xs
  • --pds-typography-ff-default
  • --pds-typography-lh-l
  • --pds-typography-size-s
  • --pds-typography-size-xs

npm run check:tokens -- ChatInput: 0 problems. --pds-color-foreground-default-secondary, --pds-spacing-2xl, --pds-spacing-4xs, --pds-typography-size-xs and the critical foreground color are used only in chat-input-story.css (story scaffolding), not in the component.

Impact

From impact.md: no existing PDS component is changed and no --pds-* token is redefined; everything is scoped to .pds-chat-input*. The only shared class is .pds-is-resizable, used as .pds-chat-input__field.pds-is-resizable, so existing uses are unaffected. No other proposal imports it by code (the Chat with an agent recipe uses it in its story; port ChatInput before it).

Reused vs net-new

  • Reused: the package classes pds-input, pds-input-label, pds-input-message, pds-is-disabled, pds-is-resizable, visually-hidden. Composed in stories (slots): local IconButton, MenuButton, Select (bare variant), ChatAttachment, and Icon.
  • Net-new: .pds-chat-input with --compact, --m, __above-field, __actions, __actions-end, __actions-start, __drop-label, __field, __field-header, __row, __textarea; the state class pds-is-drag-over; all props; one custom variable, --chat-input-padding. No raw values kept. Details in custom.md.

Accessibility

  • A real <label> tied to the textarea by htmlFor; hidden visually by default with visually-hidden, still read by screen readers. message is linked with aria-describedby.
  • The textarea is native, so keyboard and typing behave as usual. Focus: the ring is :focus-within on the field (--pds-border-width-outline, --pds-color-interactive-focus), so it also shows when a toolbar control has focus. Toolbar controls keep their own focus styles and keyboard behavior (IconButton, MenuButton, Select).
  • Compact: the DOM order is start controls, text, end controls, matching the visual order.
  • Drag and drop is a pointer shortcut; the dropLabel overlay is aria-hidden. Keyboard users add files through the add menu (the screen must provide that path).
  • Failed attachments: the screen writes one role='alert' helper message (shown in the story).
  • The a11y panel has not been run, and light and dark were not checked by eye in a session (the drag-over colors were noted as not checked in dark mode).

Decisions and open questions

For the engineer or designer to settle:

  • Enter and Shift+Enter: left to the screen (the story's helper text only says "Enter to send"). Should send-on-Enter be built in, with an IME-composition guard?
  • Agentic border: should the field get the AI gradient border (for example while the agent works), like Button? Not added.
  • Compact growth: it is one row now; should it grow to a maximum height as a long prompt is typed? The reference's voice-mode waveform when empty is not included.
  • Character counter or maximum length: not built. Does chat need one?
  • Menu features MenuButton does not support but the references show: trailing status or shortcut hints, selected toggles (on/off tools; the most likely need), submenus and count badges. Each would extend MenuButton's item type.
  • The model selector shows a chevron (the Select's trigger icon); the design shows text only. Confirm.
  • Drag-over: which dropLabel wording, and should the overlay hide the textarea as in the references?
  • Attachments: should fieldHeader get a max height with scroll when many files are attached? Should the bar show in compact? An errorType to drive retry versus remove-only is also open.
  • textareaProps is spread after the built-in props, so it can override id, rows or onChange; confirm that is acceptable.
  • The voice icon microphoneLines is staged in the Icon proposal; the port adds it to the icon registry.

Port notes

Would live in src/components/ in an inputs or chat category folder (generator npm run generate:component). Not yet matching v2: the local mergeClasses stand-in (use @libs/components/utils), and the markup reuses pds-input* classes instead of the unexported InputLabel and InputMessage (use those if the port can import them). The textarea declares font family, size and line height from primitives (no pds-copy-* class matches the line height). The story composes local proposals, so the port needs them first.

Dependencies on other proposals:

  • Select (Component Proposals/Select, bare variant): ready for dev; ChatInput composes it, so port Select first.
  • ChatAttachment (AI Chat/Chat Attachment): ready for dev; used in stories only (the fieldHeader samples), so it does not block the component.
  • Marked ready: IconButton (its primary variant and isWorking are the Send button; they use Spinner and the stop icon in the Icon wrapper), MenuButton, Icon and Button.
  • Port order: see docs/PORT-ORDER.md (node scripts/port-order.mjs --needs=ChatInput prints the graph); ChatInput goes after Select.

Preview

npm run storybook, story Component Proposals / AI Chat / Chat Input. Figma: https://www.figma.com/design/AMGWJbraJoi2PKWnxo29Ah/PDS-Proposals?node-id=213-43, page ↳ Chat Input (the designer's hand-built components: the ChatInput component set with Default/Example content and Default/Compact size, and the prototype elements; not a capture).