{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "composer",
  "title": "Composer",
  "description": "The chat composer from the Notra dashboard: a muted tray around the input card, with queued messages and attachments as chips. Built on the Notra Button and Tooltip and the shadcn Textarea.",
  "dependencies": [
    "@base-ui/react",
    "cn",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.usenotra.com/r/notra-button.json",
    "https://ui.usenotra.com/r/notra-tooltip.json",
    "spinner",
    "textarea"
  ],
  "files": [
    {
      "path": "registry/notra/composer/components/composer.tsx",
      "content": "\"use client\";\n\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cn } from \"cn\";\nimport { ArrowUpIcon, PencilIcon, XIcon } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  domAnimation,\n  LazyMotion,\n  m,\n  useReducedMotion,\n} from \"motion/react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { Button } from \"../../button/components/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"../../tooltip/components/tooltip\";\nimport {\n  COMPOSER_CHIP_ACTION,\n  COMPOSER_INPUT,\n  COMPOSER_NUDGE_TRANSITION,\n  COMPOSER_SEND_ACTIVE,\n  COMPOSER_SEND_BUTTON,\n  COMPOSER_SEND_BUTTON_SM,\n  COMPOSER_SEND_INERT,\n  COMPOSER_TOOLBAR_BUTTON,\n  COMPOSER_TOOLBAR_BUTTON_LABEL,\n  COMPOSER_TRAY_TRANSITION,\n} from \"../constants/composer\";\nimport type {\n  ComposerChipProps,\n  ComposerFrameProps,\n  ComposerInputProps,\n  ComposerNudgeProps,\n  ComposerSendProps,\n  ComposerToolbarButtonProps,\n  ComposerToolbarProps,\n} from \"../types/composer\";\n\nfunction ComposerNudgeShell({ children }: { children: ReactNode }) {\n  const reduceMotion = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useLayoutEffect(() => {\n    const element = contentRef.current;\n    if (!element) {\n      return;\n    }\n    setHeight(element.offsetHeight);\n    const observer = new ResizeObserver(() => setHeight(element.offsetHeight));\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <m.div\n      // react-doctor-disable-next-line react-doctor/no-layout-property-animation -- the tray has to push the input card down, a transform would leave a gap\n      animate={{ height, opacity: 1 }}\n      className=\"overflow-hidden\"\n      // react-doctor-disable-next-line react-doctor/no-layout-property-animation -- the tray has to push the input card down, a transform would leave a gap\n      exit={{ height: 0, opacity: 0 }}\n      // react-doctor-disable-next-line react-doctor/no-layout-property-animation -- the tray has to push the input card down, a transform would leave a gap\n      initial={{ height: 0, opacity: 0 }}\n      transition={reduceMotion ? { duration: 0 } : COMPOSER_NUDGE_TRANSITION}\n    >\n      <div ref={contentRef}>{children}</div>\n    </m.div>\n  );\n}\n\nexport function ComposerFrame({\n  children,\n  nudge,\n  connectedTop = false,\n  flat = false,\n  size = \"default\",\n  className,\n}: ComposerFrameProps) {\n  const hasNudge = Boolean(nudge);\n  const showTray = hasNudge || !flat;\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full min-w-0 flex-col\",\n        COMPOSER_TRAY_TRANSITION,\n        showTray ? \"bg-muted p-1\" : \"bg-transparent\",\n        flat\n          ? size === \"sm\"\n            ? \"rounded-[22px]\"\n            : \"rounded-[29px]\"\n          : \"rounded-[18px]\",\n        // Chips above the pill sit near the top edge, where the pill's wide\n        // corners would crowd them, so the tray's top matches the card's.\n        flat && hasNudge && \"rounded-t-[18px]\",\n        connectedTop && \"rounded-t-none\",\n        className\n      )}\n      data-slot=\"composer\"\n    >\n      <LazyMotion features={domAnimation} strict>\n        <AnimatePresence initial={false}>\n          {nudge ? (\n            <ComposerNudgeShell key=\"nudge\">{nudge}</ComposerNudgeShell>\n          ) : null}\n        </AnimatePresence>\n      </LazyMotion>\n      <div\n        className={cn(\n          \"border-border/70 bg-background min-w-0 overflow-hidden border\",\n          flat\n            ? // pill input with a round send button; the radius is half the\n              // single-line height so wrapped lines keep the button concentric\n              cn(\n                \"**:data-[slot=composer-send]:rounded-full **:data-[slot=composer-send]:[corner-shape:round] **:data-[slot=composer-send]:supports-[corner-shape:squircle]:rounded-full\",\n                size === \"sm\" ? \"rounded-[18px]\" : \"rounded-[25px]\"\n              )\n            : \"rounded-[14px] shadow-[inset_0_1px_0_rgba(255,255,255,0.9),inset_0_0_6px_rgba(255,255,255,0.8),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),inset_0_0_6px_rgba(255,255,255,0.03)]\",\n          connectedTop && !hasNudge && \"rounded-t-none border-t-0\"\n        )}\n        data-slot=\"composer-card\"\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport function ComposerNudge({ title, action, children }: ComposerNudgeProps) {\n  const hasChips = Boolean(children);\n\n  return (\n    <div\n      className={cn(\n        \"flex min-w-0 items-center gap-2 px-2.25 pt-0.5 pb-1\",\n        hasChips && \"flex-wrap\"\n      )}\n      data-slot=\"composer-nudge\"\n    >\n      {title && !hasChips ? (\n        <p className=\"min-w-0 flex-1 text-xs font-medium wrap-anywhere\">\n          {title}\n        </p>\n      ) : null}\n      {hasChips ? (\n        <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n          {children}\n        </div>\n      ) : null}\n      {action ? (\n        <div className=\"ml-auto flex shrink-0 items-center\">{action}</div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction ComposerChipAction({\n  icon: Icon,\n  label,\n  onClick,\n}: {\n  icon: LucideIcon;\n  label: string;\n  onClick: () => void;\n}) {\n  return (\n    <Button\n      aria-label={label}\n      className={COMPOSER_CHIP_ACTION}\n      onClick={onClick}\n      size=\"icon-xs\"\n      variant=\"ghost\"\n    >\n      <Icon className=\"size-3\" />\n    </Button>\n  );\n}\n\nexport function ComposerChip({\n  icon,\n  label,\n  onRemove,\n  removeLabel,\n  onEdit,\n  editLabel,\n  onSteer,\n  steerLabel,\n  onClick,\n  previewLabel,\n  pending = false,\n  className,\n  labelClassName,\n}: ComposerChipProps) {\n  const content = (\n    <>\n      {icon}\n      <span className={cn(\"max-w-48 truncate\", labelClassName)} title={label}>\n        {label}\n      </span>\n    </>\n  );\n\n  return (\n    <span\n      aria-busy={pending || undefined}\n      className={cn(\n        \"border-foreground/25 bg-background text-foreground inline-flex max-w-full items-center gap-1.5 rounded-md border border-dashed py-1 pr-1 pl-1.5 text-xs\",\n        pending && \"border-foreground/15 text-muted-foreground\",\n        className\n      )}\n      data-slot=\"composer-chip\"\n    >\n      {onClick ? (\n        <ButtonPrimitive\n          aria-label={previewLabel ?? `Preview ${label}`}\n          className=\"hover:text-foreground focus-visible:ring-ring/50 flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-left transition-colors outline-none focus-visible:ring-3\"\n          onClick={onClick}\n        >\n          {content}\n        </ButtonPrimitive>\n      ) : (\n        content\n      )}\n      {onSteer && !pending ? (\n        <ComposerChipAction\n          icon={ArrowUpIcon}\n          label={steerLabel ?? `Send ${label} now`}\n          onClick={onSteer}\n        />\n      ) : null}\n      {onEdit && !pending ? (\n        <ComposerChipAction\n          icon={PencilIcon}\n          label={editLabel ?? `Edit ${label}`}\n          onClick={onEdit}\n        />\n      ) : null}\n      {onRemove ? (\n        <ComposerChipAction\n          icon={XIcon}\n          label={removeLabel ?? `Remove ${label}`}\n          onClick={onRemove}\n        />\n      ) : null}\n    </span>\n  );\n}\n\nexport function ComposerInput({ className, ...props }: ComposerInputProps) {\n  return (\n    <Textarea\n      className={cn(COMPOSER_INPUT, className)}\n      data-slot=\"composer-input\"\n      {...props}\n    />\n  );\n}\n\nexport function ComposerToolbar({ children, className }: ComposerToolbarProps) {\n  return (\n    <div\n      className={cn(\"flex items-center gap-1 px-2 pt-1 pb-2\", className)}\n      data-slot=\"composer-toolbar\"\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function ComposerToolbarButton({\n  className,\n  size = \"sm\",\n  ...props\n}: ComposerToolbarButtonProps) {\n  const isIconOnly = typeof size === \"string\" && size.startsWith(\"icon\");\n\n  return (\n    <Button\n      className={cn(\n        COMPOSER_TOOLBAR_BUTTON,\n        !isIconOnly && COMPOSER_TOOLBAR_BUTTON_LABEL,\n        className\n      )}\n      size={size}\n      variant=\"ghost\"\n      {...props}\n    />\n  );\n}\n\nexport function ComposerSend({\n  children,\n  label,\n  tooltip,\n  busy = false,\n  disabled = false,\n  size = \"default\",\n  onClick,\n}: ComposerSendProps) {\n  const isInert = disabled && !busy;\n  const buttonProps = {\n    \"aria-busy\": busy || undefined,\n    \"aria-label\": label,\n    className: cn(\n      size === \"sm\" ? COMPOSER_SEND_BUTTON_SM : COMPOSER_SEND_BUTTON,\n      isInert ? COMPOSER_SEND_INERT : COMPOSER_SEND_ACTIVE\n    ),\n    disabled,\n    focusableWhenDisabled: true,\n    onClick,\n    size: \"icon\",\n    variant: isInert ? \"secondary\" : \"default\",\n  } satisfies ComponentProps<typeof Button>;\n\n  const content = busy ? (\n    <Spinner className={size === \"sm\" ? \"size-3\" : \"size-3.5\"} />\n  ) : (\n    children\n  );\n\n  if (!tooltip) {\n    return (\n      <Button data-slot=\"composer-send\" {...buttonProps}>\n        {content}\n      </Button>\n    );\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={<Button data-slot=\"composer-send\" {...buttonProps} />}\n      >\n        {content}\n      </TooltipTrigger>\n      <TooltipContent>{tooltip}</TooltipContent>\n    </Tooltip>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/composer/components/composer.tsx"
    },
    {
      "path": "registry/notra/composer/constants/composer.ts",
      "content": "export const COMPOSER_TRAY_TRANSITION =\n  \"transition-[background-color,padding,border-radius] duration-300 ease-[cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none\";\n\nexport const COMPOSER_NUDGE_TRANSITION = {\n  duration: 0.3,\n  ease: [0.65, 0, 0.35, 1],\n} as const;\n\nexport const COMPOSER_INPUT =\n  \"block max-h-50 min-h-12 min-w-0 resize-none overflow-y-auto rounded-none border-0 bg-transparent px-3 py-2 text-foreground text-sm leading-6 caret-foreground shadow-none focus-visible:border-transparent focus-visible:ring-0 disabled:bg-transparent md:text-sm dark:bg-transparent dark:disabled:bg-transparent\";\n\nexport const COMPOSER_TOOLBAR_BUTTON =\n  \"rounded-md text-muted-foreground data-disabled:cursor-not-allowed data-disabled:opacity-50\";\n\nexport const COMPOSER_TOOLBAR_BUTTON_LABEL = \"px-2 text-sm\";\n\nexport const COMPOSER_SEND_BUTTON =\n  \"size-8 data-disabled:not-aria-busy:opacity-100\";\n\n/** Dense send for single-line inline composers like comment replies. */\nexport const COMPOSER_SEND_BUTTON_SM =\n  \"size-7 data-disabled:not-aria-busy:opacity-100 [&_svg:not([class*='size-'])]:size-3.5\";\n\nexport const COMPOSER_SEND_ACTIVE = \"\";\n\nexport const COMPOSER_SEND_INERT =\n  \"text-muted-foreground/70 hover:brightness-100 data-disabled:not-aria-busy:cursor-default\";\n\nexport const COMPOSER_CHIP_ACTION =\n  \"size-4 rounded text-muted-foreground hover:bg-accent\";\n",
      "type": "registry:lib",
      "target": "components/composer/constants/composer.ts"
    },
    {
      "path": "registry/notra/composer/types/composer.ts",
      "content": "import type { ComponentProps, ReactNode } from \"react\";\n\nimport type { Button } from \"../../button/components/button\";\n\nexport interface ComposerFrameProps {\n  children: ReactNode;\n  /** Queued messages, attachments or notices, shown on the tray above the card. */\n  nudge?: ReactNode;\n  /** Drops the top radius so the frame can sit flush under another surface. */\n  connectedTop?: boolean;\n  /** Tighter radius, no shadow and no tray until there is a nudge. For inline composers like comments. */\n  flat?: boolean;\n  /** `sm` is a 36px single-line pill for dense inline composers. Pair it with `Composer.Send size=\"sm\"`. Only applies with `flat`. */\n  size?: \"default\" | \"sm\";\n  className?: string;\n}\n\nexport interface ComposerNudgeProps {\n  title?: ReactNode;\n  action?: ReactNode;\n  children?: ReactNode;\n}\n\nexport interface ComposerChipProps {\n  icon?: ReactNode;\n  label: string;\n  onRemove?: () => void;\n  removeLabel?: string;\n  onEdit?: () => void;\n  editLabel?: string;\n  onSteer?: () => void;\n  steerLabel?: string;\n  onClick?: () => void;\n  previewLabel?: string;\n  pending?: boolean;\n  className?: string;\n  labelClassName?: string;\n}\n\nexport type ComposerInputProps = ComponentProps<\"textarea\">;\n\nexport interface ComposerToolbarProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/** A ghost `Button`. Use `size=\"icon-sm\"` for icon-only actions. */\nexport type ComposerToolbarButtonProps = Omit<\n  ComponentProps<typeof Button>,\n  \"variant\"\n>;\n\nexport interface ComposerSendProps {\n  children: ReactNode;\n  label: string;\n  tooltip?: ReactNode;\n  busy?: boolean;\n  disabled?: boolean;\n  /** `sm` is a 28px button for single-line inline composers. */\n  size?: \"default\" | \"sm\";\n  onClick?: () => void;\n}\n",
      "type": "registry:lib",
      "target": "components/composer/types/composer.ts"
    }
  ],
  "type": "registry:component"
}