on d012421 help sign in
urbit.org/components/TableOfContents.js
131 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
58 className={classNames({
59 hidden: headings.length === 1 && headings?.[0]?.items?.length === 0,
60 })}
61 >
62 {headings.map((heading, index) => (
63 <li key={heading.id}>
64 <a
65 className={classNames({
66 "font-bold": index === 0,
67 "font-medium text-sm": index !== 0,
68 "text-green-400": heading.id === activeId && index !== 0,
69 })}
70 href={`#${heading.id}`}
71 >
72 {heading.title}
73 </a>
74 {heading.items.length > 0 && (
75 <ul className="pl-2 text-xs">
76 {heading.items.map((child) => (
77 <li
78 key={child.id}
79 className={
80 child.id === activeId ? "text-green-400" : "text-wall-500"
81 }
82 >
83 <a href={`#${child.id}`}>{child.title}</a>
84 </li>
85 ))}
86 </ul>
87 )}
88 </li>
89 ))}
90 </ul>
91);
93const useIntersectionObserver = (setActiveId) => {
94 const headingElementsRef = useRef({});
95 useEffect(() => {
96 const callback = (headings) => {
97 headingElementsRef.current = headings.reduce((map, headingElement) => {
98 map[headingElement.target.id] = headingElement;
99 return map;
100 }, headingElementsRef.current);
102 const visibleHeadings = [];
103 Object.keys(headingElementsRef.current).forEach((key) => {
104 const headingElement = headingElementsRef.current[key];
105 if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
106 });
108 const getIndexFromId = (id) =>
109 headingElements.findIndex((heading) => heading.id === id);
111 if (visibleHeadings.length === 1) {
112 setActiveId(visibleHeadings[0].target.id);
113 } else if (visibleHeadings.length > 1) {
114 const sortedVisibleHeadings = visibleHeadings.sort(
115 (a, b) => getIndexFromId(a.target.id) > getIndexFromId(b.target.id)
116 );
117 setActiveId(sortedVisibleHeadings[0].target.id);
118 }
119 };
121 const observer = new IntersectionObserver(callback, {
122 rootMargin: "0px 0px -50% 0px",
123 });
125 const headingElements = Array.from(document.querySelectorAll("h2, h3"));
127 headingElements.forEach((element) => observer.observe(element));
129 return () => observer.disconnect();
130 }, [setActiveId]);
131};