on cd863bb help sign in
urbit.org/components/TableOfContents.js
127 lines 4.0 kB history raw
1import { useState, useEffect, useRef } from "react";
2import classNames from "classnames";
4export const TableOfContents = ({ staticPosition, noh3s }) => {
5 const { nestedHeadings } = useHeadingsData(noh3s);
6 const [activeId, setActiveId] = useState();
7 useIntersectionObserver(setActiveId);
8 return (
9 <nav
10 className={
11 (staticPosition ? "static" : "sticky") +
12 " min-h-0 overflow-y-auto w-52 shrink-0 pb-24 hidden lg:block pl-4"
13 }
14 style={{ top: "8rem", height: "calc(100vh - 16rem)" }}
15 >
16 <div className="relative after:fixed after:bg-gradient-to-t after:from-white after:via-white after:bottom-0 after:w-52 after:h-60 after:pointer-events-none">
17 <Headings headings={nestedHeadings} activeId={activeId} />
18 </div>
19 </nav>
20 );
21};
23const getNestedHeadings = (headingElements) => {
24 const nestedHeadings = [];
26 headingElements.forEach((heading, index) => {
27 const { innerText: title, id } = heading;
29 if (heading.nodeName === "H2") {
30 nestedHeadings.push({ id, title, items: [] });
31 } else if (heading.nodeName === "H3" && nestedHeadings.length > 0) {
32 nestedHeadings[nestedHeadings.length - 1].items.push({
33 id,
34 title,
35 });
36 }
37 });
39 return nestedHeadings;
40};
42const useHeadingsData = (noh3s) => {
43 const [nestedHeadings, setNestedHeadings] = useState([]);
45 const query = noh3s ? "h2" : "h2, h3";
46 useEffect(() => {
47 const headingElements = Array.from(document.querySelectorAll(query));
49 const newNestedHeadings = getNestedHeadings(headingElements);
50 setNestedHeadings(newNestedHeadings);
51 }, []);
53 return { nestedHeadings };
54};
56const Headings = ({ headings, activeId }) => (
57 <ul className={classNames({ hidden: headings.length === 1 })}>
58 {headings.map((heading, index) => (
59 <li key={heading.id}>
60 <a
61 className={classNames({
62 "font-bold": index === 0,
63 "font-medium text-sm": index !== 0,
64 "text-green-400": heading.id === activeId && index !== 0,
65 })}
66 href={`#${heading.id}`}
67 >
68 {heading.title}
69 </a>
70 {heading.items.length > 0 && (
71 <ul className="pl-2 text-xs">
72 {heading.items.map((child) => (
73 <li
74 key={child.id}
75 className={
76 child.id === activeId ? "text-green-400" : "text-wall-500"
77 }
78 >
79 <a href={`#${child.id}`}>{child.title}</a>
80 </li>
81 ))}
82 </ul>
83 )}
84 </li>
85 ))}
86 </ul>
87);
89const useIntersectionObserver = (setActiveId) => {
90 const headingElementsRef = useRef({});
91 useEffect(() => {
92 const callback = (headings) => {
93 headingElementsRef.current = headings.reduce((map, headingElement) => {
94 map[headingElement.target.id] = headingElement;
95 return map;
96 }, headingElementsRef.current);
98 const visibleHeadings = [];
99 Object.keys(headingElementsRef.current).forEach((key) => {
100 const headingElement = headingElementsRef.current[key];
101 if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
102 });
104 const getIndexFromId = (id) =>
105 headingElements.findIndex((heading) => heading.id === id);
107 if (visibleHeadings.length === 1) {
108 setActiveId(visibleHeadings[0].target.id);
109 } else if (visibleHeadings.length > 1) {
110 const sortedVisibleHeadings = visibleHeadings.sort(
111 (a, b) => getIndexFromId(a.target.id) > getIndexFromId(b.target.id)
112 );
113 setActiveId(sortedVisibleHeadings[0].target.id);
114 }
115 };
117 const observer = new IntersectionObserver(callback, {
118 rootMargin: "0px 0px -50% 0px",
119 });
121 const headingElements = Array.from(document.querySelectorAll("h2, h3"));
123 headingElements.forEach((element) => observer.observe(element));
125 return () => observer.disconnect();
126 }, [setActiveId]);
127};