{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"accordion","type":"registry:ui","title":"Accordion","description":"height auto, done correctly.","dependencies":["motion"],"categories":["navigation"],"docs":"https://www.interior.dev/docs/accordion","files":[{"path":"registry/interior/accordion.tsx","type":"registry:ui","target":"components/interior/accordion.tsx","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst EXIT_EASE = [0.4, 0, 1, 1] as const;\n\nconst DISCLOSE = {\n  type: \"spring\",\n  stiffness: 480,\n  damping: 40,\n  mass: 0.6,\n} as const;\n\nconst CHEVRON = {\n  type: \"spring\",\n  stiffness: 700,\n  damping: 46,\n  mass: 0.5,\n} as const;\n\nconst NONE: readonly string[] = [];\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ntype Inertable = HTMLElement & { inert?: boolean };\n\nexport type UseAutoHeightResult = {\n  ref: React.RefObject<HTMLDivElement | null>;\n  height: number;\n  ready: boolean;\n};\n\nexport function useAutoHeight(): UseAutoHeightResult {\n  const ref = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState(0);\n  const [ready, setReady] = useState(false);\n\n  useIsomorphicLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n\n    const read = () => {\n      const next = el.getBoundingClientRect().height;\n      setHeight((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));\n    };\n\n    read();\n    setReady(true);\n\n    const observer = new ResizeObserver(read);\n    observer.observe(el);\n\n    return () => observer.disconnect();\n  }, []);\n\n  return { ref, height, ready };\n}\n\nexport type AccordionEntry = {\n  id: string;\n};\n\nexport type AccordionHeaderProps = {\n  id: string;\n  ref: (node: HTMLButtonElement | null) => void;\n  type: \"button\";\n  onClick: () => void;\n  onKeyDown: (event: React.KeyboardEvent) => void;\n  \"aria-expanded\": boolean;\n  \"aria-controls\": string;\n};\n\nexport type AccordionPanelProps = {\n  id: string;\n  role: \"region\";\n  \"aria-labelledby\": string;\n  \"aria-hidden\": true | undefined;\n};\n\nexport type UseAccordionOptions = {\n  items: readonly AccordionEntry[];\n  type?: \"single\" | \"multiple\";\n  defaultOpen?: readonly string[];\n  open?: readonly string[];\n  onOpenChange?: (open: string[]) => void;\n  collapsible?: boolean;\n};\n\nexport type UseAccordionResult = {\n  open: string[];\n  isOpen: (id: string) => boolean;\n  toggle: (id: string) => void;\n  headerProps: (id: string) => AccordionHeaderProps;\n  panelProps: (id: string) => AccordionPanelProps;\n};\n\nexport function useAccordion({\n  items,\n  type = \"single\",\n  defaultOpen = NONE,\n  open: controlled,\n  onOpenChange,\n  collapsible = true,\n}: UseAccordionOptions): UseAccordionResult {\n  const base = useId();\n\n  const [uncontrolled, setUncontrolled] = useState<string[]>(() =>\n    type === \"single\" ? defaultOpen.slice(0, 1) : defaultOpen.slice(),\n  );\n\n  const open = useMemo(\n    () => (controlled ? controlled.slice() : uncontrolled),\n    [controlled, uncontrolled],\n  );\n\n  const headers = useRef(new Map<string, HTMLButtonElement>());\n  const binders = useRef(new Map<string, AccordionHeaderProps[\"ref\"]>());\n\n  const headerRef = useCallback((id: string): AccordionHeaderProps[\"ref\"] => {\n    const cached = binders.current.get(id);\n    if (cached) return cached;\n    const bind = (node: HTMLButtonElement | null) => {\n      if (node) headers.current.set(id, node);\n      else headers.current.delete(id);\n    };\n    binders.current.set(id, bind);\n    return bind;\n  }, []);\n\n  const changed = useRef(onOpenChange);\n  changed.current = onOpenChange;\n\n  const commit = useCallback((next: string[]) => {\n    setUncontrolled(next);\n    changed.current?.(next);\n  }, []);\n\n  const isOpen = useCallback((id: string) => open.includes(id), [open]);\n\n  const toggle = useCallback(\n    (id: string) => {\n      const active = open.includes(id);\n      if (active && !collapsible && type === \"single\") return;\n      if (type === \"single\") {\n        commit(active ? [] : [id]);\n        return;\n      }\n      commit(active ? open.filter((x) => x !== id) : [...open, id]);\n    },\n    [open, type, collapsible, commit],\n  );\n\n  const order = useMemo(() => items.map((item) => item.id), [items]);\n\n  const move = useCallback(\n    (id: string, delta: number, edge: \"first\" | \"last\" | null) => {\n      if (order.length === 0) return;\n      const at = order.indexOf(id);\n      if (at < 0) return;\n      const next =\n        edge === \"first\"\n          ? 0\n          : edge === \"last\"\n            ? order.length - 1\n            : (at + delta + order.length) % order.length;\n      headers.current.get(order[next])?.focus();\n    },\n    [order],\n  );\n\n  const headerProps = useCallback(\n    (id: string): AccordionHeaderProps => ({\n      id: `${base}-header-${id}`,\n      ref: headerRef(id),\n      type: \"button\",\n      onClick: () => toggle(id),\n      onKeyDown: (event: React.KeyboardEvent) => {\n        if (event.key === \"ArrowDown\") {\n          event.preventDefault();\n          move(id, 1, null);\n        } else if (event.key === \"ArrowUp\") {\n          event.preventDefault();\n          move(id, -1, null);\n        } else if (event.key === \"Home\") {\n          event.preventDefault();\n          move(id, 0, \"first\");\n        } else if (event.key === \"End\") {\n          event.preventDefault();\n          move(id, 0, \"last\");\n        }\n      },\n      \"aria-expanded\": open.includes(id),\n      \"aria-controls\": `${base}-panel-${id}`,\n    }),\n    [base, open, toggle, move, headerRef],\n  );\n\n  const panelProps = useCallback(\n    (id: string): AccordionPanelProps => ({\n      id: `${base}-panel-${id}`,\n      role: \"region\",\n      \"aria-labelledby\": `${base}-header-${id}`,\n      \"aria-hidden\": open.includes(id) ? undefined : true,\n    }),\n    [base, open],\n  );\n\n  return { open, isOpen, toggle, headerProps, panelProps };\n}\n\nexport type AccordionItem = {\n  id: string;\n  title: React.ReactNode;\n  content: React.ReactNode;\n  meta?: React.ReactNode;\n};\n\nexport type AccordionProps = {\n  items: readonly AccordionItem[];\n  type?: \"single\" | \"multiple\";\n  defaultOpen?: readonly string[];\n  open?: readonly string[];\n  onOpenChange?: (open: string[]) => void;\n  collapsible?: boolean;\n  maxPanelHeight?: number;\n  headingLevel?: number;\n  className?: string;\n};\n\nexport function Accordion({\n  items,\n  type = \"single\",\n  defaultOpen = NONE,\n  open: controlled,\n  onOpenChange,\n  collapsible = true,\n  maxPanelHeight = 220,\n  headingLevel = 3,\n  className = \"\",\n}: AccordionProps) {\n  const reduced = useReducedMotion();\n\n  const entries = useMemo(() => items.map(({ id }) => ({ id })), [items]);\n\n  const { isOpen, headerProps, panelProps } = useAccordion({\n    items: entries,\n    type,\n    defaultOpen,\n    open: controlled,\n    onOpenChange,\n    collapsible,\n  });\n\n  return (\n    <div\n      className={`divide-y divide-stone-200 overflow-hidden rounded-[11px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:divide-white/10 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n    >\n      {items.map((item) => (\n        <AccordionRow\n          key={item.id}\n          item={item}\n          open={isOpen(item.id)}\n          reduced={Boolean(reduced)}\n          maxPanelHeight={maxPanelHeight}\n          headingLevel={headingLevel}\n          header={headerProps(item.id)}\n          panel={panelProps(item.id)}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction AccordionRow({\n  item,\n  open,\n  reduced,\n  maxPanelHeight,\n  headingLevel,\n  header,\n  panel,\n}: {\n  item: AccordionItem;\n  open: boolean;\n  reduced: boolean;\n  maxPanelHeight: number;\n  headingLevel: number;\n  header: AccordionHeaderProps;\n  panel: AccordionPanelProps;\n}) {\n  const { ref, height, ready } = useAutoHeight();\n\n  useEffect(() => {\n    const el = ref.current as Inertable | null;\n    if (!el) return;\n    el.inert = !open;\n    return () => {\n      el.inert = false;\n    };\n  }, [ref, open]);\n\n  return (\n    <div>\n      <div role=\"heading\" aria-level={headingLevel}>\n        <button\n          {...header}\n          className=\"flex w-full items-center gap-3 px-3.5 py-3 text-left outline-none transition-colors duration-150 hover:bg-stone-100 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:hover:bg-white/10 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n        >\n          <span\n            className={`min-w-0 flex-1 truncate text-[13px] font-medium transition-colors duration-150 ${\n              open\n                ? \"text-stone-900 dark:text-stone-50\"\n                : \"text-stone-700 dark:text-stone-200\"\n            }`}\n          >\n            {item.title}\n          </span>\n\n          {item.meta ? (\n            <span className=\"shrink-0 text-[11.5px] tabular-nums text-stone-400 dark:text-stone-500\">\n              {item.meta}\n            </span>\n          ) : null}\n\n          <motion.svg\n            width=\"13\"\n            height=\"13\"\n            viewBox=\"0 0 256 256\"\n            fill=\"none\"\n            aria-hidden=\"true\"\n            className=\"shrink-0 text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ rotate: open ? 180 : 0 }}\n            transition={reduced ? { duration: 0 } : CHEVRON}\n          >\n            <path\n              d=\"M208 96l-80 80-80-80\"\n              stroke=\"currentColor\"\n              strokeWidth=\"16\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            />\n          </motion.svg>\n        </button>\n      </div>\n      <motion.div\n        initial={false}\n        animate={ready ? { height: open ? height : 0 } : {}}\n        transition={reduced ? { duration: 0 } : DISCLOSE}\n        style={{\n          overflow: \"hidden\",\n          height: ready ? undefined : open ? \"auto\" : 0,\n        }}\n      >\n        <div\n          {...panel}\n          ref={ref}\n          className=\"border-t border-stone-200 bg-stone-50 shadow-[inset_0_1px_2px_rgba(28,25,23,0.05)] dark:border-white/[0.16] dark:bg-white/[0.05] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]\"\n          style={{\n            maxHeight: maxPanelHeight,\n            overflowY: \"auto\",\n            overscrollBehavior: \"contain\",\n\n            scrollbarGutter: \"stable\",\n          }}\n        >\n          <motion.div\n            initial={false}\n            animate={{ opacity: open ? 1 : 0 }}\n            transition={\n              reduced\n                ? { duration: 0 }\n                : open\n                  ? { duration: 0.18, ease: EASE }\n                  : { duration: 0.14, ease: EXIT_EASE }\n            }\n            className=\"px-3.5 pb-3.5 pt-3 text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400\"\n          >\n            {item.content}\n          </motion.div>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n"}]}