{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-of-contents",
  "type": "registry:ui",
  "title": "Table of contents",
  "description": "A sticky table of contents that spies on headings while you scroll, with a marker that glides to the active item and smooth scroll on click.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/table-of-contents.tsx",
      "type": "registry:ui",
      "target": "components/ui/table-of-contents.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A sticky table of contents that spies on headings while you scroll, with a marker that glides to the active item and smooth scroll on click. */\ntype TocItem = {\n  /** The heading element id. */\n  id: string\n  title: string\n  /** 2 for h2, 3 for h3 (indented). */\n  level?: 2 | 3\n}\n\ntype TableOfContentsProps = {\n  /** Headings to list. When absent, h2 and h3 elements are read from `selector` after mount. */\n  items?: TocItem[]\n  /** CSS selector of the container to read headings from when `items` is absent. Headings without an id get one. */\n  selector?: string\n  /** Small label above the list. Empty hides it. */\n  title?: string\n  /** Pixels between the top of the scroll area and a heading for it to count as reached. */\n  offset?: number\n  /** Called when the active heading changes. */\n  onActiveChange?: (id: string) => void\n  /** Keep the list in view while the page scrolls (position sticky). */\n  sticky?: boolean\n  className?: string\n}\n\nconst SNAPPY = { type: \"spring\", bounce: 0.18, duration: 0.42 } as const\n\nconst slug = (s: string) =>\n  s\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/(^-|-$)/g, \"\")\n\nfunction scrollParent(el: HTMLElement | null): HTMLElement | null {\n  let node = el?.parentElement ?? null\n  while (node && node !== document.body) {\n    const { overflowY } = getComputedStyle(node)\n    if (/(auto|scroll)/.test(overflowY) && node.scrollHeight > node.clientHeight) return node\n    node = node.parentElement\n  }\n  return null\n}\n\n/** The list itself. Spies on the headings it can find in the document. */\nfunction TocList({\n  items,\n  title,\n  offset,\n  onActiveChange,\n  sticky,\n  className,\n}: {\n  items: TocItem[]\n  title: string\n  offset: number\n  onActiveChange?: (id: string) => void\n  sticky: boolean\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const markerId = React.useId()\n  const [active, setActive] = React.useState<string | null>(items[0]?.id ?? null)\n  const lockUntil = React.useRef(0)\n  const changeRef = React.useRef(onActiveChange)\n  changeRef.current = onActiveChange\n  const ids = items.map((i) => i.id).join(\"|\")\n\n  React.useEffect(() => {\n    const heads = items.map((i) => ({ id: i.id, el: document.getElementById(i.id) })).filter((h): h is { id: string; el: HTMLElement } => !!h.el)\n    if (!heads.length) return\n    const scroller = scrollParent(heads[0].el)\n    const target: HTMLElement | Window = scroller ?? window\n    let raf = 0\n    const compute = () => {\n      raf = 0\n      const top = scroller ? scroller.scrollTop : window.scrollY\n      const viewH = scroller ? scroller.clientHeight : window.innerHeight\n      const fullH = scroller ? scroller.scrollHeight : document.documentElement.scrollHeight\n      const base = scroller ? scroller.getBoundingClientRect().top - scroller.scrollTop : -window.scrollY\n      let current = heads[0].id\n      if (top + viewH >= fullH - 2) current = heads[heads.length - 1].id\n      else\n        for (const h of heads) {\n          const y = h.el.getBoundingClientRect().top - base\n          if (y <= top + offset + 1) current = h.id\n          else break\n        }\n      setActive((prev) => {\n        if (prev !== current) changeRef.current?.(current)\n        return current\n      })\n    }\n    const onScroll = () => {\n      if (lockUntil.current > Date.now()) return\n      if (!raf) raf = requestAnimationFrame(compute)\n    }\n    target.addEventListener(\"scroll\", onScroll, { passive: true })\n    window.addEventListener(\"resize\", onScroll)\n    compute()\n    return () => {\n      target.removeEventListener(\"scroll\", onScroll)\n      window.removeEventListener(\"resize\", onScroll)\n      if (raf) cancelAnimationFrame(raf)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [ids, offset])\n\n  const go = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n    const el = document.getElementById(id)\n    if (!el) return\n    e.preventDefault()\n    const scroller = scrollParent(el)\n    const behavior: ScrollBehavior = reduce ? \"auto\" : \"smooth\"\n    if (scroller) {\n      const y = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - offset\n      scroller.scrollTo({ top: Math.max(0, y), behavior })\n    } else {\n      window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior })\n    }\n    lockUntil.current = Date.now() + (reduce ? 0 : 700)\n    if (active !== id) onActiveChange?.(id)\n    setActive(id)\n    if (typeof el.focus === \"function\") {\n      if (!el.hasAttribute(\"tabindex\")) el.setAttribute(\"tabindex\", \"-1\")\n      el.focus({ preventScroll: true })\n    }\n  }\n\n  return (\n    <nav aria-label={title || \"Table of contents\"} data-slot=\"table-of-contents\" className={cn(\"w-full max-w-56 text-[0.8125rem]\", sticky && \"sticky top-4 self-start\", className)}>\n      {title ? <p className=\"mb-2 pl-3 font-mono text-[10px] font-medium tracking-[0.08em] text-fg-subtle uppercase\">{title}</p> : null}\n      <ul className=\"relative\">\n        <span aria-hidden className=\"absolute top-0 bottom-0 left-0 w-px bg-border\" />\n        {items.map((item) => {\n          const on = item.id === active\n          return (\n            <li key={item.id}>\n              <a\n                href={`#${item.id}`}\n                aria-current={on ? \"true\" : undefined}\n                onClick={(e) => go(e, item.id)}\n                className={cn(\n                  \"relative block truncate rounded-r-md py-1 pr-2 leading-[1.5] outline-none transition-colors duration-[70ms] ease-hairline\",\n                  \"hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\",\n                  item.level === 3 ? \"pl-6 text-[0.75rem]\" : \"pl-3\",\n                  on ? \"font-medium text-fg\" : \"text-fg-muted\"\n                )}\n              >\n                {on ? (\n                  <motion.span\n                    layoutId={markerId}\n                    aria-hidden\n                    data-slot=\"table-of-contents-marker\"\n                    className=\"absolute top-1 bottom-1 -left-px w-0.5 rounded-full bg-accent\"\n                    transition={reduce ? { duration: 0 } : SNAPPY}\n                  />\n                ) : null}\n                {item.title}\n              </a>\n            </li>\n          )\n        })}\n      </ul>\n    </nav>\n  )\n}\n\n/* ── Demo article: a slice of the Lumen docs ──────────────────────────────── */\n\nconst ARTICLE: { id: string; level: 2 | 3; title: string; body: string[] }[] = [\n  { id: \"overview\", level: 2, title: \"Overview\", body: [\"Invoices are statements of amounts owed by a customer. Lumen generates them from a subscription or from one off line items you add through the API.\", \"Every invoice moves through draft, open, paid, void or uncollectible. Finalising an invoice locks its line items and assigns a sequential number such as LUM-2026-0147.\"] },\n  { id: \"create\", level: 2, title: \"Create an invoice\", body: [\"POST /v1/invoices with a customer id. The invoice starts as a draft so you can keep adding items before you finalise it.\", \"Drafts are editable for up to 72 hours. After that, Lumen auto finalises them unless auto_advance is false.\"] },\n  { id: \"line-items\", level: 3, title: \"Line items\", body: [\"Add items with POST /v1/invoice_items. Each one carries a description, quantity, unit amount in the smallest currency unit and an optional billing period.\", \"Usage based items are summed from metering events. The 2.4M API calls Northwind made in September show up as a single line.\"] },\n  { id: \"tax\", level: 3, title: \"Tax and discounts\", body: [\"Tax rates apply per line or per invoice. Northwind is tax exempt, so their invoices carry a zero rate with the exemption noted.\", \"Discounts can be a percentage or a fixed amount and are shown on the PDF as a separate row above the total.\"] },\n  { id: \"collect\", level: 2, title: \"Send and collect\", body: [\"Finalised invoices are emailed to the customer's billing contact, Maya Chen in Northwind's case, with a hosted page to pay by card or bank transfer.\", \"Reminders go out at 3, 7 and 14 days past due. You can change the cadence per customer or turn it off entirely.\"] },\n  { id: \"payment-links\", level: 3, title: \"Payment links\", body: [\"Each invoice has a hosted_invoice_url you can share anywhere. The page shows the line items, the amount due and the payment methods you have enabled.\"] },\n  { id: \"webhooks\", level: 2, title: \"Webhooks\", body: [\"Subscribe to invoice.paid, invoice.payment_failed and invoice.voided to keep your own records in sync. Events are signed and retried for three days.\", \"Theo Okafor's team uses invoice.paid to unlock seats in the product the moment money lands.\"] },\n  { id: \"errors\", level: 2, title: \"Errors\", body: [\"A 402 means the customer has no usable payment method. A 409 means the invoice has already been finalised and cannot be edited.\", \"Errors carry a stable code, a human readable message and a doc_url pointing at this page.\"] },\n]\n\nfunction DemoArticle({ prefix }: { prefix: string }) {\n  return (\n    <article className=\"min-w-0 flex-1 px-5 py-4\">\n      <p className=\"font-mono text-[10px] font-medium tracking-[0.08em] text-fg-subtle uppercase\">Lumen API reference</p>\n      <p className=\"mt-1 text-xl font-medium text-fg\">Invoices</p>\n      {ARTICLE.map((s) => {\n        const H = s.level === 2 ? \"h2\" : \"h3\"\n        return (\n          <section key={s.id}>\n            <H id={`${prefix}-${s.id}`} className={cn(\"scroll-mt-4 font-medium text-fg\", s.level === 2 ? \"mt-7 text-lg\" : \"mt-5 text-base\")}>\n              {s.title}\n            </H>\n            {s.body.map((p, i) => (\n              <p key={i} className=\"mt-2 text-[0.8125rem] leading-[1.65] text-fg-muted\">\n                {p}\n              </p>\n            ))}\n          </section>\n        )\n      })}\n    </article>\n  )\n}\n\n/**\n * A table of contents for long pages. It reads h2 and h3 headings from a\n * container, or takes explicit items, then watches the nearest scroll area\n * and moves an accent marker to the heading you are reading. Clicking an\n * item scrolls there smoothly and hands focus to the heading. With no props\n * it renders a scrollable Lumen docs article next to the list so the spy\n * can be seen working.\n */\nfunction TableOfContents({ items, selector, title = \"On this page\", offset = 24, onActiveChange, sticky = true, className }: TableOfContentsProps) {\n  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, \"\")\n  const demo = !items && !selector\n  const demoRef = React.useRef<HTMLDivElement>(null)\n  const [read, setRead] = React.useState<TocItem[]>([])\n\n  React.useEffect(() => {\n    if (items) return\n    const container = demo ? demoRef.current : selector ? document.querySelector(selector) : null\n    if (!container) return\n    const heads = Array.from(container.querySelectorAll<HTMLElement>(\"h2, h3\"))\n    const taken = new Set<string>()\n    setRead(\n      heads.map((h) => {\n        let id = h.id\n        if (!id || taken.has(id)) {\n          const base = id || slug(h.textContent ?? \"section\") || \"section\"\n          id = base\n          let n = 2\n          while (taken.has(id) || document.getElementById(id)) id = `${base}-${n++}`\n          h.id = id\n        }\n        taken.add(id)\n        return { id, title: (h.textContent ?? \"\").trim(), level: h.tagName === \"H3\" ? 3 : 2 }\n      })\n    )\n  }, [items, selector, demo])\n\n  const list = items ?? read\n\n  if (!demo) return <TocList items={list} title={title} offset={offset} onActiveChange={onActiveChange} sticky={sticky} className={className} />\n\n  return (\n    <div data-slot=\"table-of-contents-demo\" className={cn(\"flex w-full max-w-3xl flex-col gap-4 sm:flex-row sm:gap-6\", className)}>\n      <div className=\"shrink-0 sm:order-2 sm:w-44 sm:pt-4\">\n        <TocList items={list} title={title} offset={offset} onActiveChange={onActiveChange} sticky={sticky} />\n      </div>\n      <div ref={demoRef} className=\"h-96 min-w-0 flex-1 overflow-y-auto rounded-xl border border-border bg-surface shadow-raised sm:order-1\">\n        <DemoArticle prefix={uid} />\n      </div>\n    </div>\n  )\n}\n\nexport { TableOfContents }\nexport type { TableOfContentsProps, TocItem }\n"
    }
  ]
}
