{
  "$schema": "https://abackui.com/schema/registry-item.json",
  "name": "table-of-contents",
  "type": "registry:ui",
  "title": "Table of Contents",
  "description": "An \"On this page\" list of anchors that tracks the section in view.",
  "dependencies": [
    "@tabler/icons-react"
  ],
  "registryDependencies": [
    "https://abackui.com/r/utils.json"
  ],
  "categories": [
    "Navigation"
  ],
  "docs": "https://abackui.com/docs/components/table-of-contents",
  "meta": {
    "status": "beta",
    "usedIn": [
      "aback-tools",
      "aback-launch"
    ]
  },
  "files": [
    {
      "path": "components/ui/table-of-contents.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { IconList } from \"@tabler/icons-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface TableOfContentsItem {\n  /** The id of the heading on the page, without the hash. */\n  id: string;\n  title: React.ReactNode;\n  /** Heading depth relative to the first level. 1 is a top-level section. */\n  depth?: 1 | 2 | 3;\n}\n\nexport interface TableOfContentsProps extends Omit<React.ComponentProps<\"nav\">, \"children\" | \"title\"> {\n  items: TableOfContentsItem[];\n  /** Visible heading of the list. Also names the navigation landmark. */\n  title?: React.ReactNode;\n  /**\n   * Distance from the top of the viewport, in pixels, where a heading counts\n   * as reached. Set it to the height of a sticky header.\n   */\n  offset?: number;\n  /** Disable scroll tracking, for a static list. */\n  trackActive?: boolean;\n}\n\nconst depthIndent = {\n  1: \"pl-3\",\n  2: \"pl-6\",\n  3: \"pl-9\",\n} as const;\n\n/**\n * An \"On this page\" list that follows the reader. Links are real anchors, so\n * they work without JavaScript and update the URL hash; the active section is\n * read from the scroll position, once per frame, and marked with `aria-current`.\n */\nfunction TableOfContents({\n  items,\n  title = \"On this page\",\n  offset = 96,\n  trackActive = true,\n  className,\n  \"aria-label\": ariaLabel,\n  ...props\n}: TableOfContentsProps) {\n  const headingId = React.useId();\n  const [active, setActive] = React.useState<string | null>(null);\n  const ids = items.map((item) => item.id).join(\"|\");\n\n  React.useEffect(() => {\n    if (!trackActive) return;\n    const targets = ids\n      .split(\"|\")\n      .map((id) => document.getElementById(id))\n      .filter((element): element is HTMLElement => element !== null);\n    if (targets.length === 0) return;\n\n    // The current section is the last heading that has scrolled up to the\n    // offset line. Reading positions on scroll, rather than waiting for\n    // intersection changes, keeps it right when a fast scroll jumps past a\n    // section. At the very bottom the last section wins, since short final\n    // sections can never reach the line.\n    let frame = 0;\n    let pending = false;\n    function update() {\n      pending = false;\n      const root = document.documentElement;\n      const atBottom =\n        window.scrollY > 0 && window.innerHeight + window.scrollY >= root.scrollHeight - 2;\n      let current = targets[0]!.id;\n      if (atBottom) {\n        current = targets[targets.length - 1]!.id;\n      } else {\n        for (const target of targets) {\n          if (target.getBoundingClientRect().top > offset + 8) break;\n          current = target.id;\n        }\n      }\n      setActive(current);\n    }\n    function schedule() {\n      if (pending) return;\n      pending = true;\n      frame = window.requestAnimationFrame(update);\n    }\n\n    schedule();\n    window.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      if (pending) window.cancelAnimationFrame(frame);\n      window.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [ids, offset, trackActive]);\n\n  if (items.length === 0) return null;\n\n  return (\n    <nav\n      data-slot=\"table-of-contents\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabel ? undefined : headingId}\n      className={cn(\"flex flex-col gap-2\", className)}\n      {...props}\n    >\n      <p\n        id={headingId}\n        className=\"flex items-center gap-2 border-b border-border pb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\"\n      >\n        <IconList aria-hidden=\"true\" className=\"size-4 text-brand-icon\" />\n        {title}\n      </p>\n      <ul className=\"flex flex-col\">\n        {items.map((item) => {\n          const current = active === item.id;\n          return (\n            <li key={item.id}>\n              <a\n                href={`#${item.id}`}\n                aria-current={current ? \"location\" : undefined}\n                onClick={() => setActive(item.id)}\n                className={cn(\n                  \"block border-l-2 py-1.5 pr-2 text-xs leading-snug outline-none transition-colors duration-150\",\n                  \"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-focus-ring\",\n                  depthIndent[item.depth ?? 1],\n                  current\n                    ? \"border-primary-500 bg-brand-wash font-semibold text-link\"\n                    : \"border-transparent text-muted-foreground hover:border-border hover:text-foreground\",\n                )}\n              >\n                {item.title}\n              </a>\n            </li>\n          );\n        })}\n      </ul>\n    </nav>\n  );\n}\n\nexport { TableOfContents };\n",
      "type": "registry:ui",
      "target": "components/ui/table-of-contents.tsx"
    }
  ]
}
