{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-composer",
  "title": "Chat composer",
  "author": "Tori Bryan",
  "description": "A message box built from parts around one draft, so each chat surface composes its own.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/button.json",
    "https://fibo.toribryan.com/r/progress.json",
    "https://fibo.toribryan.com/r/tooltip.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/chat-composer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowUpIcon, AtSignIcon, PaperclipIcon, XIcon } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Progress } from \"@/components/ui/progress\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The composer is a set of parts around one context, after Fernando Rojo's\n * \"Composition is all you need\". The parts never own state: they read it and\n * call actions through the context. Whoever renders the provider decides\n * where the draft lives, so the same parts serve a local draft, an edit, or a\n * draft synced across devices without a boolean prop for each.\n */\n\ntype ChatComposerAttachment = {\n  /** Stable key for the attachment. */\n  id: string\n  /** File name shown on the chip. */\n  name: string\n  /** Size in bytes, shown beside the name when present. */\n  size?: number\n  /** The file itself, when it came from this device. */\n  file?: File\n}\n\ntype ChatComposerState = {\n  /** The draft text. */\n  value: string\n  /** Files attached to the draft. */\n  attachments: ChatComposerAttachment[]\n  /** True while a submit is in flight. */\n  submitting?: boolean\n  /** True when the composer takes no input. */\n  disabled?: boolean\n  /** Whether the draft may be sent. Defaults to having text or an attachment. */\n  canSubmit?: boolean\n}\n\ntype ChatComposerActions = {\n  /** Replaces the draft text. */\n  setValue: (value: string) => void\n  /** Attaches files to the draft. */\n  addAttachments: (files: File[]) => void\n  /** Removes one attachment by id. */\n  removeAttachment: (id: string) => void\n  /** Sends the draft. */\n  submit: () => void\n}\n\ntype ChatComposerMeta = {\n  /** The text box, so any part can focus it or read the caret. */\n  inputRef: React.RefObject<HTMLTextAreaElement | null>\n}\n\ntype ChatComposerContextValue = {\n  /** The draft. */\n  state: ChatComposerState\n  /** How to change the draft and send it. */\n  actions: ChatComposerActions\n  /** Handles the parts share, such as the text box. */\n  meta: ChatComposerMeta\n}\n\nconst ChatComposerContext =\n  React.createContext<ChatComposerContextValue | null>(null)\n\n/** Reads the nearest composer. Throws outside a provider. */\nfunction useChatComposer() {\n  const context = React.useContext(ChatComposerContext)\n  if (!context) {\n    throw new Error(\n      \"Composer parts must be rendered inside a ChatComposerProvider.\"\n    )\n  }\n  return context\n}\n\n/** Whether the draft has any text or files in it. */\nfunction hasContent({ value, attachments }: ChatComposerMessage) {\n  return value.trim() !== \"\" || attachments.length > 0\n}\n\n/** Whether the draft may be sent and the composer is free to send it. */\nfunction isSubmittable(state: ChatComposerState) {\n  return (\n    !state.disabled &&\n    !state.submitting &&\n    (state.canSubmit ?? hasContent(state))\n  )\n}\n\ntype ChatComposerProviderProps = {\n  /** The draft, from wherever it lives. */\n  state: ChatComposerState\n  /** How the parts change the draft and send it. */\n  actions: ChatComposerActions\n  /** A ref for the text box. One is made for you when omitted. */\n  inputRef?: React.RefObject<HTMLTextAreaElement | null>\n  /** The parts, in any order and anywhere below. Renders no element. */\n  children?: React.ReactNode\n}\n\n/*\n * The seam between the parts and the state. Swap what feeds it, not the parts.\n */\nfunction ChatComposerProvider({\n  state,\n  actions,\n  inputRef,\n  children,\n}: ChatComposerProviderProps) {\n  const ownRef = React.useRef<HTMLTextAreaElement>(null)\n  const ref = inputRef ?? ownRef\n  const value = React.useMemo(\n    () => ({ state, actions, meta: { inputRef: ref } }),\n    [state, actions, ref]\n  )\n  return <ChatComposerContext value={value}>{children}</ChatComposerContext>\n}\n\ntype ChatComposerMessage = {\n  /** The text that was sent, trimmed. */\n  value: string\n  /** The files that were sent. */\n  attachments: ChatComposerAttachment[]\n}\n\ntype LocalChatComposerProviderProps = {\n  /** The draft to start with. */\n  defaultValue?: string\n  /** Attachments to start with. */\n  defaultAttachments?: ChatComposerAttachment[]\n  /** Called with the draft on send. Return a promise to hold the draft until it settles; a rejection keeps it. */\n  onSubmit?: (message: ChatComposerMessage) => void | Promise<void>\n  /** Takes no input while true. */\n  disabled?: boolean\n  /** Decides whether the draft may be sent. Defaults to having text or an attachment. */\n  canSubmit?: (message: ChatComposerMessage) => boolean\n  /** A ref for the text box. One is made for you when omitted. */\n  inputRef?: React.RefObject<HTMLTextAreaElement | null>\n  /** The parts. */\n  children?: React.ReactNode\n}\n\nlet attachmentCount = 0\n\n/** Turns picked or dropped files into attachments with unique ids. */\nfunction toAttachments(files: File[]): ChatComposerAttachment[] {\n  return files.map((file) => ({\n    id: `attachment-${++attachmentCount}`,\n    name: file.name,\n    size: file.size,\n    file,\n  }))\n}\n\n/*\n * The draft in component state, cleared once a send succeeds. For a draft\n * that has to outlive the component or follow the person across devices,\n * render ChatComposerProvider with your own store instead.\n */\nfunction LocalChatComposerProvider({\n  defaultValue = \"\",\n  defaultAttachments = [],\n  onSubmit,\n  disabled,\n  canSubmit = hasContent,\n  inputRef,\n  children,\n}: LocalChatComposerProviderProps) {\n  const [value, setValue] = React.useState(defaultValue)\n  const [attachments, setAttachments] = React.useState(defaultAttachments)\n  const [submitting, setSubmitting] = React.useState(false)\n\n  const ready = canSubmit({ value: value.trim(), attachments })\n  const state = React.useMemo(\n    () => ({ value, attachments, submitting, disabled, canSubmit: ready }),\n    [value, attachments, submitting, disabled, ready]\n  )\n\n  // Submit reads the latest draft without re-creating the actions on every\n  // keystroke, which would re-render every part that only needs actions.\n  const latest = React.useRef({ state, onSubmit })\n  React.useLayoutEffect(() => {\n    latest.current = { state, onSubmit }\n  })\n\n  const actions = React.useMemo<ChatComposerActions>(\n    () => ({\n      setValue,\n      addAttachments: (files) =>\n        setAttachments((current) => [...current, ...toAttachments(files)]),\n      removeAttachment: (id) =>\n        setAttachments((current) => current.filter((a) => a.id !== id)),\n      submit: () => {\n        const { state, onSubmit } = latest.current\n        if (!isSubmittable(state)) return\n        const message = {\n          value: state.value.trim(),\n          attachments: state.attachments,\n        }\n        // Clear only what was sent: people keep typing while a slow send is\n        // in flight, and that new draft has to survive it settling.\n        const sent = new Set(state.attachments.map((a) => a.id))\n        const clear = () => {\n          setValue((current) => (current === state.value ? \"\" : current))\n          setAttachments((current) => current.filter((a) => !sent.has(a.id)))\n        }\n        const result = onSubmit?.(message)\n        if (!(result instanceof Promise)) {\n          clear()\n          return\n        }\n        setSubmitting(true)\n        result.then(clear, () => {}).finally(() => setSubmitting(false))\n      },\n    }),\n    []\n  )\n\n  return (\n    <ChatComposerProvider state={state} actions={actions} inputRef={inputRef}>\n      {children}\n    </ChatComposerProvider>\n  )\n}\n\nconst INTERACTIVE =\n  \"button, a, input, textarea, select, label, [role=button], [tabindex], [contenteditable]\"\n\n/*\n * The box around the parts. A click on its bare surface focuses the text box,\n * so the whole frame reads as one field.\n */\nfunction ChatComposerFrame({\n  className,\n  onClick,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  const { state, meta } = useChatComposer()\n  return (\n    <div\n      data-slot=\"chat-composer\"\n      data-disabled={state.disabled ? \"\" : undefined}\n      className={cn(\n        \"relative flex w-full flex-col rounded-2xl border border-input bg-input-subtle transition-colors has-[[data-slot=chat-composer-input]:focus-visible]:border-ring has-[[data-slot=chat-composer-input]:focus-visible]:ring-[3px] has-[[data-slot=chat-composer-input]:focus-visible]:ring-ring-subtle data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n        className\n      )}\n      onClick={(event) => {\n        onClick?.(event)\n        const target = event.target as Element\n        if (event.defaultPrevented || target.closest(INTERACTIVE)) return\n        meta.inputRef.current?.focus()\n      }}\n      {...props}\n    />\n  )\n}\n\n/** A row above the text box, for context such as a reply or attachments. */\nfunction ChatComposerHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"chat-composer-header\"\n      className={cn(\n        \"flex flex-wrap items-center gap-2 px-3 pt-3 text-sm text-muted-foreground\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/*\n * The text box. Enter sends and Shift+Enter breaks the line. To change that,\n * handle onKeyDown and call preventDefault; the part then leaves the key alone.\n */\nfunction ChatComposerInput({\n  className,\n  ref,\n  disabled,\n  onChange,\n  onKeyDown,\n  \"aria-label\": ariaLabel = \"Message\",\n  ...props\n}: React.ComponentProps<\"textarea\">) {\n  const {\n    state,\n    actions,\n    meta: { inputRef },\n  } = useChatComposer()\n  // A caller's ref joins the composer's rather than replacing it, which\n  // would break frame clicks and the mention button.\n  const mergedRef = React.useCallback(\n    (node: HTMLTextAreaElement | null) => {\n      inputRef.current = node\n      if (typeof ref === \"function\") return ref(node)\n      if (ref) ref.current = node\n    },\n    [inputRef, ref]\n  )\n  return (\n    <textarea\n      {...props}\n      ref={mergedRef}\n      data-slot=\"chat-composer-input\"\n      rows={props.rows ?? 1}\n      aria-label={ariaLabel}\n      value={state.value}\n      disabled={state.disabled || disabled}\n      onChange={(event) => {\n        onChange?.(event)\n        actions.setValue(event.target.value)\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (\n          event.defaultPrevented ||\n          event.key !== \"Enter\" ||\n          event.shiftKey ||\n          // Enter also confirms a word in an input method editor. Safari\n          // reports that keydown as keyCode 229 without isComposing.\n          event.nativeEvent.isComposing ||\n          event.keyCode === 229\n        )\n          return\n        event.preventDefault()\n        actions.submit()\n      }}\n      className={cn(\n        \"field-sizing-content max-h-60 min-h-11 w-full resize-none bg-transparent px-3 py-3 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed md:text-sm\",\n        className\n      )}\n    />\n  )\n}\n\n/** A row below the text box, for actions and the send button. */\nfunction ChatComposerFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"chat-composer-footer\"\n      className={cn(\n        \"flex items-center justify-between gap-1 px-2 pb-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\ntype ChatComposerActionProps = Omit<\n  React.ComponentProps<typeof Button>,\n  \"aria-label\"\n> & {\n  /** Names the icon for screen readers and the tooltip. */\n  label: string\n}\n\n/** An icon button for the footer, disabled with the composer. */\nfunction ChatComposerAction({\n  label,\n  className,\n  disabled,\n  ...props\n}: ChatComposerActionProps) {\n  const { state } = useChatComposer()\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            data-slot=\"chat-composer-action\"\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label={label}\n            disabled={disabled ?? state.disabled}\n            className={cn(\"rounded-full text-muted-foreground\", className)}\n            {...props}\n          />\n        }\n      />\n      <TooltipContent>{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\ntype ChatComposerAttachButtonProps = Omit<ChatComposerActionProps, \"label\"> & {\n  /** File types the picker offers, as in the input's accept attribute. */\n  accept?: string\n  /** Names the button. */\n  label?: string\n}\n\n/** Opens the file picker and attaches what is picked. */\nfunction ChatComposerAttachButton({\n  accept,\n  label = \"Attach files\",\n  onClick,\n  children,\n  ...props\n}: ChatComposerAttachButtonProps) {\n  const { actions } = useChatComposer()\n  const fileRef = React.useRef<HTMLInputElement>(null)\n  return (\n    <>\n      <ChatComposerAction\n        label={label}\n        onClick={(event) => {\n          onClick?.(event)\n          if (!event.defaultPrevented) fileRef.current?.click()\n        }}\n        {...props}\n      >\n        {children ?? <PaperclipIcon />}\n      </ChatComposerAction>\n      <input\n        ref={fileRef}\n        type=\"file\"\n        multiple\n        hidden\n        accept={accept}\n        tabIndex={-1}\n        onChange={(event) => {\n          const files = Array.from(event.target.files ?? [])\n          if (files.length) actions.addAttachments(files)\n          // Picking the same file twice should attach it twice.\n          event.target.value = \"\"\n        }}\n      />\n    </>\n  )\n}\n\n/** Puts text at the caret, replacing any selection, and keeps focus there. */\nfunction insertAtCaret(\n  { state, actions, meta }: ChatComposerContextValue,\n  text: string\n) {\n  const input = meta.inputRef.current\n  const start = input?.selectionStart ?? state.value.length\n  const end = input?.selectionEnd ?? state.value.length\n  actions.setValue(state.value.slice(0, start) + text + state.value.slice(end))\n  const caret = start + text.length\n  requestAnimationFrame(() => {\n    input?.focus()\n    input?.setSelectionRange(caret, caret)\n  })\n}\n\n/** Starts a mention by typing @ at the caret. */\nfunction ChatComposerMentionButton({\n  label = \"Mention someone\",\n  onClick,\n  children,\n  ...props\n}: Omit<ChatComposerActionProps, \"label\"> & {\n  /** Names the button. */\n  label?: string\n}) {\n  const composer = useChatComposer()\n  return (\n    <ChatComposerAction\n      label={label}\n      onClick={(event) => {\n        onClick?.(event)\n        if (!event.defaultPrevented) insertAtCaret(composer, \"@\")\n      }}\n      {...props}\n    >\n      {children ?? <AtSignIcon />}\n    </ChatComposerAction>\n  )\n}\n\n/*\n * The actions most composers share. It is only JSX, so a composer that needs\n * something else leaves it out and lists its own actions instead.\n */\nfunction ChatComposerCommonActions({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"chat-composer-common-actions\"\n      className={cn(\"flex items-center gap-0.5\", className)}\n      {...props}\n    >\n      <ChatComposerAttachButton />\n      <ChatComposerMentionButton />\n    </div>\n  )\n}\n\n/** Sends the draft. Works anywhere inside the provider, not only in the frame. */\nfunction ChatComposerSubmit({\n  className,\n  children,\n  disabled,\n  onClick,\n  \"aria-label\": ariaLabel = \"Send\",\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { state, actions, meta } = useChatComposer()\n  return (\n    <Button\n      data-slot=\"chat-composer-submit\"\n      type=\"button\"\n      size={children ? \"sm\" : \"icon-sm\"}\n      aria-label={children ? undefined : ariaLabel}\n      aria-busy={state.submitting || undefined}\n      {...props}\n      disabled={disabled || !isSubmittable(state)}\n      onClick={(event) => {\n        onClick?.(event)\n        if (event.defaultPrevented) return\n        actions.submit()\n        // Sending empties the draft, which disables this button and would\n        // drop focus to the page. The text box is where people go next.\n        meta.inputRef.current?.focus()\n      }}\n      className={cn(\"rounded-full\", className)}\n    >\n      {state.submitting ? (\n        <Progress type=\"circle\" size=\"sm\" value={null} aria-hidden=\"true\" />\n      ) : children ? null : (\n        <ArrowUpIcon />\n      )}\n      {children}\n    </Button>\n  )\n}\n\n/** Lists the draft's attachments, each with a button to remove it. Renders nothing when empty. */\nfunction ChatComposerAttachments({\n  className,\n  ...props\n}: React.ComponentProps<\"ul\">) {\n  const { state, actions } = useChatComposer()\n  if (!state.attachments.length) return null\n  return (\n    <ul\n      data-slot=\"chat-composer-attachments\"\n      aria-label=\"Attachments\"\n      className={cn(\"flex flex-wrap gap-1.5\", className)}\n      {...props}\n    >\n      {state.attachments.map((attachment) => (\n        <li\n          key={attachment.id}\n          data-slot=\"chat-composer-attachment\"\n          className=\"flex h-7 max-w-56 items-center gap-1.5 rounded-full border border-border bg-background pr-1 pl-2.5 text-xs text-foreground\"\n        >\n          <PaperclipIcon className=\"size-3 shrink-0 text-muted-foreground\" />\n          <span className=\"truncate\">{attachment.name}</span>\n          {attachment.size !== undefined ? (\n            <span className=\"shrink-0 text-muted-foreground\">\n              {formatBytes(attachment.size)}\n            </span>\n          ) : null}\n          <button\n            type=\"button\"\n            aria-label={`Remove ${attachment.name}`}\n            disabled={state.disabled}\n            onClick={() => actions.removeAttachment(attachment.id)}\n            className=\"flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring-subtle disabled:pointer-events-none\"\n          >\n            <XIcon className=\"size-3\" />\n          </button>\n        </li>\n      ))}\n    </ul>\n  )\n}\n\n/** Sizes in the units people read them in: 512 B, 12 KB, 3.4 MB. */\nfunction formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`\n  const units = [\"KB\", \"MB\", \"GB\"]\n  let size = bytes / 1024\n  let unit = 0\n  while (size >= 1024 && unit < units.length - 1) {\n    size /= 1024\n    unit++\n  }\n  return `${size < 10 ? size.toFixed(1).replace(/\\.0$/, \"\") : Math.round(size)} ${units[unit]}`\n}\n\n/** Whether a file passes an accept string such as \"image/*,.pdf\". */\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept?.trim()) return true\n  const name = file.name.toLowerCase()\n  const type = file.type.toLowerCase()\n  return accept.split(\",\").some((raw) => {\n    const rule = raw.trim().toLowerCase()\n    if (!rule) return false\n    if (rule.startsWith(\".\")) return name.endsWith(rule)\n    if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1))\n    return type === rule\n  })\n}\n\n/*\n * Attaches files dropped anywhere inside it. Leave it out to turn drag and\n * drop off; there is no prop for that.\n */\nfunction ChatComposerDropZone({\n  className,\n  children,\n  label = \"Drop files to attach\",\n  accept,\n  onDragEnter,\n  onDragOver,\n  onDragLeave,\n  onDrop,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  /** Shown over the zone while files are dragged across it. */\n  label?: React.ReactNode\n  /** File types it takes, as in a file input's accept attribute. Match it to the attach button's. */\n  accept?: string\n}) {\n  const { state, actions } = useChatComposer()\n  const [dragging, setDragging] = React.useState(false)\n  // dragenter and dragleave fire for every child crossed, so count them.\n  const depth = React.useRef(0)\n\n  const hasFiles = (event: React.DragEvent) =>\n    !state.disabled && event.dataTransfer.types.includes(\"Files\")\n\n  return (\n    <div\n      data-slot=\"chat-composer-drop-zone\"\n      data-dragging={dragging ? \"\" : undefined}\n      className={cn(\"relative\", className)}\n      onDragEnter={(event) => {\n        // A caller's handler runs first and can take the drag over by\n        // calling preventDefault, as with ChatComposerFrame's click.\n        onDragEnter?.(event)\n        if (event.defaultPrevented || !hasFiles(event)) return\n        event.preventDefault()\n        depth.current++\n        setDragging(true)\n      }}\n      onDragOver={(event) => {\n        onDragOver?.(event)\n        if (event.defaultPrevented || !hasFiles(event)) return\n        event.preventDefault()\n        event.dataTransfer.dropEffect = \"copy\"\n      }}\n      onDragLeave={(event) => {\n        onDragLeave?.(event)\n        depth.current = Math.max(0, depth.current - 1)\n        if (depth.current === 0) setDragging(false)\n      }}\n      onDrop={(event) => {\n        onDrop?.(event)\n        // Reset first, so the hint never sticks if the composer was\n        // disabled mid-drag.\n        depth.current = 0\n        setDragging(false)\n        if (event.defaultPrevented || !hasFiles(event)) return\n        event.preventDefault()\n        const files = Array.from(event.dataTransfer.files).filter((file) =>\n          matchesAccept(file, accept)\n        )\n        if (files.length) actions.addAttachments(files)\n      }}\n      {...props}\n    >\n      {children}\n      {dragging ? (\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-ring bg-background text-sm font-medium text-foreground\"\n        >\n          {label}\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport {\n  ChatComposerAction,\n  ChatComposerAttachButton,\n  ChatComposerAttachments,\n  ChatComposerCommonActions,\n  ChatComposerDropZone,\n  ChatComposerFooter,\n  ChatComposerFrame,\n  ChatComposerHeader,\n  ChatComposerInput,\n  ChatComposerMentionButton,\n  ChatComposerProvider,\n  ChatComposerSubmit,\n  LocalChatComposerProvider,\n  useChatComposer,\n  type ChatComposerActions,\n  type ChatComposerAttachment,\n  type ChatComposerContextValue,\n  type ChatComposerMessage,\n  type ChatComposerMeta,\n  type ChatComposerState,\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "theme": {
      "color-input-subtle": "var(--input-subtle)",
      "color-ring-subtle": "var(--ring-subtle)"
    },
    "light": {
      "input-subtle": "color-mix(in oklch, var(--color-neutral-200) 20%, transparent)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-400) 50%, transparent)"
    },
    "dark": {
      "input-subtle": "oklch(1 0 0 / 5%)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-500) 50%, transparent)"
    }
  },
  "meta": {
    "tier": "base-components",
    "group": "Forms",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-forms-chat-composer--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-forms-chat-composer--docs",
  "categories": [
    "base-components",
    "forms"
  ],
  "type": "registry:ui"
}