{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-composer","type":"registry:ui","title":"Prompt Composer","description":"Where a message to an AI assistant is written: it grows as you type, takes files, and turns send into stop while the reply streams.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-files.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/prompt-composer.tsx","type":"registry:file","target":"components/dashral/prompt-composer.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUp, Paperclip, Square, X } from \"lucide-react\"\n\nimport { accepts, FileThumb, formatBytes } from \"./lib/files\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { Button } from \"./button\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\n\n/** What the composer sends: the text, trimmed, and the files attached to it. */\ntype PromptMessage = { text: string; files: File[] }\n\n/** \"ready\" takes a message; \"submitted\" (waiting for the first word) and \"streaming\" turn send into stop. */\ntype PromptStatus = \"ready\" | \"submitted\" | \"streaming\"\n\ntype PromptAttachments = {\n  /** Like the `accept` attribute: \"image/*\", \".pdf,.csv\". */\n  accept?: string\n  maxFiles?: number\n  /** Largest file, in bytes. */\n  maxSize?: number\n}\n\ntype PromptComposerProps = FieldOwnProps & {\n  /** Controlled text. Omit it (and use defaultValue) and the composer clears itself after sending. */\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  /** Controlled attachments; uncontrolled ones clear after sending too. */\n  files?: File[]\n  defaultFiles?: File[]\n  onFilesChange?: (files: File[]) => void\n  /** Called with the message when it's sent: Enter, or the send button. */\n  onSubmit?: (message: PromptMessage) => void\n  /** While a reply is on its way, send becomes stop, and Escape stops too. */\n  status?: PromptStatus\n  onStop?: () => void\n  /** \"enter\": Enter sends and Shift + Enter adds a line (a chat). \"mod-enter\": only ⌘/Ctrl + Enter sends. */\n  submitOn?: \"enter\" | \"mod-enter\"\n  /** The longest message; a count shows near it, and past it the message can't be sent. */\n  maxLength?: number\n  /** Files from the paperclip, pasting or dropping them on the composer. true takes any file. */\n  attachments?: boolean | PromptAttachments\n  /** Controls at the start of the toolbar, after the paperclip: a model picker, a \"search the web\" toggle. */\n  actions?: React.ReactNode\n  placeholder?: string\n  disabled?: boolean\n  required?: boolean\n  autoFocus?: boolean\n  id?: string\n  name?: string\n  /** Names the text box when there's no label. \"Message\" by default. */\n  \"aria-label\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  /** Classes for the composer's box. */\n  className?: string\n  classNames?: FieldClassNames & { composer?: string; textarea?: string; files?: string; toolbar?: string; submit?: string }\n}\n\n/** Whether the browser grows a textarea with its content by itself (CSS field-sizing). */\nconst sizesItself = typeof CSS !== \"undefined\" && CSS.supports?.(\"field-sizing\", \"content\")\n\n/**\n * Where a message to an assistant is written: a box that grows as you type, files from the paperclip, a paste or a\n * drop, your own controls in its toolbar, and a send button that turns into stop while the reply streams in.\n */\nfunction PromptComposer(props: PromptComposerProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <PromptComposerControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction PromptComposerControl({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  files: filesProp,\n  defaultFiles = [],\n  onFilesChange,\n  onSubmit,\n  status = \"ready\",\n  onStop,\n  submitOn = \"enter\",\n  maxLength,\n  attachments,\n  actions,\n  placeholder,\n  autoFocus,\n  name,\n  className,\n  classNames,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: Omit<PromptComposerProps, keyof FieldOwnProps>) {\n  const { id, disabled, required, ...aria } = useFieldControl<FieldControlProps>({\n    id: rest.id,\n    disabled: rest.disabled,\n    required: rest.required,\n    \"aria-invalid\": rest[\"aria-invalid\"],\n  })\n  const { t, locale } = useLocale()\n  const textarea = React.useRef<HTMLTextAreaElement>(null)\n  const picker = React.useRef<HTMLInputElement>(null)\n\n  const [ownText, setOwnText] = React.useState(defaultValue)\n  const text = value ?? ownText\n  const setText = (next: string) => {\n    if (value === undefined) setOwnText(next)\n    onValueChange?.(next)\n  }\n  const [ownFiles, setOwnFiles] = React.useState(defaultFiles)\n  const files = filesProp ?? ownFiles\n  const setFiles = (next: File[]) => {\n    if (filesProp === undefined) setOwnFiles(next)\n    onFilesChange?.(next)\n  }\n\n  const rules: PromptAttachments | null = attachments ? (attachments === true ? {} : attachments) : null\n  const [refused, setRefused] = React.useState<string[]>([])\n  const [dragging, setDragging] = React.useState(false)\n\n  const busy = status !== \"ready\"\n  const left = maxLength === undefined ? Infinity : maxLength - text.length\n  const hasContent = text.trim().length > 0 || files.length > 0\n  const canSend = !disabled && !busy && hasContent && left >= 0\n  // The count stays quiet until the last tenth (at least 20 characters), then counts down; past the limit it's red.\n  const showCount = maxLength !== undefined && left <= Math.max(20, Math.round(maxLength / 10))\n\n  // Browsers without field-sizing grow the box here instead; its max height still caps it, and it then scrolls.\n  React.useLayoutEffect(() => {\n    const el = textarea.current\n    if (sizesItself || !el) return\n    el.style.height = \"auto\"\n    el.style.height = `${el.scrollHeight + (el.offsetHeight - el.clientHeight)}px`\n  }, [text])\n\n  function addFiles(list: File[]) {\n    if (!rules || !list.length) return\n    const problems: string[] = []\n    const next = [...files]\n    for (const file of list) {\n      if (!accepts(file, rules.accept)) problems.push(t(\"fileType\", { name: file.name }))\n      else if (rules.maxSize !== undefined && file.size > rules.maxSize) problems.push(t(\"fileSize\", { name: file.name, size: formatBytes(rules.maxSize, locale) }))\n      else if (rules.maxFiles !== undefined && next.length >= rules.maxFiles) problems.push(t(\"fileLimit\", { name: file.name, count: rules.maxFiles }))\n      else next.push(file)\n    }\n    setRefused(problems)\n    if (next.length !== files.length) setFiles(next)\n  }\n\n  function send() {\n    if (!canSend) return\n    onSubmit?.({ text: text.trim(), files })\n    if (value === undefined) setOwnText(\"\")\n    if (filesProp === undefined) setOwnFiles([])\n    setRefused([])\n    textarea.current?.focus()\n  }\n\n  function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n    // A word still being composed (Japanese, Chinese, Korean input) takes its own Enter.\n    if (event.nativeEvent.isComposing) return\n    if (event.key === \"Escape\" && busy && onStop) {\n      event.preventDefault()\n      onStop()\n      return\n    }\n    if (event.key !== \"Enter\") return\n    const mod = event.metaKey || event.ctrlKey\n    const sends = submitOn === \"enter\" ? !event.shiftKey : mod\n    if (!sends) return\n    event.preventDefault()\n    send()\n  }\n\n  const countLabel = left >= 0 ? t(\"charactersLeft\", { count: left }) : t(\"charactersOver\", { count: -left })\n\n  return (\n    <div\n      data-slot=\"prompt-composer\"\n      data-disabled={disabled || undefined}\n      data-dragging={dragging || undefined}\n      data-status={status}\n      // A click on the box's empty parts writes in it, as a text field would.\n      onMouseDown={(event) => {\n        if (event.target === event.currentTarget) {\n          event.preventDefault()\n          textarea.current?.focus()\n        }\n      }}\n      onDragOver={(event) => {\n        if (!rules || disabled || !event.dataTransfer.types.includes(\"Files\")) return\n        event.preventDefault()\n        setDragging(true)\n      }}\n      onDragLeave={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)\n      }}\n      onDrop={(event) => {\n        if (!rules || disabled) return\n        event.preventDefault()\n        setDragging(false)\n        addFiles([...event.dataTransfer.files])\n      }}\n      className={cn(\n        \"relative flex w-full cursor-text flex-col rounded-xl border border-input bg-card shadow-xs transition-[border-color,box-shadow] duration-(--duration-fast)\",\n        \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15\",\n        \"has-[textarea[aria-invalid=true]]:border-destructive has-[textarea[aria-invalid=true]]:ring-destructive/15\",\n        \"data-[dragging]:border-primary data-[dragging]:ring-2 data-[dragging]:ring-primary/20\",\n        \"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-60\",\n        className,\n        classNames?.composer\n      )}\n    >\n      {files.length > 0 && (\n        <ul aria-label={t(\"attachments\")} className={cn(\"flex flex-wrap gap-2 px-3 pt-3\", classNames?.files)}>\n          {files.map((file, index) => (\n            <li key={`${file.name}-${file.size}-${index}`} className=\"flex max-w-56 items-center gap-2 rounded-lg border bg-background py-1 ps-1 pe-1.5\">\n              <FileThumb file={file} className=\"size-8\" />\n              <span className=\"min-w-0\">\n                <span className=\"block truncate text-xs font-medium\" dir=\"auto\">\n                  {file.name}\n                </span>\n                <span className=\"block text-[11px] text-muted-foreground tabular-nums\">{formatBytes(file.size, locale)}</span>\n              </span>\n              <button\n                type=\"button\"\n                onClick={() => setFiles(files.filter((_, i) => i !== index))}\n                disabled={disabled}\n                aria-label={t(\"remove\", { name: file.name })}\n                className=\"grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n              >\n                <X aria-hidden className=\"size-3.5\" />\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n\n      <textarea\n        ref={textarea}\n        data-slot=\"prompt-composer-textarea\"\n        id={id}\n        name={name}\n        rows={1}\n        value={text}\n        onChange={(event) => setText(event.target.value)}\n        onKeyDown={onKeyDown}\n        onPaste={(event) => {\n          const pasted = [...event.clipboardData.files]\n          if (!rules || !pasted.length) return\n          event.preventDefault()\n          addFiles(pasted)\n        }}\n        placeholder={placeholder ?? t(\"askAnything\")}\n        disabled={disabled}\n        required={required}\n        autoFocus={autoFocus}\n        enterKeyHint={submitOn === \"enter\" ? \"send\" : \"enter\"}\n        aria-label={ariaLabel ?? (aria[\"aria-labelledby\"] ? undefined : t(\"message\"))}\n        {...aria}\n        aria-invalid={aria[\"aria-invalid\"] ?? (left < 0 || undefined)}\n        className={cn(\n          \"field-sizing-content max-h-60 min-h-12 w-full resize-none overflow-y-auto bg-transparent [overflow-wrap:anywhere] px-4 pt-3 pb-1 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm\",\n          classNames?.textarea\n        )}\n      />\n\n      {refused.length > 0 && (\n        <ul role=\"alert\" className=\"grid gap-0.5 px-4 pt-1 text-xs text-destructive-text\">\n          {refused.map((problem) => (\n            <li key={problem}>{problem}</li>\n          ))}\n        </ul>\n      )}\n\n      {/* Quiet controls (muted until hovered or pressed), so the message and the send button lead; ghost buttons and a\n          borderless select in `actions` take this color too. */}\n      <div data-slot=\"prompt-composer-toolbar\" className={cn(\"flex items-center gap-1 p-2 text-muted-foreground\", classNames?.toolbar)}>\n        {rules && (\n          <>\n            <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" aria-label={t(\"attachFiles\")} onClick={() => picker.current?.click()} disabled={disabled}>\n              <Paperclip />\n            </Button>\n            <input\n              ref={picker}\n              type=\"file\"\n              tabIndex={-1}\n              aria-hidden\n              hidden\n              multiple={rules.maxFiles !== 1}\n              accept={rules.accept}\n              onChange={(event) => {\n                addFiles([...(event.target.files ?? [])])\n                event.target.value = \"\"\n              }}\n            />\n          </>\n        )}\n        {actions}\n        <span className=\"ms-auto\" />\n        {maxLength !== undefined && (\n          <span\n            className={cn(\n              \"px-1 text-xs tabular-nums transition-opacity duration-(--duration-fast)\",\n              showCount ? \"opacity-100\" : \"opacity-0\",\n              left < 0 ? \"text-destructive-text\" : \"text-muted-foreground\"\n            )}\n          >\n            <span aria-hidden>{left}</span>\n            <span className=\"sr-only\" aria-live=\"polite\">\n              {showCount ? countLabel : \"\"}\n            </span>\n          </span>\n        )}\n        {busy ? (\n          <Button\n            type=\"button\"\n            size=\"icon-sm\"\n            className={cn(\"rounded-full\", classNames?.submit)}\n            onClick={onStop}\n            disabled={!onStop}\n            aria-label={t(\"stopGenerating\")}\n          >\n            <Square aria-hidden className=\"size-3 fill-current\" />\n          </Button>\n        ) : (\n          <Button type=\"button\" size=\"icon-sm\" className={cn(\"rounded-full\", classNames?.submit)} onClick={send} disabled={!canSend} aria-label={t(\"sendMessage\")}>\n            <ArrowUp aria-hidden />\n          </Button>\n        )}\n      </div>\n\n      {dragging && (\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 grid place-items-center rounded-[inherit] bg-card/85 text-sm font-medium text-primary-text backdrop-blur-[1px]\">\n          {t(\"dropToAttach\")}\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { PromptComposer, type PromptComposerProps, type PromptMessage, type PromptStatus, type PromptAttachments }\n"}],"cssVars":{"theme":{"color-primary-text":"var(--primary-text, var(--primary))","color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}