1"use client";
2
3import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
4
5export interface TocItem {
6 key: string;
7 label: string;
8 level: 1 | 2 | 3;
9}
10
11interface TableOfContentsProps {
12 items: TocItem[];
13 activeKey?: string;
14 defaultActiveKey?: string;
15 onSelect?: (key: string) => void;
16 className?: string;
17}
18
19const indent: Record<1 | 2 | 3, number> = { 1: 16, 2: 28, 3: 40 };
20
21/* A hairline track runs down the left edge; a slim dark marker glides
22 along it to the active heading. Nothing else moves — the active label
23 just darkens. */
24export function TableOfContents({
25 items,
26 activeKey,
27 defaultActiveKey,
28 onSelect,
29 className = "",
30}: TableOfContentsProps) {
31 const [internal, setInternal] = useState(defaultActiveKey ?? items[0]?.key ?? "");
32 const current = activeKey !== undefined ? activeKey : internal;
33 const refs = useRef<Record<string, HTMLButtonElement | null>>({});
34 const [marker, setMarker] = useState<{ top: number; height: number } | null>(null);
35 const [ready, setReady] = useState(false);
36
37 const measure = () => {
38 const el = refs.current[current];
39 setMarker(el ? { top: el.offsetTop, height: el.offsetHeight } : null);
40 };
41
42 useLayoutEffect(() => {
43 measure();
44 // eslint-disable-next-line react-hooks/exhaustive-deps
45 }, [current]);
46
47 useEffect(() => {
48 // Only animate moves after the first placement
49 const id = requestAnimationFrame(() => setReady(true));
50 return () => cancelAnimationFrame(id);
51 }, []);
52
53 const handleSelect = (key: string) => {
54 setInternal(key);
55 onSelect?.(key);
56 };
57
58 const slide = ready ? "top 360ms var(--ease-out-soft), height 360ms var(--ease-out-soft)" : "none";
59
60 return (
61 <nav
62 aria-label="On this page"
63 className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
64 style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
65 >
66 <div
67 className="bg-white rounded-[13px] px-5 pt-4 pb-4"
68 style={{ boxShadow: "var(--panel-face-shadow)" }}
69 >
70 <p aria-hidden className="text-[12px] font-medium text-neutral-400 mb-3">
71 on this page
72 </p>
73
74 <div className="relative">
75 <span aria-hidden className="absolute left-0 top-0 bottom-0 w-px bg-neutral-900/[0.08]" />
76 {marker && (
77 <span
78 aria-hidden
79 className="absolute -left-[0.5px] w-[2px] rounded-full bg-neutral-900"
80 style={{ top: marker.top + 6, height: marker.height - 12, transition: slide }}
81 />
82 )}
83
84 {items.map((item) => {
85 const isActive = current === item.key;
86 return (
87 <button
88 key={item.key}
89 ref={(el) => {
90 refs.current[item.key] = el;
91 }}
92 type="button"
93 onClick={() => handleSelect(item.key)}
94 aria-current={isActive ? "location" : undefined}
95 className={[
96 // Weight stays fixed so the active item doesn't reflow its line
97 "relative block w-full text-left py-1.5 text-[13px] leading-5 rounded-[6px] cursor-pointer",
98 "transition-colors duration-200 outline-none",
99 "focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-neutral-900",
100 isActive
101 ? "text-neutral-900 font-medium"
102 : item.level === 1
103 ? "text-neutral-600 hover:text-neutral-900"
104 : "text-neutral-400 hover:text-neutral-700",
105 ].join(" ")}
106 style={{ paddingLeft: indent[item.level] }}
107 >
108 {item.label}
109 </button>
110 );
111 })}
112 </div>
113 </div>
114 </nav>
115 );
116}