{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-card",
  "type": "registry:ui",
  "title": "Expandable Card",
  "description": "Cards that open in place to show details while the others make room.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/expandable-card.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, use, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype GroupContextValue = { single: boolean; value: string | null; setValue: (next: string | null) => void };\nconst GroupContext = createContext<GroupContextValue | null>(null);\n\ntype CardContextValue = {\n  open: boolean;\n  disabled: boolean;\n  /** True when the last change came from Escape: keyboard dismissals don't animate. */\n  instant: boolean;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n};\nconst CardContext = createContext<CardContextValue | null>(null);\nconst useCard = () => {\n  const ctx = use(CardContext);\n  if (!ctx) throw new Error(\"ExpandableCard parts must be inside <ExpandableCard>\");\n  return ctx;\n};\n\nexport type ExpandableCardGroupProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** One card open at a time. Opening another closes the first. */\n  single?: boolean;\n  /** The open card's value (single mode). */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n};\n\n/**\n * Cards that share one layout: when one grows, the others glide to their new\n * places instead of jumping. Lays out as a column unless you pass a className.\n */\nexport function ExpandableCardGroup({ single = true, value, defaultValue = null, onValueChange, className, children, ...rest }: ExpandableCardGroupProps) {\n  const [current, setCurrent] = useControllableState<string | null>({ value, defaultValue, onChange: onValueChange });\n  return (\n    <GroupContext value={{ single, value: current, setValue: setCurrent }}>\n      <LayoutGroup>\n        <div className={className ?? \"flex flex-col gap-2\"} {...rest}>\n          {children}\n        </div>\n      </LayoutGroup>\n    </GroupContext>\n  );\n}\n\nexport type ExpandableCardProps = Omit<React.ComponentProps<typeof motion.div>, \"children\"> & {\n  children?: React.ReactNode;\n  /** Identifies the card inside a single-open group. */\n  value?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n};\n\nexport function ExpandableCard({ value, open, defaultOpen = false, onOpenChange, disabled = false, className, style, onKeyDown, children, ...rest }: ExpandableCardProps) {\n  const group = use(GroupContext);\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const key = value ?? uid;\n  const [own, setOwn] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });\n  const grouped = !!group?.single;\n  const isOpen = grouped ? group.value === key : own;\n  const [instant, setInstant] = useState(false);\n\n  const set = (next: boolean, viaKeyboard = false) => {\n    if (disabled || next === isOpen) return;\n    setInstant(viaKeyboard);\n    if (grouped) {\n      group.setValue(next ? key : null);\n      onOpenChange?.(next);\n    } else setOwn(next);\n  };\n\n  const ids = { triggerId: `${uid}-trigger`, contentId: `${uid}-content` };\n\n  return (\n    <CardContext value={{ open: isOpen, disabled, instant, toggle: () => set(!isOpen), ...ids }}>\n      <motion.div\n        // Position and size changes caused by other cards (or a grid span) glide; this card's own growth is its height, below.\n        layout\n        transition={reduce || instant ? { duration: 0 } : spring.soft}\n        data-state={isOpen ? \"open\" : \"closed\"}\n        data-disabled={disabled || undefined}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          if (e.key !== \"Escape\" || !isOpen || e.defaultPrevented) return;\n          // Escape closes the innermost open card only, at once, and hands focus back to its trigger.\n          e.preventDefault();\n          e.stopPropagation();\n          set(false, true);\n          document.getElementById(ids.triggerId)?.focus();\n        }}\n        // Radius in style so Motion can keep the corners round while it scales the box.\n        style={{ borderRadius: 12, ...style }}\n        className={cn(\n          \"group/card relative min-w-0 overflow-hidden border border-line-2 bg-raised shadow-[var(--shadow)]\",\n          \"transition-[border-color,scale] duration-150 ease-out\",\n          \"has-[[data-expandable-trigger]:hover]:border-fg-4 data-disabled:has-[[data-expandable-trigger]:hover]:border-line-2\",\n          \"has-[[data-expandable-trigger]:active]:scale-[0.99] motion-reduce:has-[[data-expandable-trigger]:active]:scale-100\",\n          \"has-[[data-expandable-trigger]:focus-visible]:outline-solid has-[[data-expandable-trigger]:focus-visible]:outline-1 has-[[data-expandable-trigger]:focus-visible]:outline-offset-2 has-[[data-expandable-trigger]:focus-visible]:outline-fg-3\",\n          \"data-disabled:opacity-60\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </motion.div>\n    </CardContext>\n  );\n}\n\nexport type ExpandableCardTriggerProps = Omit<React.ComponentProps<\"button\">, \"type\"> & {\n  /** Show the chevron at the end of the row. */\n  indicator?: boolean;\n};\n\n/** The summary row. Press it to open; press it again (or Escape) to close. */\nexport function ExpandableCardTrigger({ indicator = true, className, children, onClick, ...rest }: ExpandableCardTriggerProps) {\n  const { open, disabled, toggle, triggerId, contentId } = useCard();\n  return (\n    <motion.button\n      layout=\"position\"\n      type=\"button\"\n      id={triggerId}\n      data-expandable-trigger=\"\"\n      aria-expanded={open}\n      aria-controls={contentId}\n      disabled={disabled}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!e.defaultPrevented) toggle();\n      }}\n      className={cn(\n        \"group/trigger flex w-full min-w-0 select-none items-center gap-3 p-4 text-left outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent] disabled:cursor-not-allowed\",\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.button>)}\n    >\n      <span className=\"flex min-w-0 flex-1 items-center gap-3\">{children}</span>\n      {indicator && (\n        <svg\n          width=\"16\"\n          height=\"16\"\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.4}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n          className={cn(\n            \"shrink-0 text-fg-3 transition-[rotate,color] duration-[260ms] ease-in-out-quart motion-reduce:transition-none group-hover/trigger:text-fg\",\n            open && \"rotate-180\",\n          )}\n        >\n          <path d=\"m4.5 6.25 3.5 3.5 3.5-3.5\" />\n        </svg>\n      )}\n    </motion.button>\n  );\n}\n\nexport type ExpandableCardContentProps = React.ComponentProps<\"div\">;\n\n/**\n * The details. Height grows on a soft spring, pushing the cards below along with it;\n * the content fades up once there is room, and leaves before the height closes over it.\n */\nexport function ExpandableCardContent({ className, children, ...rest }: ExpandableCardContentProps) {\n  const { open, instant, contentId, triggerId } = useCard();\n  const reduce = useReducedMotion();\n  const still = reduce || instant;\n\n  return (\n    <AnimatePresence initial={false}>\n      {open && (\n        <motion.div\n          key=\"content\"\n          id={contentId}\n          role=\"region\"\n          aria-labelledby={triggerId}\n          initial={{ height: 0 }}\n          animate={{ height: \"auto\", transition: still ? { duration: 0 } : spring.soft }}\n          exit={{ height: 0, transition: still ? { duration: 0 } : { duration: 0.22, ease: ease.inOut } }}\n          className=\"overflow-hidden\"\n        >\n          <motion.div\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\", transition: still ? { duration: 0 } : { duration: 0.26, ease: ease.out, delay: 0.06 } }}\n            exit={{ opacity: 0, transition: { duration: still ? 0 : 0.12 } }}\n            className={cn(\"px-4 pb-4\", className)}\n            {...(rest as React.ComponentProps<typeof motion.div>)}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/expandable-card"
}
