{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "expandable-dialog",
  "title": "Expandable Dialog",
  "description": "A shared-layout dialog that expands list items into an accessible detail view.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/expandable-dialog.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ExpandableDialogItem = {\n  id: string;\n  title: string;\n  description: string;\n  content: React.ReactNode;\n  image: string;\n  imageAlt?: string;\n  actionLabel?: string;\n};\n\nexport type ExpandableDialogProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\" | \"value\"\n> & {\n  items: readonly ExpandableDialogItem[];\n  /** Id of the expanded item, or null when collapsed. */\n  value?: string | null;\n  defaultValue?: string | null;\n  /** Receives the expanded item's id, plus the item itself for convenience. */\n  onValueChange?: (\n    id: string | null,\n    item: ExpandableDialogItem | null,\n  ) => void;\n  onAction?: (item: ExpandableDialogItem) => void;\n  actionLabel?: string;\n  modalLabel?: string;\n  listClassName?: string;\n  itemClassName?: string;\n};\n\n\n/** How long a click waits for image decode before opening regardless. */\nconst DECODE_BUDGET_MS = 200;\n\nfunction wait(duration: number) {\n  return new Promise<void>((resolve) => {\n    window.setTimeout(resolve, duration);\n  });\n}\n\nconst actionButtonClassName =\n  \"inline-flex h-7 shrink-0 items-center justify-center rounded-[min(var(--radius-md),12px)] border border-transparent bg-primary bg-clip-padding px-2.5 pt-px text-[0.8rem] leading-[1.4285714286] font-medium whitespace-nowrap text-primary-foreground outline-none transition-colors hover:bg-primary/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nexport function ExpandableDialog({\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  onAction,\n  actionLabel = \"Open\",\n  modalLabel,\n  className,\n  listClassName,\n  itemClassName,\n  ...props\n}: ExpandableDialogProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const reactId = React.useId();\n  const titleId = `${reactId}-title`;\n  const descriptionId = `${reactId}-description`;\n  const dialogRef = React.useRef<HTMLDivElement>(null);\n  const previouslyFocusedElement = React.useRef<HTMLElement | null>(null);\n  const imageDecodePromises = React.useRef(new Map<string, Promise<void>>());\n  const decodedImages = React.useRef(new Set<string>());\n  const openRequestId = React.useRef(0);\n  const isControlled = value !== undefined;\n  const [uncontrolledValue, setUncontrolledValue] = React.useState<\n    string | null\n  >(defaultValue);\n\n  const activeItemValue = isControlled ? value : uncontrolledValue;\n  const activeItem =\n    items.find((item) => item.id === activeItemValue) ?? null;\n\n  const setActiveItem = React.useCallback(\n    (nextItem: ExpandableDialogItem | null) => {\n      if (!isControlled) {\n        setUncontrolledValue(nextItem?.id ?? null);\n      }\n\n      onValueChange?.(nextItem?.id ?? null, nextItem);\n    },\n    [isControlled, onValueChange],\n  );\n\n  const decodeImage = React.useCallback((src: string) => {\n    return new Promise<void>((resolve) => {\n      const image = new Image();\n\n      image.decoding = \"sync\";\n      image.onload = () => {\n        if (typeof image.decode === \"function\") {\n          image.decode().then(resolve, resolve);\n        } else {\n          resolve();\n        }\n      };\n      image.onerror = () => resolve();\n      image.src = src;\n    });\n  }, []);\n\n  const prepareImage = React.useCallback(\n    (src: string) => {\n      if (decodedImages.current.has(src)) {\n        return Promise.resolve();\n      }\n\n      const cachedPromise = imageDecodePromises.current.get(src);\n\n      if (cachedPromise) {\n        return cachedPromise;\n      }\n\n      const promise = decodeImage(src).then(() => {\n        decodedImages.current.add(src);\n      });\n\n      imageDecodePromises.current.set(src, promise);\n\n      return promise;\n    },\n    [decodeImage],\n  );\n\n  const closeActiveItem = React.useCallback(() => {\n    openRequestId.current += 1;\n    setActiveItem(null);\n  }, [setActiveItem]);\n\n  const handleAction = React.useCallback(\n    (item: ExpandableDialogItem) => {\n      onAction?.(item);\n      closeActiveItem();\n    },\n    [closeActiveItem, onAction],\n  );\n\n  const openItem = React.useCallback(\n    async (item: ExpandableDialogItem) => {\n      const requestId = openRequestId.current + 1;\n\n      openRequestId.current = requestId;\n      // Decoding first keeps the open animation from stuttering, but a slow\n      // remote image must never make the click feel dead: past this budget the\n      // modal opens anyway and the image lands when it lands.\n      await Promise.race([prepareImage(item.image), wait(DECODE_BUDGET_MS)]);\n\n      if (openRequestId.current === requestId) {\n        setActiveItem(item);\n      }\n    },\n    [prepareImage, setActiveItem],\n  );\n\n  React.useEffect(() => {\n    const decodePromises = imageDecodePromises.current;\n\n    items.forEach((item) => {\n      void prepareImage(item.image);\n    });\n\n    return () => {\n      decodePromises.clear();\n    };\n  }, [items, prepareImage]);\n\n  // Read via a ref inside the focus-trap effect: with an inline controlled\n  // `onValueChange`, `closeActiveItem`'s identity changes every parent render,\n  // and keying the effect on it would re-run the trap and yank focus back to\n  // the dialog root while the user is tabbing through its controls.\n  const closeActiveItemRef = React.useRef(closeActiveItem);\n\n  React.useEffect(() => {\n    closeActiveItemRef.current = closeActiveItem;\n  });\n\n  const activeItemId = activeItem?.id ?? null;\n\n  React.useEffect(() => {\n    if (activeItemId === null) return;\n\n    previouslyFocusedElement.current = document.activeElement as HTMLElement;\n    dialogRef.current?.focus();\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        closeActiveItemRef.current();\n        return;\n      }\n\n      if (event.key !== \"Tab\") {\n        return;\n      }\n\n      const dialog = dialogRef.current;\n\n      if (!dialog) return;\n\n      const focusableElements = Array.from(\n        dialog.querySelectorAll<HTMLElement>(\n          [\n            \"button:not([disabled])\",\n            \"a[href]\",\n            \"input:not([disabled])\",\n            \"select:not([disabled])\",\n            \"textarea:not([disabled])\",\n            \"[tabindex]:not([tabindex='-1'])\",\n          ].join(\",\"),\n        ),\n      ).filter((element) => element.offsetParent !== null);\n\n      if (focusableElements.length === 0) {\n        event.preventDefault();\n        dialog.focus();\n        return;\n      }\n\n      const firstElement = focusableElements[0];\n      const lastElement = focusableElements[focusableElements.length - 1];\n      const activeElement = document.activeElement as HTMLElement | null;\n\n      if (!activeElement || !dialog.contains(activeElement)) {\n        event.preventDefault();\n        firstElement.focus();\n      } else if (event.shiftKey && activeElement === firstElement) {\n        event.preventDefault();\n        lastElement.focus();\n      } else if (!event.shiftKey && activeElement === lastElement) {\n        event.preventDefault();\n        firstElement.focus();\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n      previouslyFocusedElement.current?.focus();\n    };\n  }, [activeItemId]);\n\n  const layoutTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : ({ type: \"spring\", duration: 0.32, bounce: 0 } as const);\n  const fadeTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : ({ duration: 0.2, ease: [0.215, 0.61, 0.355, 1] } as const);\n\n  return (\n    <div\n      data-slot=\"expandable-dialog\"\n      className={cn(\n        \"relative mx-auto flex w-full items-center justify-center\",\n        className,\n      )}\n      {...props}\n    >\n      <LayoutGroup id={reactId}>\n        <AnimatePresence>\n          {activeItem ? (\n            <motion.div\n              key=\"overlay\"\n              aria-hidden=\"true\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={fadeTransition}\n              // Dismissal is owned by the z-50 dialog container's onMouseDown;\n              // this layer sits beneath it and can never receive the click.\n              className=\"pointer-events-none absolute inset-0 z-40 bg-background/80 backdrop-blur-sm\"\n            />\n          ) : null}\n        </AnimatePresence>\n\n        <AnimatePresence>\n          {activeItem ? (\n            <div\n              className=\"absolute inset-0 z-50 flex items-center justify-center p-4\"\n              onMouseDown={(event) => {\n                if (event.target === event.currentTarget) {\n                  closeActiveItem();\n                }\n              }}\n            >\n              <div\n                ref={dialogRef}\n                role=\"dialog\"\n                aria-modal=\"true\"\n                // The visible title labels the dialog by default; an explicit\n                // `modalLabel` takes over (aria-labelledby would silently win\n                // if both were set).\n                aria-label={modalLabel}\n                aria-labelledby={modalLabel ? undefined : titleId}\n                aria-describedby={descriptionId}\n                tabIndex={-1}\n                className=\"relative w-full max-w-md outline-none\"\n              >\n                <motion.div\n                  layoutId={`card-${activeItem.id}`}\n                  transition={layoutTransition}\n                  className=\"w-full overflow-hidden rounded-xl border bg-background shadow-lg\"\n                >\n                  <div className=\"flex items-start gap-3 border-b p-3\">\n                    <motion.img\n                      layoutId={`image-${activeItem.id}`}\n                      transition={layoutTransition}\n                      src={activeItem.image}\n                      alt={activeItem.imageAlt ?? \"\"}\n                      loading=\"eager\"\n                      decoding=\"sync\"\n                      fetchPriority=\"high\"\n                      className=\"size-14 shrink-0 rounded-lg object-cover\"\n                    />\n                    <div className=\"flex min-w-0 flex-1 items-start justify-between gap-3\">\n                      <div className=\"min-w-0\">\n                        <motion.h2\n                          id={titleId}\n                          layoutId={`title-${activeItem.id}`}\n                          transition={layoutTransition}\n                          className=\"truncate text-sm font-semibold\"\n                        >\n                          {activeItem.title}\n                        </motion.h2>\n                        <motion.p\n                          id={descriptionId}\n                          layoutId={`description-${activeItem.id}`}\n                          transition={layoutTransition}\n                          className=\"mt-1 text-sm leading-5 text-muted-foreground\"\n                        >\n                          {activeItem.description}\n                        </motion.p>\n                      </div>\n                      <motion.button\n                        type=\"button\"\n                        layoutId={`button-${activeItem.id}`}\n                        transition={layoutTransition}\n                        onClick={() => handleAction(activeItem)}\n                        className={actionButtonClassName}\n                      >\n                        {activeItem.actionLabel ?? actionLabel}\n                      </motion.button>\n                    </div>\n                  </div>\n                  <motion.div\n                    initial={shouldReduceMotion ? false : { opacity: 0, y: 8 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={\n                      shouldReduceMotion ? undefined : { opacity: 0, y: 4 }\n                    }\n                    transition={fadeTransition}\n                    className=\"p-4 text-sm leading-6 text-muted-foreground\"\n                  >\n                    {activeItem.content}\n                  </motion.div>\n                </motion.div>\n              </div>\n            </div>\n          ) : null}\n        </AnimatePresence>\n\n        <ul\n          data-slot=\"expandable-dialog-list\"\n          aria-hidden={activeItem ? true : undefined}\n          className={cn(\"flex w-full max-w-md flex-col gap-2\", listClassName)}\n        >\n          {items.map((item) => (\n            <li key={item.id}>\n              <motion.div\n                layoutId={`card-${item.id}`}\n                transition={layoutTransition}\n                onPointerEnter={() => void prepareImage(item.image)}\n                className={cn(\n                  \"flex w-full items-center gap-3 rounded-lg border bg-background p-3 text-left transition-colors hover:bg-muted/50\",\n                  itemClassName,\n                )}\n              >\n                <button\n                  type=\"button\"\n                  onClick={() => void openItem(item)}\n                  onFocus={() => void prepareImage(item.image)}\n                  className=\"flex min-w-0 flex-1 items-center gap-3 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n                >\n                  <motion.img\n                    layoutId={`image-${item.id}`}\n                    transition={layoutTransition}\n                    src={item.image}\n                    alt={item.imageAlt ?? \"\"}\n                    loading=\"eager\"\n                    decoding=\"sync\"\n                    className=\"size-14 shrink-0 rounded-lg object-cover\"\n                  />\n                  <span className=\"min-w-0\">\n                    <motion.span\n                      layoutId={`title-${item.id}`}\n                      transition={layoutTransition}\n                      className=\"block truncate text-sm font-semibold\"\n                    >\n                      {item.title}\n                    </motion.span>\n                    <motion.span\n                      layoutId={`description-${item.id}`}\n                      transition={layoutTransition}\n                      className=\"mt-1 block truncate text-sm text-muted-foreground\"\n                    >\n                      {item.description}\n                    </motion.span>\n                  </span>\n                </button>\n                <motion.button\n                  type=\"button\"\n                  layoutId={`button-${item.id}`}\n                  transition={layoutTransition}\n                  onClick={() => void openItem(item)}\n                  onFocus={() => void prepareImage(item.image)}\n                  aria-label={`${item.actionLabel ?? actionLabel} ${item.title}`}\n                  className={actionButtonClassName}\n                >\n                  {item.actionLabel ?? actionLabel}\n                </motion.button>\n              </motion.div>\n            </li>\n          ))}\n        </ul>\n      </LayoutGroup>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/expandable-dialog.tsx"
    }
  ],
  "meta": {
    "tags": [
      "modal",
      "details",
      "list-item",
      "escape-key",
      "focus-trap",
      "dialog"
    ],
    "effects": [
      "shared-layout",
      "expand",
      "fade"
    ]
  },
  "categories": [
    "overlay"
  ],
  "type": "registry:ui"
}