{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"attachment","title":"Attachment","description":"Show a file or an image attached to a message or a draft, with its upload state. Search terms: thumbnail, file chip, file part, chat.","dependencies":[],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/motion","@siteplane/scroll-area"],"files":[{"path":"packages/ui/src/components/attachment/attachment.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { Button, type ButtonBaseProps, type ButtonProps } from \"../button\";\nimport { KitButton } from \"../button/button-internal\";\nimport { MotionItem, MotionList, MotionShimmer } from \"../motion\";\nimport { ScrollArea } from \"../scroll-area\";\n\nimport \"./attachment.css\";\n\n/**\n * Where an attachment is in its life: `idle` was picked and waits, `uploading`\n * and `processing` are under way (the name shimmers), `error` failed and\n * `done` is there. The AI SDK's file parts carry no state, so the app maps\n * its upload's own state to one of these words.\n */\nexport type AttachmentState =\n  \"idle\" | \"uploading\" | \"processing\" | \"error\" | \"done\";\n\nexport type AttachmentSize = \"sm\" | \"md\";\n\nexport type AttachmentOrientation = \"horizontal\" | \"vertical\";\n\ntype AttachmentContextValue = {\n  orientation: AttachmentOrientation;\n  state: AttachmentState;\n};\n\n// The parts read the state and the orientation of their card. Outside a card\n// they show as a finished, horizontal one.\nconst AttachmentContext = React.createContext<AttachmentContextValue>({\n  orientation: \"horizontal\",\n  state: \"done\",\n});\n\nexport type AttachmentProps = React.ComponentProps<\"div\"> & {\n  /** The upload state. It styles the card and shimmers the name while the file is on its way. */\n  state?: AttachmentState | undefined;\n  size?: AttachmentSize | undefined;\n  /** `horizontal` puts the media beside the text; `vertical` stacks it above, for image cards. */\n  orientation?: AttachmentOrientation | undefined;\n};\n\n/**\n * A file or an image attached to a draft or a message: media, a name with its\n * details, actions and, over the whole card, an optional trigger. The app owns\n * the files and their upload; the card shows the state it is given.\n */\nexport function Attachment({\n  className,\n  orientation = \"horizontal\",\n  size = \"md\",\n  state = \"done\",\n  ...props\n}: AttachmentProps): React.ReactElement {\n  const context = React.useMemo(\n    () => ({ orientation, state }),\n    [orientation, state],\n  );\n  const busy = state === \"uploading\" || state === \"processing\";\n\n  return (\n    <AttachmentContext.Provider value={context}>\n      <div\n        aria-busy={busy || undefined}\n        className={cn(slotClass(\"attachment\", props), className)}\n        data-orientation={orientation}\n        data-size={size}\n        data-slot=\"attachment\"\n        data-state={state}\n        {...props}\n      />\n    </AttachmentContext.Provider>\n  );\n}\n\nexport type AttachmentMediaProps = React.ComponentProps<\"div\"> & {\n  /** `icon` is a tile for an icon or a file type icon; `image` holds an `img` that fills it. */\n  variant?: \"icon\" | \"image\" | undefined;\n};\n\nexport function AttachmentMedia({\n  className,\n  variant = \"icon\",\n  ...props\n}: AttachmentMediaProps): React.ReactElement {\n  return (\n    <div\n      className={cn(slotClass(\"attachment-media\", props), className)}\n      data-slot=\"attachment-media\"\n      data-variant={variant}\n      {...props}\n    />\n  );\n}\n\n/** The column of the name and its details. It may shrink below its text (rulebook K5). */\nexport function AttachmentContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(slotClass(\"attachment-content\", props), className)}\n      data-slot=\"attachment-content\"\n      {...props}\n    />\n  );\n}\n\n/**\n * The name of the file. It ends in an ellipsis where it meets the edge of the\n * card and carries its full text as its title; while the file uploads or is\n * processed, the shimmer runs over it.\n */\nexport function AttachmentTitle({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n  const { state } = React.useContext(AttachmentContext);\n\n  return (\n    <span\n      className={cn(slotClass(\"attachment-title\", props), className)}\n      data-slot=\"attachment-title\"\n      title={typeof children === \"string\" ? children : undefined}\n      {...props}\n    >\n      <MotionShimmer active={state === \"uploading\" || state === \"processing\"}>\n        {children}\n      </MotionShimmer>\n    </span>\n  );\n}\n\n/** The details of the file: its type and size, the upload progress or why it failed, in words. */\nexport function AttachmentDescription({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"span\">): React.ReactElement {\n  return (\n    <span\n      className={cn(slotClass(\"attachment-description\", props), className)}\n      data-slot=\"attachment-description\"\n      title={typeof children === \"string\" ? children : undefined}\n      {...props}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function AttachmentActions({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">): React.ReactElement {\n  return (\n    <div\n      className={cn(slotClass(\"attachment-actions\", props), className)}\n      data-slot=\"attachment-actions\"\n      {...props}\n    />\n  );\n}\n\nexport type AttachmentActionProps = Omit<ButtonBaseProps, \"aria-label\"> & {\n  /** Names the action and its file, such as \"Remove brief.pdf\". The icon alone says nothing. */\n  \"aria-label\": string;\n};\n\n/**\n * An icon action of the card, such as remove, retry or download: a Button.\n * Beside the text it is the muted bare action of rulebook T2 in the 24 px\n * square target; over the image of a vertical card it is the opaque default\n * Button, which reads on any picture. Any Button prop replaces these.\n */\nexport function AttachmentAction({\n  className,\n  ...props\n}: AttachmentActionProps): React.ReactElement {\n  const { orientation } = React.useContext(AttachmentContext);\n  const look: ButtonProps =\n    orientation === \"vertical\"\n      ? { variant: \"default\" }\n      : { profile: \"inline\", profileTone: \"muted\", variant: \"text\" };\n\n  return (\n    <Button\n      className={className}\n      data-slot=\"attachment-action\"\n      shape=\"square\"\n      size=\"icon-xs\"\n      {...look}\n      {...props}\n    />\n  );\n}\n\nexport type AttachmentTriggerProps = Omit<ButtonBaseProps, \"aria-label\"> & {\n  /** Says what activating the card does, such as \"Open brief.pdf\". The trigger has no text of its own. */\n  \"aria-label\": string;\n};\n\n/**\n * A button laid over the whole card, behind the actions, that opens the file\n * or a preview of it. It is the full-cell Button profile; pass `render` for a\n * link, or put it in a DialogTrigger's `render`. Its hover tint and keyboard\n * ring are the Button's own.\n */\nexport function AttachmentTrigger({\n  className,\n  ...props\n}: AttachmentTriggerProps): React.ReactElement {\n  return (\n    <KitButton\n      className={cn(\"absolute inset-0 z-10 size-full\", className)}\n      data-slot=\"attachment-trigger\"\n      profile=\"full-cell-action\"\n      variant=\"ghost\"\n      {...props}\n    />\n  );\n}\n\nexport type AttachmentGroupProps = React.ComponentProps<\"div\"> & {\n  /**\n   * Names the row of files for assistive technology, such as \"Attachments\".\n   * The name goes to the row's scroll viewport, a labelled region, because\n   * that is what joins the tab order once the row scrolls; a row that can\n   * scroll should have one.\n   */\n  label?: string | undefined;\n};\n\n/**\n * A row of attachments that scrolls sideways, snaps card by card and fades the\n * edge that has more to show. Its cards are a list, and one that joins or\n * leaves fades in or out while the others glide (rulebook M6). Give each\n * Attachment a stable `key`.\n */\nexport function AttachmentGroup({\n  children,\n  className,\n  label,\n  ...props\n}: AttachmentGroupProps): React.ReactElement {\n  const attachments = React.Children.toArray(children).filter(\n    React.isValidElement,\n  );\n\n  return (\n    <div\n      className={cn(slotClass(\"attachment-group\", props), className)}\n      data-slot=\"attachment-group\"\n      {...props}\n    >\n      <ScrollArea aria-label={label} scrollFade scrollbar=\"hidden\">\n        <MotionList className=\"sp-attachment-group-row\" layout=\"flow\">\n          {attachments.map((attachment) => (\n            <MotionItem\n              className=\"sp-attachment-group-item\"\n              key={attachment.key}\n            >\n              {attachment}\n            </MotionItem>\n          ))}\n        </MotionList>\n      </ScrollArea>\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/attachment/attachment.tsx"},{"path":"packages/ui/src/components/attachment/attachment.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* Attachment: a file or an image as a small card, with its media, its name\n   * and details, its actions and, over the whole card, one optional trigger.\n   * Every value is a kit token, so a theme or a radius override reaches it.\n   * The card clips what it holds, so the square corners of the trigger's\n   * hover tint follow the card's radius instead of a radius of their own.\n   * The card is no control, so it keeps the plain cursor also inside a field,\n   * whose addons show a text cursor; the trigger and the actions are Buttons\n   * and show their own. */\n  .sp-attachment {\n    --sp-attachment-media: var(--sp-rw-space-10);\n    --sp-attachment-padding: var(--sp-rw-space-2);\n\n    position: relative;\n    isolation: isolate;\n    display: flex;\n    box-sizing: border-box;\n    width: fit-content;\n    min-width: 0;\n    max-width: 100%;\n    align-items: center;\n    gap: var(--sp-rw-space-2);\n    overflow: clip;\n    border: var(--sp-rw-border-default) solid var(--sp-rw-border-translucent);\n    border-radius: var(--sp-rw-radius-surface);\n    padding: var(--sp-attachment-padding);\n    background: var(--sp-rw-bg-secondary);\n    color: var(--sp-rw-fg-primary);\n    cursor: default;\n    font-size: var(--sp-rw-font-14);\n    line-height: var(--sp-rw-line-18);\n    transition-property: border-color, background-color;\n    transition-duration: var(--motion-duration-fast);\n    transition-timing-function: var(--motion-ease-state);\n  }\n\n  .sp-attachment[data-size=\"sm\"] {\n    --sp-attachment-media: var(--sp-rw-space-8);\n    --sp-attachment-padding: var(--sp-rw-space-1-5);\n\n    font-size: var(--sp-rw-font-12);\n    line-height: var(--sp-rw-line-16);\n  }\n\n  .sp-attachment[data-orientation=\"horizontal\"] {\n    min-width: calc(var(--sp-rw-space) * 40);\n  }\n\n  /* A vertical card stacks its media over its text: 96 px wide with media\n   * alone, 120 px with text. */\n  .sp-attachment[data-orientation=\"vertical\"] {\n    width: calc(var(--sp-rw-space) * 24);\n    flex-direction: column;\n    align-items: stretch;\n  }\n\n  .sp-attachment[data-orientation=\"vertical\"]:has(> .sp-attachment-content) {\n    width: calc(var(--sp-rw-space) * 30);\n  }\n\n  /* A file that was selected and waits to upload is drawn with a dashed edge,\n   * a failure with the destructive edge of an invalid field. The words of the\n   * state stay in the description, so the color is never the only cue. */\n  .sp-attachment[data-state=\"idle\"] {\n    border-style: dashed;\n  }\n\n  .sp-attachment[data-state=\"error\"] {\n    border-color: var(--sp-rw-border-destructive);\n  }\n\n  /* The media is a square tile for an icon, and the thumbnail of an image. */\n  .sp-attachment-media {\n    position: relative;\n    display: flex;\n    width: var(--sp-attachment-media);\n    flex: none;\n    aspect-ratio: 1;\n    align-items: center;\n    justify-content: center;\n    overflow: hidden;\n    border-radius: var(--sp-rw-radius-control);\n    background: var(--sp-rw-bg-muted);\n    color: var(--sp-rw-fg-secondary);\n  }\n\n  .sp-attachment[data-size=\"sm\"] .sp-attachment-media {\n    border-radius: var(--sp-rw-radius-sm);\n  }\n\n  .sp-attachment[data-orientation=\"vertical\"] .sp-attachment-media {\n    width: 100%;\n  }\n\n  /* A file type icon is a sheet with its own label, so it needs no tile. */\n  .sp-attachment-media:has(> .sp-file-type-icon) {\n    background: transparent;\n  }\n\n  .sp-attachment-media > svg:not([class*=\"size-\"]) {\n    width: var(--sp-rw-control-icon);\n    height: var(--sp-rw-control-icon);\n    flex: none;\n    pointer-events: none;\n  }\n\n  .sp-attachment[data-orientation=\"vertical\"]\n    .sp-attachment-media\n    > svg:not([class*=\"size-\"]) {\n    width: var(--sp-rw-space-6);\n    height: var(--sp-rw-space-6);\n  }\n\n  .sp-attachment-media[data-variant=\"image\"] > img {\n    width: 100%;\n    height: 100%;\n    aspect-ratio: 1;\n    object-fit: cover;\n  }\n\n  /* An image that is still on its way shows dimmed. */\n  .sp-attachment[data-state=\"uploading\"]\n    .sp-attachment-media[data-variant=\"image\"],\n  .sp-attachment[data-state=\"processing\"]\n    .sp-attachment-media[data-variant=\"image\"] {\n    opacity: 0.6;\n  }\n\n  .sp-attachment[data-state=\"error\"] .sp-attachment-media {\n    background: var(--sp-rw-bg-destructive-translucent);\n    color: var(--sp-rw-fg-destructive);\n  }\n\n  /* The text track may shrink below its text (rulebook K5), so a long name\n   * ends in an ellipsis and never pushes the actions out of the card. */\n  .sp-attachment-content {\n    display: flex;\n    min-width: 0;\n    max-width: 100%;\n    flex: 1 1 0%;\n    flex-direction: column;\n    gap: var(--sp-rw-space-0-5);\n  }\n\n  .sp-attachment[data-orientation=\"vertical\"] .sp-attachment-content {\n    padding-inline: var(--sp-rw-space-1);\n  }\n\n  .sp-attachment-title,\n  .sp-attachment-description {\n    display: block;\n    min-width: 0;\n    max-width: 100%;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  .sp-attachment-title {\n    font-weight: var(--sp-rw-font-weight-label);\n  }\n\n  .sp-attachment-description {\n    color: var(--sp-rw-fg-tertiary);\n    font-size: var(--sp-rw-font-12);\n    line-height: var(--sp-rw-line-16);\n  }\n\n  .sp-attachment[data-state=\"error\"] .sp-attachment-description {\n    color: var(--sp-rw-fg-destructive);\n  }\n\n  /* The actions sit above the trigger, so both stay clickable. On a vertical\n   * card they float 8 px inside the media's top-end corner. */\n  .sp-attachment-actions {\n    position: relative;\n    z-index: 20;\n    display: flex;\n    flex: none;\n    align-items: center;\n    gap: var(--sp-rw-space-0-5);\n  }\n\n  .sp-attachment[data-orientation=\"vertical\"] .sp-attachment-actions {\n    position: absolute;\n    inset-block-start: calc(\n      var(--sp-attachment-padding) + var(--sp-rw-space-2)\n    );\n    inset-inline-end: calc(var(--sp-attachment-padding) + var(--sp-rw-space-2));\n    gap: var(--sp-rw-space-1);\n  }\n\n  /* The group is a row of cards that scrolls sideways and snaps card by card.\n   * The ScrollArea fades the edge that has more to show; the snap clears\n   * that fade, as the tabs of a scrolling rail do. */\n  .sp-attachment-group {\n    width: 100%;\n    min-width: 0;\n    max-width: 100%;\n  }\n\n  .sp-attachment-group .sp-scroll-area-viewport {\n    scroll-padding-inline: var(--fade-size);\n    scroll-snap-type: x mandatory;\n  }\n\n  .sp-attachment-group-row {\n    display: flex;\n    width: max-content;\n    min-width: 100%;\n    align-items: flex-start;\n    gap: var(--sp-rw-space-2);\n  }\n\n  .sp-attachment-group-item {\n    display: flex;\n    flex: none;\n    scroll-snap-align: start;\n  }\n\n  /* A card in a row is at most 16 rem wide, so a long file name ends in an\n   * ellipsis there too; a max-w utility on the card widens it. */\n  .sp-attachment-group-item > .sp-attachment {\n    max-width: calc(var(--sp-rw-space) * 64);\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/attachment/attachment.css"},{"path":"packages/ui/src/components/attachment/index.ts","content":"export * from \"./attachment\";\n","type":"registry:ui","target":"~/src/siteplane/components/attachment/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/attachment.md","categories":["Chat & AI"],"meta":{"avoidWhen":["Avoid as the dropzone of an upload form; the card shows a file that was picked and takes no drop.","Avoid for a file manager or a document list with folders, sorting or bulk actions."],"description":"Show a file or an image attached to a message or a draft, with its upload state.","knownLimitations":["The card uploads nothing and reads no file. The application starts, retries and cancels the uploads and gives each card its state and its details in words.","A File Type Icon takes the type from the file name only; pass the thumbnail of an image as an img in AttachmentMedia.","AttachmentAction and AttachmentTrigger have no text, so each needs an aria-label that names the action and the file.","AttachmentGroup keeps its cards in one row that scrolls and does not wrap. A card that leaves it is inert while it fades, so move focus before removing one."],"providerNotes":["Depends on Button, Scroll Area and the motion component, which the registry installs with the attachment.","SiteplaneMotionProvider preset=\"reduced\" and the system setting stop the shimmer and let cards join and leave a group at once."],"releaseStatus":"preview","searchTerms":["attachment","file","image","thumbnail","upload","file chip","file part","chat"],"useWhen":["Use when a draft or a message carries files or images, each with its name, its details and actions such as remove, retry or download.","Use when the state of an upload, from waiting to done or failed, should show on the file itself."]}}