gardesk/tarmac-web / 1616c63

Browse files

add table of contents component

Authored by espadonne
SHA
1616c63aeda0e0848c544f643b8ff9c6768b7ce0
Parents
6ebba2d
Tree
3002873

1 changed file

StatusFile+-
A src/components/TableOfContents.tsx 101 0
src/components/TableOfContents.tsxadded
@@ -0,0 +1,101 @@
1
+"use client";
2
+
3
+import { useEffect, useState } from "react";
4
+import { usePathname } from "next/navigation";
5
+
6
+interface Heading {
7
+  id: string;
8
+  text: string;
9
+  level: number;
10
+}
11
+
12
+export default function TableOfContents() {
13
+  const [headings, setHeadings] = useState<Heading[]>([]);
14
+  const [activeId, setActiveId] = useState<string>("");
15
+  const pathname = usePathname();
16
+
17
+  useEffect(() => {
18
+    setHeadings([]);
19
+    setActiveId("");
20
+
21
+    const timer = setTimeout(() => {
22
+      const article = document.querySelector("article");
23
+      if (!article) return;
24
+
25
+      const elements = article.querySelectorAll("h2, h3");
26
+      const items: Heading[] = [];
27
+
28
+      elements.forEach((el) => {
29
+        if (!el.id) {
30
+          el.id =
31
+            el.textContent
32
+              ?.toLowerCase()
33
+              .replace(/[^a-z0-9]+/g, "-")
34
+              .replace(/(^-|-$)/g, "") || "";
35
+        }
36
+
37
+        items.push({
38
+          id: el.id,
39
+          text: el.textContent || "",
40
+          level: el.tagName === "H2" ? 2 : 3,
41
+        });
42
+      });
43
+
44
+      setHeadings(items);
45
+    }, 100);
46
+
47
+    return () => clearTimeout(timer);
48
+  }, [pathname]);
49
+
50
+  useEffect(() => {
51
+    if (headings.length === 0) return;
52
+
53
+    const observer = new IntersectionObserver(
54
+      (entries) => {
55
+        entries.forEach((entry) => {
56
+          if (entry.isIntersecting) {
57
+            setActiveId(entry.target.id);
58
+          }
59
+        });
60
+      },
61
+      { rootMargin: "-80px 0px -80% 0px" }
62
+    );
63
+
64
+    headings.forEach((heading) => {
65
+      const el = document.getElementById(heading.id);
66
+      if (el) observer.observe(el);
67
+    });
68
+
69
+    return () => observer.disconnect();
70
+  }, [headings]);
71
+
72
+  if (headings.length === 0) return null;
73
+
74
+  return (
75
+    <nav className="w-56 shrink-0 hidden xl:block">
76
+      <div className="sticky top-8 p-4">
77
+        <h4 className="text-sm font-semibold text-surface-900 dark:text-surface-100 mb-4">
78
+          On This Page
79
+        </h4>
80
+        <ul className="space-y-2 text-sm">
81
+          {headings.map((heading) => (
82
+            <li key={heading.id}>
83
+              <a
84
+                href={`#${heading.id}`}
85
+                className={`block transition-colors ${
86
+                  heading.level === 3 ? "pl-3" : ""
87
+                } ${
88
+                  activeId === heading.id
89
+                    ? "text-surface-900 dark:text-surface-100 font-medium"
90
+                    : "text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100"
91
+                }`}
92
+              >
93
+                {heading.text}
94
+              </a>
95
+            </li>
96
+          ))}
97
+        </ul>
98
+      </div>
99
+    </nav>
100
+  );
101
+}