{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fluid-accordion",
  "type": "registry:ui",
  "title": "Fluid Accordion",
  "description": "An accessible accordion whose panels glide open to their natural height, with single or multiple open items.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/fluid-accordion.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface FluidAccordionItem {\n  /** Stable, unique id. Used as the open value. */\n  id: string;\n  /** Header text. Rendered inside the trigger button. */\n  title: React.ReactNode;\n  /** Panel content, revealed when the item opens. */\n  content: React.ReactNode;\n  disabled?: boolean;\n}\n\nexport interface FluidAccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  items: FluidAccordionItem[];\n  /** \"single\" keeps at most one item open; \"multiple\" lets any number stay open. */\n  type?: \"single\" | \"multiple\";\n  /** Open item ids (controlled). */\n  value?: string[];\n  /** Initially open item ids (uncontrolled). */\n  defaultValue?: string[];\n  /** Called with the next list of open ids. */\n  onValueChange?: (value: string[]) => void;\n  /** In single mode, allow closing the open item so none is open. */\n  collapsible?: boolean;\n  /** Indicator glyph: a rotating chevron or a plus that morphs into a minus. */\n  icon?: \"chevron\" | \"plus\";\n  /** \"lined\" separates items with hairlines; \"cards\" gives each item its own surface. */\n  variant?: \"lined\" | \"cards\";\n  /** Show a mono index (01, 02…) before each title. */\n  showIndex?: boolean;\n  /** Open / close duration in ms. */\n  duration?: number;\n  /** Heading level wrapping each trigger, to fit the page outline. */\n  headingLevel?: 2 | 3 | 4 | 5 | 6;\n  /** Accent for the open index, indicator and focus ring. */\n  accent?: string;\n}\n\nfunction Indicator({ kind, open }: { kind: \"chevron\" | \"plus\"; open: boolean }) {\n  if (kind === \"chevron\") {\n    return (\n      <svg\n        viewBox=\"0 0 16 16\"\n        aria-hidden=\"true\"\n        className={`h-4 w-4 shrink-0 transition-transform duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${\n          open ? \"rotate-180 text-[var(--fa-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"\n        }`}\n      >\n        <path d=\"m4 6 4 4 4-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      </svg>\n    );\n  }\n  // Plus → minus: the vertical bar rotates 90° and lands on the horizontal one.\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`relative flex h-4 w-4 shrink-0 items-center justify-center ${\n        open ? \"text-[var(--fa-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"\n      }`}\n    >\n      <span className=\"absolute h-[1.5px] w-3 rounded-full bg-current\" />\n      <span\n        className={`absolute h-3 w-[1.5px] rounded-full bg-current transition-transform duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${\n          open ? \"rotate-90\" : \"\"\n        }`}\n      />\n    </span>\n  );\n}\n\n/**\n * An accordion whose panels open with a fluid height animation (grid-template-rows 0fr → 1fr),\n * so content of any height animates without measuring. Follows the WAI-ARIA accordion pattern.\n */\nexport function FluidAccordion({\n  items,\n  type = \"single\",\n  value,\n  defaultValue,\n  onValueChange,\n  collapsible = true,\n  icon = \"chevron\",\n  variant = \"lined\",\n  showIndex = true,\n  duration = 320,\n  headingLevel = 3,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: FluidAccordionProps) {\n  const uid = React.useId();\n  const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);\n  const raw = value ?? internal;\n  const open = type === \"single\" ? raw.slice(0, 1) : raw;\n  const Heading = `h${headingLevel}` as \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n\n  const toggle = (id: string) => {\n    const isOpen = open.includes(id);\n    let next: string[];\n    if (type === \"single\") {\n      if (isOpen && !collapsible) return;\n      next = isOpen ? [] : [id];\n    } else {\n      next = isOpen ? open.filter((v) => v !== id) : [...open, id];\n    }\n    if (value === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  // Arrow keys, Home and End move focus between headers (WAI-ARIA accordion, optional keys).\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const target = event.target as HTMLElement;\n    if (target.dataset.accordionTrigger !== uid) return;\n    const triggers = Array.from(\n      event.currentTarget.querySelectorAll<HTMLButtonElement>(`[data-accordion-trigger=\"${uid}\"]:not(:disabled)`),\n    );\n    if (!triggers.length) return;\n    event.preventDefault();\n    const index = triggers.indexOf(target as HTMLButtonElement);\n    const last = triggers.length - 1;\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? last\n          : event.key === \"ArrowDown\"\n            ? (index + 1) % triggers.length\n            : (index - 1 + triggers.length) % triggers.length;\n    triggers[next].focus();\n  };\n\n  const isCards = variant === \"cards\";\n\n  return (\n    <div\n      className={`w-full ${isCards ? \"flex flex-col gap-2\" : \"border-t border-zinc-900/10 dark:border-white/10\"} ${className}`}\n      style={{ [\"--fa-accent\" as string]: accent, [\"--fa-duration\" as string]: `${duration}ms`, ...style }}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      {items.map((item, index) => {\n        const isOpen = open.includes(item.id);\n        const triggerId = `${uid}-trigger-${index}`;\n        const panelId = `${uid}-panel-${index}`;\n        // A single, non-collapsible open item cannot be closed: expose that state to AT.\n        const locked = isOpen && type === \"single\" && !collapsible;\n        return (\n          <div\n            key={item.id}\n            data-state={isOpen ? \"open\" : \"closed\"}\n            className={\n              isCards\n                ? `rounded-xl border bg-white transition-colors duration-200 dark:bg-zinc-900 ${\n                    isOpen\n                      ? \"border-zinc-900/20 shadow-[0_1px_2px_rgba(0,0,0,0.05)] dark:border-white/20\"\n                      : \"border-zinc-900/10 dark:border-white/10\"\n                  }`\n                : \"border-b border-zinc-900/10 dark:border-white/10\"\n            }\n          >\n            <Heading className=\"m-0\">\n              <button\n                type=\"button\"\n                id={triggerId}\n                data-accordion-trigger={uid}\n                aria-expanded={isOpen}\n                aria-controls={panelId}\n                aria-disabled={locked || undefined}\n                disabled={item.disabled}\n                onClick={() => toggle(item.id)}\n                className={`group flex w-full items-center gap-4 text-left text-[15px] font-medium tracking-tight text-zinc-900 transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--fa-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-100 dark:hover:text-white ${\n                  isCards ? \"rounded-xl px-4 py-3.5\" : \"rounded-sm py-4\"\n                }`}\n              >\n                {showIndex && (\n                  <span\n                    aria-hidden=\"true\"\n                    className={`w-5 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums transition-colors ${\n                      isOpen ? \"text-[var(--fa-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"\n                    }`}\n                  >\n                    {String(index + 1).padStart(2, \"0\")}\n                  </span>\n                )}\n                <span className=\"min-w-0 flex-1\">{item.title}</span>\n                <Indicator kind={icon} open={isOpen} />\n              </button>\n            </Heading>\n            {/* Animating grid rows from 0fr to 1fr transitions to the content's natural height. */}\n            <div\n              id={panelId}\n              role=\"region\"\n              aria-labelledby={triggerId}\n              // Collapsed content stays in the DOM for the animation but is removed from focus and AT.\n              inert={!isOpen}\n              className={`grid transition-[grid-template-rows] duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none ${\n                isOpen ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\"\n              }`}\n            >\n              <div className=\"min-h-0 overflow-hidden\">\n                <div\n                  className={`text-sm leading-relaxed text-zinc-600 transition-[opacity,translate] duration-[var(--fa-duration)] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none dark:text-zinc-400 ${\n                    isOpen ? \"translate-y-0 opacity-100\" : \"-translate-y-1 opacity-0\"\n                  } ${isCards ? \"px-4 pb-4\" : \"pb-5\"} ${showIndex ? (isCards ? \"pl-[52px]\" : \"pl-9\") : \"\"}`}\n                >\n                  {item.content}\n                </div>\n              </div>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/fluid-accordion"
}
