{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-card",
  "title": "Expandable Card",
  "description": "Expands into a centred panel with shared-layout transitions.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/velora/expandable-card.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface ExpandableCardProps {\n  title: string;\n  subtitle?: string;\n  /** Shown only once the card is expanded */\n  children: React.ReactNode;\n  /** Rendered in both the collapsed card and the expanded panel */\n  media?: React.ReactNode;\n  className?: string;\n}\n\n/**\n * Card that expands into a centred panel, with the title and media animating\n * between the two positions via a shared layout id.\n */\nexport function ExpandableCard({\n  title,\n  subtitle,\n  children,\n  media,\n  className,\n}: ExpandableCardProps) {\n  const [open, setOpen] = useState(false);\n  const id = useId();\n  const reducedMotion = useReducedMotion();\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const dialogRef = useRef<HTMLDivElement>(null);\n\n  // Modal focus handling: move focus in, keep Tab inside, Escape closes, and\n  // hand focus back to the trigger however the dialog was closed.\n  useEffect(() => {\n    if (!open) return;\n    const trigger = triggerRef.current;\n    const dialog = dialogRef.current;\n    dialog?.focus({ preventScroll: true });\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setOpen(false);\n        return;\n      }\n      if (event.key !== \"Tab\" || !dialog) return;\n      const focusable = Array.from(\n        dialog.querySelectorAll<HTMLElement>(\n          'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n        )\n      );\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      if (!first || !dialog.contains(active)) {\n        event.preventDefault();\n        (first ?? dialog).focus();\n      } else if (event.shiftKey && (active === first || active === dialog)) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && active === last) {\n        event.preventDefault();\n        first.focus();\n      }\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      trigger?.focus({ preventScroll: true });\n    };\n  }, [open]);\n\n  const spring = reducedMotion\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 320, damping: 32 };\n\n  return (\n    <>\n      <motion.button\n        ref={triggerRef}\n        type=\"button\"\n        layoutId={`card-${id}`}\n        onClick={() => setOpen(true)}\n        transition={spring}\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={open ? `dialog-${id}` : undefined}\n        data-slot=\"expandable-card\"\n        className={cn(\n          \"flex w-full max-w-sm cursor-pointer flex-col gap-3 rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n          className\n        )}\n      >\n        {media && (\n          <motion.div\n            layoutId={`media-${id}`}\n            transition={spring}\n            className=\"overflow-hidden rounded-xl\"\n          >\n            {media}\n          </motion.div>\n        )}\n        {/* Buttons only allow phrasing content, so the title is a span here\n            and becomes the dialog's heading once expanded. */}\n        <motion.span\n          layoutId={`title-${id}`}\n          transition={spring}\n          className=\"font-medium\"\n        >\n          {title}\n        </motion.span>\n        {subtitle && (\n          <span className=\"-mt-2 text-sm text-muted-foreground\">{subtitle}</span>\n        )}\n      </motion.button>\n\n      <AnimatePresence>\n        {open && (\n          <div className=\"fixed inset-0 z-50 grid place-items-center p-4\">\n            <motion.div\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              onClick={() => setOpen(false)}\n              className=\"absolute inset-0 bg-background/70 backdrop-blur-sm\"\n            />\n            <motion.div\n              ref={dialogRef}\n              id={`dialog-${id}`}\n              layoutId={`card-${id}`}\n              transition={spring}\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-labelledby={`title-${id}`}\n              tabIndex={-1}\n              className=\"relative flex w-full max-w-lg flex-col gap-4 rounded-2xl border bg-card p-6 shadow-2xl outline-none\"\n            >\n              {media && (\n                <motion.div\n                  layoutId={`media-${id}`}\n                  transition={spring}\n                  className=\"overflow-hidden rounded-xl\"\n                >\n                  {media}\n                </motion.div>\n              )}\n              <motion.h3\n                id={`title-${id}`}\n                layoutId={`title-${id}`}\n                transition={spring}\n                className=\"text-lg font-semibold\"\n              >\n                {title}\n              </motion.h3>\n              <div className=\"text-sm text-muted-foreground\">{children}</div>\n              <button\n                type=\"button\"\n                onClick={() => setOpen(false)}\n                className=\"self-start rounded-lg border px-3 py-1.5 text-sm transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n              >\n                Close\n              </button>\n            </motion.div>\n          </div>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/velora/expandable-card.tsx"
    }
  ],
  "type": "registry:ui"
}