{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prompt-input",
  "title": "Prompt Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An auto-growing chat input: Enter sends, Shift+Enter adds a line (IME-safe), the send button turns into Stop while streaming, with file attachment chips and a toolbar slot.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ui.ballmac.com/r/button.json",
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/prompt-input.tsx",
      "content": "// Ballmac UI: Prompt Input. https://ui.ballmac.com/components/prompt-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUp, FileText, Image as ImageIcon, Paperclip, Square, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype PromptInputStatus = \"idle\" | \"streaming\"\n\ntype PromptInputContextValue = {\n  value: string\n  setValue: (value: string) => void\n  files: File[]\n  setFiles: (files: File[]) => void\n  status: PromptInputStatus\n  disabled: boolean\n  canSubmit: boolean\n  submit: () => void\n  stop?: () => void\n  textareaRef: React.RefObject<HTMLTextAreaElement | null>\n}\n\nconst PromptInputContext = React.createContext<PromptInputContextValue | null>(null)\n\nfunction usePromptInput() {\n  const ctx = React.useContext(PromptInputContext)\n  if (!ctx) throw new Error(\"PromptInput parts must be rendered inside <PromptInput>.\")\n  return ctx\n}\n\nfunction useControllable<T>(prop: T | undefined, defaultValue: T, onChange?: (value: T) => void) {\n  const [internal, setInternal] = React.useState(defaultValue)\n  const controlled = prop !== undefined\n  const value = controlled ? prop : internal\n  const onChangeRef = React.useRef(onChange)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n  })\n  const setValue = React.useCallback(\n    (next: T) => {\n      if (!controlled) setInternal(next)\n      onChangeRef.current?.(next)\n    },\n    [controlled]\n  )\n  return [value, setValue] as const\n}\n\ntype PromptInputProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"defaultValue\"> & {\n  /** Called with the trimmed text and attached files on Enter or the send button. Uncontrolled inputs clear themselves afterwards. */\n  onSubmit?: (value: string, files: File[]) => void\n  /** Controlled text. Clear it yourself in onSubmit. */\n  value?: string\n  /** Initial text when uncontrolled. */\n  defaultValue?: string\n  /** Called on every edit. */\n  onValueChange?: (value: string) => void\n  /** Controlled attachments. */\n  files?: File[]\n  /** Called when files are attached or removed. Nothing is uploaded; that is your job. */\n  onFilesChange?: (files: File[]) => void\n  /** \"streaming\" turns the send button into a Stop button and blocks new submissions. */\n  status?: PromptInputStatus\n  /** Called by the Stop button while streaming. */\n  onStop?: () => void\n  /** Disables typing, attaching and sending. */\n  disabled?: boolean\n}\n\nfunction PromptInput({\n  onSubmit,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  files: filesProp,\n  onFilesChange,\n  status = \"idle\",\n  onStop,\n  disabled = false,\n  className,\n  children,\n  ...props\n}: PromptInputProps) {\n  const [value, setValue] = useControllable(valueProp, defaultValue, onValueChange)\n  const [files, setFiles] = useControllable<File[]>(filesProp, [], onFilesChange)\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null)\n  const canSubmit = !disabled && status !== \"streaming\" && (value.trim().length > 0 || files.length > 0)\n\n  const submit = React.useCallback(() => {\n    if (!canSubmit) return\n    onSubmit?.(value.trim(), files)\n    if (valueProp === undefined) setValue(\"\")\n    if (filesProp === undefined) setFiles([])\n  }, [canSubmit, onSubmit, value, files, valueProp, filesProp, setValue, setFiles])\n\n  const ctx = React.useMemo<PromptInputContextValue>(\n    () => ({ value, setValue, files, setFiles, status, disabled, canSubmit, submit, stop: onStop, textareaRef }),\n    [value, setValue, files, setFiles, status, disabled, canSubmit, submit, onStop]\n  )\n\n  return (\n    <PromptInputContext.Provider value={ctx}>\n      <form\n        data-slot=\"prompt-input\"\n        data-status={status}\n        data-disabled={disabled || undefined}\n        className={cn(\n          \"flex w-full cursor-text flex-col rounded-xl border border-input bg-card shadow-xs transition-[border-color,box-shadow] duration-150 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60\",\n          className\n        )}\n        onSubmit={(event) => {\n          event.preventDefault()\n          submit()\n        }}\n        onMouseDown={(event) => {\n          // Clicking the padding focuses the textarea, like a single field.\n          if (event.target === event.currentTarget) {\n            event.preventDefault()\n            textareaRef.current?.focus()\n          }\n        }}\n        {...props}\n      >\n        {children ?? (\n          <>\n            <PromptInputAttachments />\n            <PromptInputTextarea />\n            <PromptInputToolbar>\n              {onFilesChange ? <PromptInputAttachButton /> : null}\n              <PromptInputSubmit />\n            </PromptInputToolbar>\n          </>\n        )}\n      </form>\n    </PromptInputContext.Provider>\n  )\n}\n\ntype PromptInputTextareaProps = Omit<React.ComponentProps<\"textarea\">, \"value\" | \"defaultValue\"> & {\n  /** Height (px) the textarea grows to before it scrolls. */\n  maxHeight?: number\n}\n\nfunction PromptInputTextarea({\n  maxHeight = 200,\n  placeholder,\n  className,\n  onKeyDown,\n  onChange,\n  ref,\n  style,\n  ...props\n}: PromptInputTextareaProps) {\n  const msg = useMessages()\n  placeholder ??= msg(\"prompt-input.placeholder\", \"Send a message…\")\n  const { value, setValue, submit, disabled, textareaRef } = usePromptInput()\n\n  const setRefs = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      textareaRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref, textareaRef]\n  )\n\n  // Grow with the content (no dependency): measure from auto height each change.\n  React.useLayoutEffect(() => {\n    const node = textareaRef.current\n    if (!node) return\n    node.style.height = \"auto\"\n    node.style.height = `${Math.min(node.scrollHeight, maxHeight)}px`\n    node.style.overflowY = node.scrollHeight > maxHeight ? \"auto\" : \"hidden\"\n  }, [value, maxHeight, textareaRef])\n\n  return (\n    <textarea\n      ref={setRefs}\n      data-slot=\"prompt-input-textarea\"\n      rows={1}\n      aria-label={props[\"aria-labelledby\"] ? undefined : msg(\"prompt-input.message\", \"Message\")}\n      placeholder={placeholder}\n      disabled={disabled}\n      value={value}\n      onChange={(event) => {\n        onChange?.(event)\n        setValue(event.target.value)\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (event.defaultPrevented) return\n        // Enter sends; Shift+Enter inserts a newline; ignore Enter that confirms an IME composition.\n        if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing && event.keyCode !== 229) {\n          event.preventDefault()\n          submit()\n        }\n      }}\n      style={{ maxHeight, ...style }}\n      className={cn(\n        \"block min-h-11 w-full resize-none bg-transparent px-3.5 pt-3 pb-1 text-sm leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype PromptInputToolbarProps = React.ComponentProps<\"div\">\n\n/** The bottom row: attach button, model pickers or other controls on the left, send/stop on the right. */\nfunction PromptInputToolbar({ className, ...props }: PromptInputToolbarProps) {\n  return (\n    <div\n      data-slot=\"prompt-input-toolbar\"\n      className={cn(\"flex min-w-0 flex-wrap items-center gap-1 px-2 pb-2\", className)}\n      {...props}\n    />\n  )\n}\n\ntype PromptInputSubmitProps = Omit<React.ComponentProps<typeof Button>, \"type\" | \"onClick\"> & {\n  /** Accessible name of the send button. */\n  submitLabel?: string\n  /** Accessible name of the stop button. */\n  stopLabel?: string\n}\n\nfunction PromptInputSubmit({\n  submitLabel,\n  stopLabel,\n  className,\n  children,\n  ...props\n}: PromptInputSubmitProps) {\n  const msg = useMessages()\n  submitLabel ??= msg(\"prompt-input.submitLabel\", \"Send message\")\n  stopLabel ??= msg(\"prompt-input.stopLabel\", \"Stop generating\")\n  const { status, canSubmit, stop, disabled } = usePromptInput()\n  const streaming = status === \"streaming\"\n  return (\n    <Button\n      data-slot=\"prompt-input-submit\"\n      data-status={status}\n      type={streaming ? \"button\" : \"submit\"}\n      size=\"icon-sm\"\n      shape=\"pill\"\n      aria-label={streaming ? stopLabel : submitLabel}\n      title={streaming ? stopLabel : submitLabel}\n      disabled={streaming ? disabled || !stop : !canSubmit}\n      onClick={streaming ? () => stop?.() : undefined}\n      className={cn(\"ms-auto\", className)}\n      {...props}\n    >\n      {children ?? (streaming ? <Square className=\"size-3 fill-current\" /> : <ArrowUp />)}\n    </Button>\n  )\n}\n\ntype PromptInputAttachButtonProps = Omit<React.ComponentProps<typeof Button>, \"type\" | \"onClick\"> & {\n  /** File types to accept, as for <input type=\"file\" accept>. */\n  accept?: string\n  /** Allow picking several files at once. */\n  multiple?: boolean\n  /** Accessible name of the icon button. */\n  label?: string\n}\n\nfunction PromptInputAttachButton({\n  accept,\n  multiple = true,\n  label,\n  className,\n  children,\n  ...props\n}: PromptInputAttachButtonProps) {\n  const msg = useMessages()\n  label ??= msg(\"prompt-input.label\", \"Attach files\")\n  const { files, setFiles, disabled } = usePromptInput()\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  return (\n    <>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        className=\"hidden\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(event) => {\n          const picked = Array.from(event.target.files ?? [])\n          if (picked.length) setFiles(multiple ? [...files, ...picked] : picked.slice(0, 1))\n          event.target.value = \"\"\n        }}\n      />\n      <Button\n        data-slot=\"prompt-input-attach\"\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon-sm\"\n        aria-label={label}\n        title={label}\n        disabled={disabled}\n        onClick={() => inputRef.current?.click()}\n        className={cn(\"text-muted-foreground hover:text-foreground\", className)}\n        {...props}\n      >\n        {children ?? <Paperclip />}\n      </Button>\n    </>\n  )\n}\n\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n\ntype PromptInputAttachmentsProps = React.ComponentProps<\"ul\">\n\n/** Removable chips for the attached files. Renders nothing when there are none. */\nfunction PromptInputAttachments({ className, ...props }: PromptInputAttachmentsProps) {\n  const msg = useMessages()\n  const { files, setFiles, disabled } = usePromptInput()\n  if (!files.length) return null\n  return (\n    <ul\n      data-slot=\"prompt-input-attachments\"\n      aria-label={msg(\"prompt-input.attachments\", \"Attachments\")}\n      className={cn(\"flex flex-wrap gap-1.5 px-2.5 pt-2.5\", className)}\n      {...props}\n    >\n      {files.map((file, index) => {\n        const Icon = file.type.startsWith(\"image/\") ? ImageIcon : FileText\n        return (\n          <li\n            key={`${file.name}-${file.size}-${file.lastModified}-${index}`}\n            data-slot=\"prompt-input-attachment\"\n            className=\"flex h-8 max-w-full items-center gap-1.5 rounded-md border bg-muted/50 pe-1 ps-2 text-xs\"\n          >\n            <Icon aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" />\n            <span className=\"max-w-40 truncate font-medium\">{file.name}</span>\n            <span className=\"shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums\">{formatBytes(file.size)}</span>\n            <button\n              type=\"button\"\n              aria-label={msg(\"prompt-input.remove\", \"Remove {name}\", { name: file.name })}\n              disabled={disabled}\n              onClick={() => setFiles(files.filter((_, i) => i !== index))}\n              className=\"flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n            >\n              <X aria-hidden=\"true\" className=\"size-3.5\" />\n            </button>\n          </li>\n        )\n      })}\n    </ul>\n  )\n}\n\nexport {\n  PromptInput,\n  PromptInputTextarea,\n  PromptInputToolbar,\n  PromptInputSubmit,\n  PromptInputAttachButton,\n  PromptInputAttachments,\n  usePromptInput,\n  type PromptInputProps,\n  type PromptInputTextareaProps,\n  type PromptInputToolbarProps,\n  type PromptInputSubmitProps,\n  type PromptInputAttachButtonProps,\n  type PromptInputAttachmentsProps,\n  type PromptInputStatus,\n}\n",
      "type": "registry:ui",
      "target": "@components/ballmac/prompt-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "chat",
      "input",
      "textarea",
      "composer",
      "attachments",
      "llm"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "The message composer for chat UIs. <PromptInput onSubmit={(text, files) => …} status onStop> renders a textarea, attachment chips and a send button by default; compose PromptInputTextarea, PromptInputAttachments, PromptInputToolbar, PromptInputAttachButton and PromptInputSubmit for custom layouts.",
      "whenToUse": [
        "The input at the bottom of an AI chat",
        "A 'describe what you want' box that sends to a model and can be stopped mid-response",
        "Composer that needs a model picker or other controls next to the send button"
      ],
      "whenNotToUse": [
        "Plain multi-line form fields (use textarea)",
        "Search boxes with results as you type (use an input or command menu)"
      ],
      "composesWith": [
        "ai-chat",
        "ai-message"
      ],
      "a11y": [
        {
          "keys": "Enter",
          "action": "Sends the message (ignored while an IME composition is active)"
        },
        {
          "keys": "Shift + Enter",
          "action": "Inserts a new line"
        },
        {
          "keys": "Tab",
          "action": "Moves to the attach button, toolbar controls and send/stop"
        }
      ],
      "customization": [
        "value/onValueChange for controlled text (clear it yourself in onSubmit) or defaultValue for uncontrolled (clears itself)",
        "files/onFilesChange for controlled attachments; nothing is uploaded — send the File objects wherever you need",
        "status: idle | streaming. With the AI SDK map status 'submitted' and 'streaming' to 'streaming'",
        "PromptInputTextarea maxHeight (px, default 200) caps the auto-grow",
        "PromptInputAttachButton accept and multiple mirror <input type=file>",
        "usePromptInput() exposes { value, setValue, files, submit, status } to custom toolbar controls"
      ]
    },
    "examples": [
      "prompt-input-demo",
      "prompt-input-attachments"
    ],
    "url": "https://ui.ballmac.com/components/prompt-input"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}