{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-attachment-2","type":"registry:block","title":"Composer tray with removable attachments","description":"Composer tray with removable attachments","dependencies":[],"registryDependencies":["attachment","field","input-group"],"files":[{"path":"c-attachment-2.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useId, useState, type ReactNode } from \"react\"\n\nimport {\n  Attachment,\n  AttachmentAction,\n  AttachmentActions,\n  AttachmentContent,\n  AttachmentGroup,\n  AttachmentMedia,\n  AttachmentTitle,\n} from \"@/components/ui/attachment\"\nimport { Field, FieldLabel } from \"@/components/ui/field\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupText,\n  InputGroupTextarea,\n} from \"@/components/ui/input-group\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\nconst ICON_SHEET = (\n  <IconPlaceholder\n    lucide=\"FileSpreadsheetIcon\"\n    tabler=\"IconFileSpreadsheet\"\n    hugeicons=\"GoogleSheetIcon\"\n    phosphor=\"FileTextIcon\"\n    remixicon=\"RiFileTextLine\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_IMAGE = (\n  <IconPlaceholder\n    lucide=\"ImageIcon\"\n    tabler=\"IconPhoto\"\n    hugeicons=\"ImageIcon\"\n    phosphor=\"ImageIcon\"\n    remixicon=\"RiImageLine\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_TEXT = (\n  <IconPlaceholder\n    lucide=\"FileTextIcon\"\n    tabler=\"IconFileText\"\n    hugeicons=\"File02Icon\"\n    phosphor=\"FileTextIcon\"\n    remixicon=\"RiFileTextLine\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_CODE = (\n  <IconPlaceholder\n    lucide=\"CodeIcon\"\n    tabler=\"IconCode\"\n    hugeicons=\"SourceCodeIcon\"\n    phosphor=\"CodeIcon\"\n    remixicon=\"RiCodeSSlashLine\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_REMOVE = (\n  <IconPlaceholder\n    lucide=\"XIcon\"\n    tabler=\"IconX\"\n    hugeicons=\"Cancel01Icon\"\n    phosphor=\"XIcon\"\n    remixicon=\"RiCloseLine\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_ATTACH = (\n  <IconPlaceholder\n    lucide=\"PaperclipIcon\"\n    tabler=\"IconPaperclip\"\n    hugeicons=\"Attachment02Icon\"\n    phosphor=\"PaperclipIcon\"\n    remixicon=\"RiAttachment2\"\n    aria-hidden=\"true\"\n  />\n)\n\nconst ICON_SEND = (\n  <IconPlaceholder\n    lucide=\"ArrowUpIcon\"\n    tabler=\"IconArrowUp\"\n    hugeicons=\"ArrowUp01Icon\"\n    phosphor=\"ArrowUpIcon\"\n    remixicon=\"RiArrowUpLine\"\n    aria-hidden=\"true\"\n  />\n)\n\ntype Source = {\n  id: string\n  name: string\n  icon: ReactNode\n}\n\n// Icons ride in the data as ready made nodes rather than component\n// references. The shadcn CLI rewrites IconPlaceholder props where it finds\n// them, so the five names have to sit in the source as plain literals.\nconst FILES: Source[] = [\n  { id: \"churn\", name: \"churn-drivers.csv\", icon: ICON_SHEET },\n  { id: \"pricing\", name: \"pricing-tiers-v4.xlsx\", icon: ICON_SHEET },\n  { id: \"latency\", name: \"checkout-latency.png\", icon: ICON_IMAGE },\n  { id: \"limits\", name: \"rate-limits.md\", icon: ICON_TEXT },\n  { id: \"trace\", name: \"stack-trace-2041.log\", icon: ICON_CODE },\n  { id: \"invoice\", name: \"invoice-3182.pdf\", icon: ICON_TEXT },\n]\n\nexport function Pattern() {\n  // useId rather than a fixed string, so the label and the textarea stay\n  // paired even when two copies of this composer share one page.\n  const id = useId()\n  const [attachedIds, setAttachedIds] = useState<string[]>([\n    \"churn\",\n    \"pricing\",\n    \"latency\",\n  ])\n\n  // Both lists are derived during render rather than stored. A second piece of\n  // state for \"the next file\" would only have to be kept in step with the\n  // first, and the paperclip already knows everything it needs from the ids.\n  const attached = FILES.filter((file) => attachedIds.includes(file.id))\n  const next = FILES.find((file) => !attachedIds.includes(file.id))\n\n  // The tray renders only when it holds something. An empty block-start addon\n  // still draws its own padding, which would leave a dead band above the\n  // textarea the moment the last chip is removed.\n  const hasTray = attached.length > 0\n\n  // One sentence, rendered twice. The visible copy sits in the composer\n  // chrome, which nothing announces, so a live region repeats it below and\n  // makes each removal audible.\n  const summary = `${attached.length} of ${FILES.length} attached`\n\n  return (\n    <div className=\"mx-auto flex w-full max-w-2xl flex-col gap-2\">\n      <form onSubmit={(event) => event.preventDefault()}>\n        <Field>\n          <FieldLabel htmlFor={id} className=\"sr-only\">\n            Message the assistant\n          </FieldLabel>\n          <InputGroup>\n            {hasTray ? (\n              <InputGroupAddon align=\"block-start\">\n                {/*\n                 * AttachmentGroup forces its children to flex-none and scrolls\n                 * them, so the fourth and fifth chips slide sideways instead of\n                 * adding a second row and shoving the transcript off screen.\n                 */}\n                <AttachmentGroup role=\"group\" aria-label=\"Attached files\">\n                  {attached.map((file) => (\n                    <Attachment key={file.id} size=\"xs\" className=\"max-w-52\">\n                      <AttachmentMedia>{file.icon}</AttachmentMedia>\n                      <AttachmentContent>\n                        <AttachmentTitle>{file.name}</AttachmentTitle>\n                      </AttachmentContent>\n                      <AttachmentActions>\n                        {/*\n                         * type=\"button\" is spelled out because this composer is\n                         * a real form, and a button with no explicit type\n                         * submits it.\n                         */}\n                        <AttachmentAction\n                          type=\"button\"\n                          aria-label={`Remove ${file.name}`}\n                          onClick={() =>\n                            setAttachedIds((current) =>\n                              current.filter((value) => value !== file.id)\n                            )\n                          }\n                        >\n                          {ICON_REMOVE}\n                        </AttachmentAction>\n                      </AttachmentActions>\n                    </Attachment>\n                  ))}\n                </AttachmentGroup>\n              </InputGroupAddon>\n            ) : null}\n\n            {/*\n             * The textarea grows with the draft and stops at a cap, so a long\n             * message scrolls inside the box instead of pushing the tray off\n             * the top.\n             */}\n            <InputGroupTextarea\n              id={id}\n              placeholder=\"Ask about the billing incident...\"\n              className=\"field-sizing-content max-h-32 min-h-10\"\n            />\n\n            <InputGroupAddon align=\"block-end\">\n              {/*\n               * Once the pool runs dry the paperclip is disabled rather than\n               * removed: a control that vanishes reflows the row and explains\n               * nothing.\n               */}\n              <InputGroupButton\n                type=\"button\"\n                size=\"icon-sm\"\n                aria-label=\"Attach a file\"\n                disabled={!next}\n                onClick={() => {\n                  if (next) {\n                    setAttachedIds((current) => [...current, next.id])\n                  }\n                }}\n              >\n                {ICON_ATTACH}\n              </InputGroupButton>\n              <InputGroupText>{summary}</InputGroupText>\n              <InputGroupButton\n                type=\"submit\"\n                size=\"icon-sm\"\n                variant=\"default\"\n                className=\"ms-auto\"\n              >\n                {ICON_SEND}\n                <span className=\"sr-only\">Send</span>\n              </InputGroupButton>\n            </InputGroupAddon>\n          </InputGroup>\n        </Field>\n      </form>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {summary}\n      </span>\n    </div>\n  )\n}","target":"components/examples/c-attachment-2.tsx"}],"meta":{"order":2,"gridSize":2}}