{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "attachment",
  "title": "Attachment",
  "description": "A file or image with its name, details, upload state and actions.",
  "dependencies": [
    "@base-ui/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/button.json",
    "https://constella.aayush.cv/r/shimmer.json",
    "https://constella.aayush.cv/r/scroll-fade.json"
  ],
  "files": [
    {
      "path": "src/components/ai/attachment.tsx",
      "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { createContext, use } from \"react\";\nimport type { ComponentProps, ReactNode } from \"react\";\n\nimport { Shimmer } from \"@/components/ai/shimmer\";\nimport { Button } from \"@/components/primitives/button\";\nimport type { ButtonProps } from \"@/components/primitives/button\";\nimport { scrollFade } from \"@/lib/constella/scroll-fade\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  fontWeights,\n  lineHeights,\n  media,\n  opacities,\n  radii,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\nconst attachmentStates = [\n  \"idle\",\n  \"uploading\",\n  \"processing\",\n  \"error\",\n  \"done\",\n] as const;\nconst attachmentSizes = [\"xs\", \"sm\", \"default\"] as const;\nconst attachmentOrientations = [\"horizontal\", \"vertical\"] as const;\nconst attachmentMediaVariants = [\"default\", \"image\"] as const;\n\ntype AttachmentState = (typeof attachmentStates)[number];\ntype AttachmentSize = (typeof attachmentSizes)[number];\ntype AttachmentOrientation = (typeof attachmentOrientations)[number];\ntype AttachmentMediaVariant = (typeof attachmentMediaVariants)[number];\n\ntype Styled<T> = Omit<T, \"className\" | \"style\"> & {\n  sx?: Sx;\n};\n\nconst State = createContext<AttachmentState>(\"done\");\n\nconst root = \"[data-slot='attachment']\";\nconst inSize = (size: AttachmentSize) => `:is(${root}[data-size='${size}'] *)`;\nconst inState = (state: AttachmentState) =>\n  `:is(${root}[data-state='${state}'] *)`;\nconst vertical = `:is(${root}[data-orientation='vertical'] *)`;\nconst withMedia = \":has(> [data-slot='attachment-media'])\";\nconst withContent = \":has(> [data-slot='attachment-content'])\";\nconst pressable = \":has(> [data-slot='attachment-trigger'])\";\n\n// The media's corner plus the inset around it, so the two stay concentric.\nconst corner = {\n  default: `calc(${radii.sm} + ${space.xs})`,\n  \":is([data-size='sm'])\": `calc(${radii.sm} + ${space.row})`,\n  \":is([data-size='xs'])\": `calc(${radii.xs} + ${space.xxs})`,\n};\n\n// A card with media is inset evenly around it; one with only text, wider at the sides.\nconst textOnly = `:not(${withMedia})${withContent}`;\nconst padBlock = {\n  default: 0,\n  [textOnly]: space.xs,\n  [`:is([data-size='sm'])${textOnly}`]: space.row,\n  [`:is([data-size='xs'])${textOnly}`]: space.xxs,\n  [withMedia]: space.xs,\n  [`:is([data-size='sm'])${withMedia}`]: space.row,\n  [`:is([data-size='xs'])${withMedia}`]: space.xxs,\n};\nconst padInline = {\n  default: 0,\n  [textOnly]: space.popup,\n  [`:is([data-size='sm'])${textOnly}`]: space.xs,\n  [`:is([data-size='xs'])${textOnly}`]: space.row,\n  [withMedia]: space.xs,\n  [`:is([data-size='sm'])${withMedia}`]: space.row,\n  [`:is([data-size='xs'])${withMedia}`]: space.xxs,\n};\n\nconst styles = stylex.create({\n  attachment: {\n    alignItems: {\n      default: \"center\",\n      \":is([data-orientation='vertical'])\": null,\n    },\n    backgroundColor: {\n      default: colors.raised,\n      [media.hover]: {\n        default: colors.raised,\n        [`${pressable}:hover`]: colors.fillSubtle,\n      },\n    },\n    borderBlockEndColor: {\n      default: colors.border,\n      \":is([data-state='error'])\": colors.dangerBorder,\n    },\n    borderBlockStartColor: {\n      default: colors.border,\n      \":is([data-state='error'])\": colors.dangerBorder,\n    },\n    borderInlineEndColor: {\n      default: colors.border,\n      \":is([data-state='error'])\": colors.dangerBorder,\n    },\n    borderInlineStartColor: {\n      default: colors.border,\n      \":is([data-state='error'])\": colors.dangerBorder,\n    },\n    borderBlockEndStyle: {\n      default: \"solid\",\n      \":is([data-state='idle'])\": \"dashed\",\n    },\n    borderBlockStartStyle: {\n      default: \"solid\",\n      \":is([data-state='idle'])\": \"dashed\",\n    },\n    borderInlineEndStyle: {\n      default: \"solid\",\n      \":is([data-state='idle'])\": \"dashed\",\n    },\n    borderInlineStartStyle: {\n      default: \"solid\",\n      \":is([data-state='idle'])\": \"dashed\",\n    },\n    borderBlockEndWidth: strokes.border,\n    borderBlockStartWidth: strokes.border,\n    borderInlineEndWidth: strokes.border,\n    borderInlineStartWidth: strokes.border,\n    borderStartStartRadius: corner,\n    borderStartEndRadius: corner,\n    borderEndStartRadius: corner,\n    borderEndEndRadius: corner,\n    color: colors.textPrimary,\n    display: \"flex\",\n    flexDirection: {\n      default: \"row\",\n      \":is([data-orientation='vertical'])\": \"column\",\n    },\n    flexShrink: 0,\n    flexWrap: \"wrap\",\n    gap: {\n      default: space.xs,\n      \":is([data-orientation='horizontal'][data-size='default'])\": space.sm,\n      \":is([data-size='sm'])\": space.popup,\n      \":is([data-size='xs'])\": space.row,\n    },\n    maxWidth: \"100%\",\n    minWidth: {\n      default: 0,\n      \":is([data-orientation='horizontal'])\": sizes.itemText,\n    },\n    paddingBlockEnd: padBlock,\n    paddingBlockStart: padBlock,\n    // The text's end inset matches its gap from the media.\n    paddingInlineEnd: {\n      ...padInline,\n      [`:is([data-orientation='horizontal'][data-size='default'])${withMedia}`]:\n        space.sm,\n      [`:is([data-orientation='horizontal'][data-size='sm'])${withMedia}`]:\n        space.popup,\n      [`:is([data-orientation='horizontal'][data-size='xs'])${withMedia}`]:\n        space.row,\n    },\n    paddingInlineStart: padInline,\n    position: \"relative\",\n    scrollSnapAlign: \"start\",\n    transitionDuration: durations.hover,\n    transitionProperty: \"background-color, border-color\",\n    transitionTimingFunction: \"ease\",\n    width: {\n      default: \"fit-content\",\n      \":is([data-orientation='vertical'])\": sizes.attachment,\n      [`:is([data-orientation='vertical'])${withContent}`]:\n        sizes.attachmentWide,\n    },\n  },\n  media: {\n    alignItems: \"center\",\n    aspectRatio: \"1\",\n    backgroundColor: {\n      default: colors.fill,\n      [inState(\"error\")]: colors.dangerSubtle,\n    },\n    borderStartStartRadius: { default: radii.sm, [inSize(\"xs\")]: radii.xs },\n    borderStartEndRadius: { default: radii.sm, [inSize(\"xs\")]: radii.xs },\n    borderEndStartRadius: { default: radii.sm, [inSize(\"xs\")]: radii.xs },\n    borderEndEndRadius: { default: radii.sm, [inSize(\"xs\")]: radii.xs },\n    color: {\n      default: colors.textPrimary,\n      [inState(\"error\")]: colors.dangerText,\n    },\n    display: \"flex\",\n    flexShrink: 0,\n    justifyContent: \"center\",\n    overflow: \"hidden\",\n    position: \"relative\",\n    width: {\n      default: sizes.media,\n      [inSize(\"sm\")]: sizes.controlMd,\n      [inSize(\"xs\")]: sizes.controlSm,\n      [vertical]: \"100%\",\n    },\n  },\n  // An image waits dimmed until its file is in.\n  image: {\n    opacity: {\n      default: opacities.busy,\n      [inState(\"done\")]: 1,\n      [inState(\"idle\")]: 1,\n    },\n  },\n  // Two lines exactly the media's height, so the media's inset holds on every side; xs has room for one.\n  content: {\n    alignItems: { default: null, [inSize(\"xs\")]: \"baseline\" },\n    columnGap: { default: null, [inSize(\"xs\")]: space.xs },\n    display: { default: null, [inSize(\"xs\")]: \"flex\" },\n    flex: \"1\",\n    lineHeight: {\n      default: lineHeights.textSm,\n      [inSize(\"sm\")]: lineHeights.captionLg,\n    },\n    maxWidth: \"100%\",\n    minWidth: 0,\n    paddingInlineEnd: { default: null, [vertical]: space.xxs },\n    paddingInlineStart: { default: null, [vertical]: space.xxs },\n  },\n  title: {\n    color: {\n      default: null,\n      [inState(\"uploading\")]: colors.textSecondary,\n      [inState(\"processing\")]: colors.textSecondary,\n    },\n    display: \"block\",\n    fontWeight: fontWeights.medium,\n    maxWidth: \"100%\",\n    minWidth: 0,\n    overflow: \"hidden\",\n    textOverflow: \"ellipsis\",\n    whiteSpace: \"nowrap\",\n  },\n  description: {\n    color: {\n      default: colors.textSecondary,\n      [inState(\"error\")]: colors.dangerText,\n    },\n    display: \"block\",\n    flexShrink: 0,\n    lineHeight: { default: null, [inSize(\"sm\")]: lineHeights.captionLg },\n    marginBlockStart: { default: 0, [vertical]: space.xxxs },\n    maxWidth: \"100%\",\n    minWidth: 0,\n    overflow: \"hidden\",\n    textOverflow: \"ellipsis\",\n    whiteSpace: \"nowrap\",\n  },\n  // Over the trigger, so each action stays its own target. Over a picture it sits on its own\n  // `raised` chip, so a ghost action's hover fill never shows the image through.\n  actions: {\n    alignItems: \"center\",\n    backgroundColor: { default: null, [vertical]: colors.raised },\n    borderStartStartRadius: { default: null, [vertical]: radii.sm },\n    borderStartEndRadius: { default: null, [vertical]: radii.sm },\n    borderEndStartRadius: { default: null, [vertical]: radii.sm },\n    borderEndEndRadius: { default: null, [vertical]: radii.sm },\n    display: \"flex\",\n    flexShrink: 0,\n    gap: { default: null, [vertical]: space.xxs },\n    insetBlockStart: { default: null, [vertical]: space.sm },\n    insetInlineEnd: { default: null, [vertical]: space.sm },\n    position: { default: \"relative\", [vertical]: \"absolute\" },\n    zIndex: 2,\n  },\n  // Covers the whole card, so the card opens from anywhere on it; its ring follows the corner.\n  trigger: {\n    backgroundColor: \"transparent\",\n    borderBlockEndWidth: 0,\n    borderBlockStartWidth: 0,\n    borderInlineEndWidth: 0,\n    borderInlineStartWidth: 0,\n    borderStartStartRadius: \"inherit\",\n    borderStartEndRadius: \"inherit\",\n    borderEndStartRadius: \"inherit\",\n    borderEndEndRadius: \"inherit\",\n    cursor: \"pointer\",\n    insetBlockEnd: 0,\n    insetBlockStart: 0,\n    insetInlineEnd: 0,\n    insetInlineStart: 0,\n    paddingBlockEnd: 0,\n    paddingBlockStart: 0,\n    paddingInlineEnd: 0,\n    paddingInlineStart: 0,\n    position: \"absolute\",\n    zIndex: 1,\n  },\n  group: {\n    display: \"flex\",\n    gap: space.xs,\n    minWidth: 0,\n    overflowX: \"auto\",\n    overscrollBehaviorX: \"contain\",\n    paddingBlockEnd: space.xxs,\n    paddingBlockStart: space.xxs,\n    scrollPaddingInline: space.xxs,\n    scrollSnapType: \"x mandatory\",\n    scrollbarWidth: \"none\",\n  },\n});\n\nconst mediaStyles = {\n  default: null,\n  image: styles.image,\n} satisfies Record<AttachmentMediaVariant, stylex.StyleXStyles | null>;\n\ntype AttachmentProps = Styled<ComponentProps<\"div\">> & {\n  orientation?: AttachmentOrientation;\n  size?: AttachmentSize;\n  state?: AttachmentState;\n};\n\n/** A file or image: its media, name and details, upload state and actions. */\nfunction Attachment({\n  orientation = \"horizontal\",\n  size = \"default\",\n  state = \"done\",\n  sx,\n  ...props\n}: AttachmentProps) {\n  return (\n    <State value={state}>\n      <div\n        data-orientation={orientation}\n        data-size={size}\n        data-slot=\"attachment\"\n        data-state={state}\n        {...props}\n        {...stylex.props([typeStyles.textSm, styles.attachment], sx)}\n      />\n    </State>\n  );\n}\n\nfunction AttachmentMedia({\n  sx,\n  variant = \"default\",\n  ...props\n}: Styled<ComponentProps<\"div\">> & { variant?: AttachmentMediaVariant }) {\n  return (\n    <div\n      data-slot=\"attachment-media\"\n      data-variant={variant}\n      {...props}\n      {...stylex.props(styles.media, mediaStyles[variant], sx)}\n    />\n  );\n}\n\nfunction AttachmentContent({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"attachment-content\"\n      {...props}\n      {...stylex.props(styles.content, sx)}\n    />\n  );\n}\n\n/** The file's name; it shimmers while the file uploads or processes. */\nfunction AttachmentTitle({\n  children,\n  sx,\n  ...props\n}: Styled<ComponentProps<\"span\">> & { children?: ReactNode }) {\n  const state = use(State);\n  const working = state === \"uploading\" || state === \"processing\";\n  return (\n    <span\n      data-slot=\"attachment-title\"\n      {...props}\n      {...stylex.props(styles.title, sx)}\n    >\n      {working ? <Shimmer>{children}</Shimmer> : children}\n    </span>\n  );\n}\n\nfunction AttachmentDescription({\n  sx,\n  ...props\n}: Styled<ComponentProps<\"span\">>) {\n  return (\n    <span\n      data-slot=\"attachment-description\"\n      {...props}\n      {...stylex.props([typeStyles.textSm, styles.description], sx)}\n    />\n  );\n}\n\nfunction AttachmentActions({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"attachment-actions\"\n      {...props}\n      {...stylex.props(styles.actions, sx)}\n    />\n  );\n}\n\nfunction AttachmentAction({\n  size = \"icon-xs\",\n  variant = \"ghost\",\n  ...props\n}: ButtonProps) {\n  return (\n    <Button\n      data-slot=\"attachment-action\"\n      size={size}\n      variant={variant}\n      {...props}\n    />\n  );\n}\n\n/** Covers the card to open the file; `render` an `a` for a link. */\nfunction AttachmentTrigger({\n  render,\n  sx,\n  type,\n  ...props\n}: Styled<useRender.ComponentProps<\"button\">>) {\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(\n      { type: render ? type : (type ?? \"button\") },\n      props,\n      stylex.props(styles.trigger, sx)\n    ),\n    render,\n    state: { slot: \"attachment-trigger\" },\n  });\n}\n\n/** A row of attachments that scrolls and snaps, its edges fading while there's more. */\nfunction AttachmentGroup({ sx, ...props }: Styled<ComponentProps<\"div\">>) {\n  return (\n    <div\n      data-slot=\"attachment-group\"\n      {...props}\n      {...stylex.props(styles.group, scrollFade.x, sx)}\n    />\n  );\n}\n\nexport {\n  Attachment,\n  AttachmentAction,\n  AttachmentActions,\n  AttachmentContent,\n  AttachmentDescription,\n  AttachmentGroup,\n  AttachmentMedia,\n  AttachmentTitle,\n  AttachmentTrigger,\n  attachmentMediaVariants,\n  attachmentOrientations,\n  attachmentSizes,\n  attachmentStates,\n};\nexport type {\n  AttachmentMediaVariant,\n  AttachmentOrientation,\n  AttachmentProps,\n  AttachmentSize,\n  AttachmentState,\n};\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/attachment/index.md",
  "type": "registry:ui"
}