{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-details-accordion",
  "title": "Product Details Accordion",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Materials, care, shipping and maker sections on your shadcn Accordion, first section open. Each closed row carries a one-line summary, so shoppers get the answer without opening it, and panels open with a Motion height morph. One tag from data, or composed from sections and a specs list; open state is shared through context. Works on Radix and Base UI.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "accordion"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/product-details-accordion/product-details-accordion.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronDown } from \"lucide-react\"\nimport { MotionConfig, motion, useReducedMotion, type Transition } from \"motion/react\"\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * Product details — materials, care, shipping, the maker — as an accordion.\n *\n *   <ProductDetailsAccordion sections={sections} />            one tag\n *\n *   <ProductDetailsAccordion defaultOpen={[\"materials\"]}>      or composed\n *     <ProductDetailsSection id=\"materials\" title=\"Materials\" summary=\"Organic cotton\">\n *       <ProductDetailsSpecs items={[[\"Shell\", \"Organic cotton\"], [\"Lining\", \"Linen\"]]} />\n *     </ProductDetailsSection>\n *   </ProductDetailsAccordion>\n *\n * Built on your shadcn/ui Accordion — its triggers, ids and keyboard model —\n * with Motion driving the open and close: the panel grows to its real height\n * and settles, rather than snapping at the end of a CSS keyframe. Works on\n * either base, Radix or Base UI.\n */\n\nexport interface ProductDetailsSectionData {\n  /** Stable id: the accordion value, and what `open` / `defaultOpen` refer to. */\n  id: string\n  title: string\n  /**\n   * One line shown on the closed row — \"Organic cotton\", \"Free over $300\".\n   * The shopper gets the answer without opening the section, and the line\n   * is always in the page for search engines and find-in-page.\n   */\n  summary?: React.ReactNode\n  content: React.ReactNode\n}\n\ntype AccordionContentProps = React.ComponentProps<typeof AccordionContent>\ntype AccordionRootProps = React.ComponentProps<typeof Accordion>\n\n/** The same settle curve as product-quick-view: no bounce, an editorial ease. */\nconst MORPH: Transition = { duration: 0.45, ease: [0.2, 0, 0, 1] }\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface ProductDetailsAccordionContextValue {\n  /** Ids of the open sections. */\n  open: string[]\n  setOpen: (open: string[]) => void\n  /** Opens or closes one section, leaving the others as they are. */\n  toggle: (id: string) => void\n  isOpen: (id: string) => boolean\n  contentProps?: Omit<AccordionContentProps, \"children\">\n}\n\ninterface InternalContextValue extends ProductDetailsAccordionContextValue {\n  /** A closing panel reports in once it has animated shut. */\n  settle: (id: string) => void\n}\n\nconst ProductDetailsAccordionContext =\n  React.createContext<InternalContextValue | null>(null)\n\nfunction useInternalContext(): InternalContextValue {\n  const context = React.useContext(ProductDetailsAccordionContext)\n  if (!context) {\n    throw new Error(\n      \"useProductDetailsAccordion must be used within <ProductDetailsAccordion>.\"\n    )\n  }\n  return context\n}\n\n/** For your own parts inside a <ProductDetailsAccordion> — an \"Expand all\", say. */\nexport function useProductDetailsAccordion(): ProductDetailsAccordionContextValue {\n  const { open, setOpen, toggle, isOpen, contentProps } = useInternalContext()\n  return { open, setOpen, toggle, isOpen, contentProps }\n}\n\nconst subscribe = () => () => {}\n\n/**\n * False while hydrating server HTML, true for anything rendered after. A panel\n * that was open in the server's HTML is already at full height, so it must\n * not animate in; one opened later must.\n */\nfunction useHydrated() {\n  return React.useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false\n  )\n}\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface ProductDetailsAccordionProps\n  extends Omit<\n    React.HTMLAttributes<HTMLDivElement>,\n    \"defaultValue\" | \"dir\" | \"onChange\"\n  > {\n  /** Renders one section each when there are no children. */\n  sections?: ProductDetailsSectionData[]\n  /** Open on first render. Defaults to the first section. */\n  defaultOpen?: string[]\n  open?: string[]\n  onOpenChange?: (open: string[]) => void\n  /**\n   * Passed to every AccordionContent. To keep closed panels in the page —\n   * collapsed, inert, still readable by crawlers — pass `{ forceMount: true }`\n   * on Radix or `{ keepMounted: true }` on Base UI.\n   */\n  contentProps?: Omit<AccordionContentProps, \"children\">\n}\n\nexport function ProductDetailsAccordion({\n  sections,\n  defaultOpen,\n  open: openProp,\n  onOpenChange,\n  contentProps,\n  className,\n  children,\n  ...props\n}: ProductDetailsAccordionProps) {\n  const [open, setOpen] = useControllableState(\n    openProp,\n    defaultOpen ?? (sections?.[0] ? [sections[0].id] : []),\n    onOpenChange\n  )\n\n  // A section that closes stays in the accordion's value until its panel has\n  // animated shut — otherwise the primitive unmounts it mid-animation. Derived\n  // during render, so it also catches `open` changing from outside.\n  const [closing, setClosing] = React.useState<string[]>([])\n  const [previous, setPrevious] = React.useState(open)\n  if (previous !== open) {\n    const removed = previous.filter((id) => !open.includes(id))\n    setPrevious(open)\n    setClosing((current) => [\n      ...current.filter((id) => !open.includes(id)),\n      ...removed.filter((id) => !current.includes(id)),\n    ])\n  }\n\n  const settle = React.useCallback(\n    (id: string) => setClosing((current) => current.filter((c) => c !== id)),\n    []\n  )\n\n  const context = React.useMemo<InternalContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle: (id) =>\n        setOpen(open.includes(id) ? open.filter((o) => o !== id) : [...open, id]),\n      isOpen: (id) => open.includes(id),\n      contentProps,\n      settle,\n    }),\n    [open, setOpen, contentProps, settle]\n  )\n\n  // The primitive only ever sees a controlled value — the open sections, plus\n  // any still animating shut — and each section's trigger does the toggling.\n  // That keeps one source file correct on both bases with no base-specific\n  // prop: Radix needs `type=\"multiple\"` (on Base UI it is an inert\n  // attribute), while Base UI's own `multiple` would reach Radix's DOM as a\n  // stray attribute and break hydration. And no `onValueChange`: Base UI,\n  // left in single mode, would report every click as \"only this one open\".\n  const rootProps = {\n    type: \"multiple\",\n    value: [...open, ...closing.filter((id) => !open.includes(id))],\n  } as unknown as AccordionRootProps\n\n  if (!children && !sections?.length) return null\n\n  return (\n    <ProductDetailsAccordionContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <Accordion {...rootProps} className={cn(\"w-full\", className)} {...props}>\n          {children ??\n            sections?.map((section) => (\n              <ProductDetailsSection\n                key={section.id}\n                id={section.id}\n                title={section.title}\n                summary={section.summary}\n              >\n                {section.content}\n              </ProductDetailsSection>\n            ))}\n        </Accordion>\n      </MotionConfig>\n    </ProductDetailsAccordionContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\nexport interface ProductDetailsSectionProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\" | \"defaultValue\" | \"dir\"> {\n  id: string\n  title: React.ReactNode\n  summary?: React.ReactNode\n}\n\n/** One ruled row: a tracked-out title, its summary, and the panel. */\nexport function ProductDetailsSection({\n  id,\n  title,\n  summary,\n  className,\n  children,\n  ...props\n}: ProductDetailsSectionProps) {\n  const { isOpen, toggle, contentProps, settle } = useInternalContext()\n  const hydrated = useHydrated()\n  const reduce = useReducedMotion()\n  const expanded = isOpen(id)\n  const transition = reduce ? { duration: 0 } : MORPH\n\n  return (\n    <AccordionItem\n      value={id}\n      data-section={id}\n      // Rules above every row and below the last: a custom part placed before\n      // the first section (an \"Expand all\") sits clear of the top rule.\n      className={cn(\"border-t border-b-0 last:border-b\", className)}\n      {...props}\n    >\n      <AccordionTrigger\n        onClick={() => toggle(id)}\n        // Our chevron turns with the section; the stock icon is hidden.\n        className=\"items-center gap-4 py-4 hover:no-underline [&_[data-slot=accordion-trigger-icon]]:hidden [&>svg]:hidden\"\n      >\n        <span className=\"flex min-w-0 flex-1 flex-wrap items-baseline justify-between gap-x-6 gap-y-1\">\n          <span className=\"text-xs font-medium tracking-[0.14em] uppercase\">{title}</span>\n          {summary ? (\n            <span\n              data-slot=\"product-details-summary\"\n              className=\"min-w-0 text-left text-sm font-normal text-muted-foreground sm:text-right\"\n            >\n              {summary}\n            </span>\n          ) : null}\n        </span>\n        <motion.span\n          aria-hidden\n          data-slot=\"product-details-chevron\"\n          className=\"grid size-4 shrink-0 place-items-center text-muted-foreground\"\n          initial={false}\n          animate={{ rotate: expanded ? 180 : 0 }}\n          transition={transition}\n        >\n          <ChevronDown className=\"size-4\" />\n        </motion.span>\n      </AccordionTrigger>\n      <AccordionContent\n        {...contentProps}\n        // The stock keyframes would animate the same height Motion does.\n        // animationName, not the shorthand: Base UI sets it on this element\n        // too, and React warns when a shorthand and a longhand are mixed.\n        style={{ animationName: \"none\" }}\n        className=\"h-auto p-0\"\n      >\n        <motion.div\n          data-slot=\"product-details-panel\"\n          className=\"overflow-hidden\"\n          initial={hydrated ? { height: 0, opacity: 0 } : false}\n          animate={expanded ? { height: \"auto\", opacity: 1 } : { height: 0, opacity: 0 }}\n          transition={transition}\n          onAnimationComplete={() => {\n            if (!expanded) settle(id)\n          }}\n          inert={!expanded}\n        >\n          <motion.div\n            data-slot=\"product-details-body\"\n            initial={hydrated ? { y: -8 } : false}\n            animate={{ y: expanded ? 0 : -8 }}\n            transition={transition}\n            className=\"max-w-prose pb-5 text-sm leading-[1.7] text-muted-foreground [&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-4 [&_p+p]:mt-3 [&_strong]:font-medium [&_strong]:text-foreground\"\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      </AccordionContent>\n    </AccordionItem>\n  )\n}\n\nexport interface ProductDetailsSpecsProps\n  extends React.HTMLAttributes<HTMLDListElement> {\n  /** Label and value pairs: `[[\"Dimensions\", \"38 × 42 × 14 cm\"], …]`. */\n  items: [label: string, value: React.ReactNode][]\n}\n\n/** Key facts as a definition list — labels muted, values in ink, aligned. */\nexport function ProductDetailsSpecs({\n  items,\n  className,\n  ...props\n}: ProductDetailsSpecsProps) {\n  if (items.length === 0) return null\n  return (\n    <dl\n      data-slot=\"product-details-specs\"\n      className={cn(\n        \"grid grid-cols-[minmax(0,9rem)_1fr] gap-x-6 gap-y-2 [&+*]:mt-4\",\n        className\n      )}\n      {...props}\n    >\n      {items.map(([label, value]) => (\n        <React.Fragment key={label}>\n          <dt className=\"text-muted-foreground\">{label}</dt>\n          <dd className=\"text-foreground tabular-nums\">{value}</dd>\n        </React.Fragment>\n      ))}\n    </dl>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/product-details-accordion.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp"
  ],
  "type": "registry:component"
}