Skip to content

Commit bbed393

Browse files
authored
Merge pull request #39 from jsantanders/feat/update-table-of-content
Update the TOC
2 parents 8040168 + a15fdff commit bbed393

1 file changed

Lines changed: 218 additions & 84 deletions

File tree

components/post/post-table-of-content.tsx

Lines changed: 218 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,108 +2,242 @@
22

33
import { Link } from "@/navigation";
44
import type { Toc, TocEntry } from "@stefanprobst/rehype-extract-toc";
5-
import { ChevronDown, ChevronUp } from "lucide-react";
6-
import { useState } from "react";
5+
import { ChevronDown, ChevronUp, X } from "lucide-react";
6+
import { useEffect, useMemo, useRef, useState } from "react";
77
import { Button } from "../ui/button";
88
import {
9-
Collapsible,
10-
CollapsibleContent,
11-
CollapsibleTrigger,
9+
Collapsible,
10+
CollapsibleContent,
11+
CollapsibleTrigger,
1212
} from "../ui/collapsible";
1313

1414
type PostTableOfContentProps = {
15-
toc: Toc;
16-
locales: {
17-
title: string;
18-
};
15+
toc: Toc;
16+
locales: {
17+
title: string;
18+
};
1919
};
2020

2121
//TODO: I don't want to include the footnotes section,
2222
// but still want to generate it. It could be a better way of handle this.
2323
const NON_TOC_ELEMENTS = ["Footnotes"];
2424

2525
export const PostTableOfContents: React.FC<PostTableOfContentProps> = ({
26-
toc,
27-
locales,
26+
toc,
27+
locales,
2828
}) => {
29-
const [isOpen, setIsOpen] = useState(false);
30-
31-
if (!toc?.length) {
32-
return null;
33-
}
34-
35-
return (
36-
<div className="w-full border rounded-lg bg-primary-foreground p-4 my-8">
37-
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-full">
38-
<div className="flex items-center justify-between">
39-
<h2 className="text-xl lg:text-2xl font-semibold">{locales.title}</h2>
40-
<CollapsibleTrigger asChild>
41-
<Button variant="ghost" size="sm" className="w-9 p-0">
42-
{isOpen ? (
43-
<ChevronUp className="h-4 w-4" />
44-
) : (
45-
<ChevronDown className="h-4 w-4" />
46-
)}
47-
<span className="sr-only">Toggle table of contents</span>
48-
</Button>
49-
</CollapsibleTrigger>
50-
</div>
51-
<CollapsibleContent className="mt-4">
52-
<nav>
53-
<ListOfContent nodes={toc} />
54-
</nav>
55-
</CollapsibleContent>
56-
</Collapsible>
57-
</div>
58-
);
29+
// Desktop: expanded by default; Mobile: collapsed pill by default
30+
const [desktopOpen, setDesktopOpen] = useState(true);
31+
const [mobileExpanded, setMobileExpanded] = useState(false);
32+
const [showMobileControl, setShowMobileControl] = useState(true);
33+
const [activeId, setActiveId] = useState<string | null>(null);
34+
const lastScrollY = useRef(0);
35+
const idsInOrder = useMemo(() => flattenTocIds(toc), [toc]);
36+
37+
useEffect(() => {
38+
if (!idsInOrder.length) return;
39+
let ticking = false;
40+
const updateActiveHeading = () => {
41+
const scrollY = window.scrollY;
42+
// Offset so the active section updates a bit before reaching the very top
43+
const offset = 96; // ~24px top nav + spacing
44+
let current: string | null = null;
45+
for (const id of idsInOrder) {
46+
const el = document.getElementById(id);
47+
if (!el) continue;
48+
if (el.offsetTop - offset <= scrollY) {
49+
current = id;
50+
} else {
51+
break;
52+
}
53+
}
54+
setActiveId(current);
55+
};
56+
57+
const onScroll = () => {
58+
if (!ticking) {
59+
window.requestAnimationFrame(() => {
60+
// Determine scroll direction for mobile control visibility
61+
const y = window.scrollY;
62+
const diff = y - lastScrollY.current;
63+
if (!mobileExpanded) {
64+
if (diff > 2) setShowMobileControl(false); // scrolling down
65+
else if (diff < -2) setShowMobileControl(true); // scrolling up
66+
} else {
67+
setShowMobileControl(true);
68+
}
69+
lastScrollY.current = y;
70+
updateActiveHeading();
71+
ticking = false;
72+
});
73+
ticking = true;
74+
}
75+
};
76+
77+
window.addEventListener("scroll", onScroll, { passive: true });
78+
// Initial run
79+
updateActiveHeading();
80+
return () => window.removeEventListener("scroll", onScroll);
81+
}, [idsInOrder, mobileExpanded]);
82+
83+
if (!toc?.length) return null;
84+
85+
return (
86+
<>
87+
{/* Desktop: fixed right, sticky-like, always visible and expanded by default */}
88+
<div className="hidden lg:block">
89+
<div className="fixed right-6 top-[160px] z-30 w-72 max-h-[70vh] overflow-auto rounded-lg border bg-primary-foreground p-4 shadow-sm">
90+
<Collapsible
91+
open={desktopOpen}
92+
onOpenChange={setDesktopOpen}
93+
className="w-full"
94+
>
95+
<div className="flex items-center justify-between">
96+
<h2 className="text-lg font-semibold">{locales.title}</h2>
97+
<CollapsibleTrigger asChild>
98+
<Button
99+
variant="ghost"
100+
size="sm"
101+
className="w-9 p-0"
102+
aria-label="Toggle table of contents"
103+
>
104+
{desktopOpen ? (
105+
<ChevronUp className="h-4 w-4" />
106+
) : (
107+
<ChevronDown className="h-4 w-4" />
108+
)}
109+
</Button>
110+
</CollapsibleTrigger>
111+
</div>
112+
<CollapsibleContent className="mt-4">
113+
<nav aria-label="Table of contents">
114+
<ListOfContent nodes={toc} activeId={activeId} />
115+
</nav>
116+
</CollapsibleContent>
117+
</Collapsible>
118+
</div>
119+
</div>
120+
121+
{/* Mobile: bottom-center pill that expands to overlay panel; show on scroll up */}
122+
<div className="lg:hidden">
123+
{/* Pill control */}
124+
<button
125+
onClick={() => setMobileExpanded(true)}
126+
className={`fixed bottom-4 left-1/2 -translate-x-1/2 z-40 rounded-full border bg-primary-foreground px-4 py-2 shadow-md transition-all duration-200 ${
127+
showMobileControl
128+
? "opacity-100 translate-y-0"
129+
: "opacity-0 translate-y-2 pointer-events-none"
130+
}`}
131+
aria-label="Open table of contents"
132+
>
133+
<span className="text-sm font-medium">{locales.title}</span>
134+
</button>
135+
136+
{/* Expanded overlay */}
137+
{mobileExpanded && (
138+
<div className="fixed inset-x-0 bottom-0 z-50 flex items-end justify-center">
139+
<div className="mb-4 w-[min(92vw,28rem)] max-h-[75vh] overflow-auto rounded-xl border bg-primary-foreground p-4 shadow-xl">
140+
<div className="flex items-center justify-between">
141+
<h2 className="text-lg font-semibold">{locales.title}</h2>
142+
<Button
143+
variant="ghost"
144+
size="sm"
145+
className="w-9 p-0"
146+
onClick={() => setMobileExpanded(false)}
147+
aria-label="Close table of contents"
148+
>
149+
<X className="h-4 w-4" />
150+
</Button>
151+
</div>
152+
<nav className="mt-3" aria-label="Table of contents">
153+
<ListOfContent
154+
nodes={toc}
155+
activeId={activeId}
156+
onNavigate={() => setMobileExpanded(false)}
157+
/>
158+
</nav>
159+
</div>
160+
</div>
161+
)}
162+
</div>
163+
</>
164+
);
59165
};
60166

61167
const ListOfContent: React.FC<{
62-
nodes: Toc;
63-
chapter?: string;
64-
}> = ({ nodes, chapter = "" }) => {
65-
const headingElements = nodes
66-
.filter((n) => !NON_TOC_ELEMENTS.includes(n.value))
67-
.map((node, idx) => {
68-
return (
69-
<li key={node.id}>
70-
<TOCLink node={node} ch={`${chapter}${idx + 1}.`} />
71-
{node.children?.length && node.children?.length > 0 && (
72-
<ListOfContent
73-
nodes={node.children}
74-
chapter={`${chapter}${idx + 1}.`}
75-
/>
76-
)}
77-
</li>
78-
);
79-
});
80-
81-
return <ul>{headingElements}</ul>;
168+
nodes: Toc;
169+
chapter?: string;
170+
activeId?: string | null;
171+
onNavigate?: () => void;
172+
}> = ({ nodes, chapter = "", activeId, onNavigate }) => {
173+
const headingElements = nodes
174+
.filter((n) => !NON_TOC_ELEMENTS.includes(n.value))
175+
.map((node, idx) => {
176+
return (
177+
<li key={node.id}>
178+
<TOCLink
179+
node={node}
180+
ch={`${chapter}${idx + 1}.`}
181+
activeId={activeId}
182+
onNavigate={onNavigate}
183+
/>
184+
{node.children?.length && node.children?.length > 0 && (
185+
<ListOfContent
186+
nodes={node.children}
187+
chapter={`${chapter}${idx + 1}.`}
188+
activeId={activeId}
189+
onNavigate={onNavigate}
190+
/>
191+
)}
192+
</li>
193+
);
194+
});
195+
196+
return <ul>{headingElements}</ul>;
82197
};
83198

84199
const TOCLink: React.FC<{
85-
node: TocEntry;
86-
ch: string;
87-
}> = ({ node, ch }) => {
88-
const fontSizes: Record<number, string> = { 2: "base", 3: "sm", 4: "xs" };
89-
const padding: Record<number, string> = { 2: "pl-0", 3: "pl-7", 4: "pl-10" };
90-
const id = node.id;
91-
92-
if (!id) return null;
93-
94-
return (
95-
<Link
96-
href={`#${id}`}
97-
className={`block text-${fontSizes[node.depth]} ${padding[node.depth]} py-1 text-muted-foreground hover:text-primary hover:underline
200+
node: TocEntry;
201+
ch: string;
202+
activeId?: string | null;
203+
onNavigate?: () => void;
204+
}> = ({ node, ch, activeId, onNavigate }) => {
205+
const fontSizes: Record<number, string> = { 2: "base", 3: "sm", 4: "xs" };
206+
const padding: Record<number, string> = { 2: "pl-0", 3: "pl-7", 4: "pl-10" };
207+
const id = node.id;
208+
const isActive = id && activeId === id;
209+
210+
if (!id) return null;
211+
212+
return (
213+
<Link
214+
href={`#${id}`}
215+
className={`block py-1 ${padding[node.depth]} pl-2 border-l text-${
216+
fontSizes[node.depth]
217+
} ${
218+
isActive
219+
? "text-foreground border-primary"
220+
: "text-muted-foreground border-transparent hover:text-primary hover:underline"
98221
}`}
99-
onClick={(e) => {
100-
e.preventDefault();
101-
document
102-
.getElementById(id)
103-
?.scrollIntoView({ behavior: "smooth", block: "start" });
104-
}}
105-
>
106-
{ch} {node.value}
107-
</Link>
108-
);
222+
aria-current={isActive ? "true" : undefined}
223+
onClick={(e) => {
224+
e.preventDefault();
225+
document
226+
.getElementById(id)
227+
?.scrollIntoView({ behavior: "smooth", block: "start" });
228+
onNavigate?.();
229+
}}
230+
>
231+
{ch} {node.value}
232+
</Link>
233+
);
109234
};
235+
236+
function flattenTocIds(nodes: Toc, acc: string[] = []): string[] {
237+
for (const n of nodes) {
238+
if (NON_TOC_ELEMENTS.includes(n.value)) continue;
239+
if (n.id) acc.push(n.id);
240+
if (n.children?.length) flattenTocIds(n.children, acc);
241+
}
242+
return acc;
243+
}

0 commit comments

Comments
 (0)