Skip to content

Commit 68c6450

Browse files
committed
feat: add table of contents navigation for documentation pages
1 parent 4e1e90a commit 68c6450

File tree

6 files changed

+212
-7
lines changed

6 files changed

+212
-7
lines changed

components/Sidebar.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
CollapsibleTrigger,
1818
} from './ui/collapsible';
1919
import { Button } from './ui/button';
20+
import TableOfContents from './TableOfContents';
2021

2122
const DocLink = ({
2223
uri,
@@ -276,9 +277,9 @@ export const SidebarLayout = ({ children }: { children: React.ReactNode }) => {
276277
<DocsNav open={open} setOpen={setOpen} />
277278
</div>
278279
</div>
279-
<div className='dark:bg-slate-800 max-w-[1400px] grid grid-cols-1 lg:grid-cols-4 mx-4 md:mx-12'>
280+
<div className='dark:bg-slate-800 max-w-[1400px] grid grid-cols-1 lg:grid-cols-12 mx-4 md:mx-12 gap-4'>
280281
{!shouldHideSidebar && (
281-
<div className='hidden lg:block mt-24 sticky top-24 h-[calc(100vh-6rem)] overflow-hidden'>
282+
<div className='hidden lg:block lg:col-span-3 mt-24 sticky top-24 h-[calc(100vh-6rem)] overflow-hidden'>
282283
<div className='h-full overflow-y-auto scrollbar-hidden'>
283284
<DocsNav open={open} setOpen={setOpen} />
284285
<CarbonAds
@@ -289,10 +290,19 @@ export const SidebarLayout = ({ children }: { children: React.ReactNode }) => {
289290
</div>
290291
)}
291292
<div
292-
className={`lg:mt-20 mx-4 md:mx-0 ${shouldHideSidebar ? 'col-span-4 w-full' : 'col-span-4 md:col-span-3 lg:w-5/6'}`}
293+
className={`lg:mt-20 mx-4 md:mx-0 ${
294+
shouldHideSidebar
295+
? 'col-span-12 w-full'
296+
: 'col-span-12 lg:col-span-6 xl:col-span-6'
297+
}`}
293298
>
294299
{children}
295300
</div>
301+
{!shouldHideSidebar && (
302+
<div className='hidden xl:block xl:col-span-3 mt-20'>
303+
<TableOfContents />
304+
</div>
305+
)}
296306
</div>
297307
</section>
298308
</div>

components/TableOfContents.tsx

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
'use client';
2+
3+
import React, { useEffect, useState, useCallback } from 'react';
4+
import { useRouter } from 'next/router';
5+
import { cn } from '~/lib/utils';
6+
7+
interface TocItem {
8+
id: string;
9+
text: string;
10+
level: number;
11+
}
12+
13+
interface TableOfContentsProps {
14+
className?: string;
15+
}
16+
17+
export const TableOfContents: React.FC<TableOfContentsProps> = ({
18+
className,
19+
}) => {
20+
const router = useRouter();
21+
const [tocItems, setTocItems] = useState<TocItem[]>([]);
22+
const [activeId, setActiveId] = useState<string>('');
23+
24+
// Extract headings from the page
25+
useEffect(() => {
26+
const headings = document.querySelectorAll('h2, h3');
27+
const items: TocItem[] = [];
28+
29+
// Skip the first heading and add "Introduction" as the first item
30+
if (headings.length > 0) {
31+
items.push({
32+
id: 'introduction',
33+
text: 'Introduction',
34+
level: 2, // Same level as h2
35+
});
36+
}
37+
38+
// Start from index 1 to skip the first heading
39+
for (let i = 1; i < headings.length; i++) {
40+
const heading = headings[i];
41+
const text = heading.textContent || '';
42+
const id = heading.id || text.toLowerCase().replace(/\s+/g, '-');
43+
44+
if (!heading.id && id) {
45+
heading.id = id;
46+
}
47+
48+
items.push({
49+
id,
50+
text,
51+
level: parseInt(heading.tagName.substring(1), 10), // Get heading level (2 for h2, 3 for h3, etc.)
52+
});
53+
}
54+
55+
setTocItems(items);
56+
}, [router.asPath]);
57+
58+
// Intersection Observer to track which section is visible
59+
useEffect(() => {
60+
if (tocItems.length === 0) return;
61+
62+
const observer = new IntersectionObserver(
63+
(entries) => {
64+
let newActiveId = '';
65+
let isAtTop = window.scrollY < 100; // 100px from top
66+
67+
if (isAtTop) {
68+
// If at the top, highlight Introduction
69+
newActiveId = 'introduction';
70+
} else {
71+
// Otherwise, find the first visible heading
72+
entries.forEach((entry) => {
73+
if (entry.isIntersecting && !newActiveId) {
74+
newActiveId = entry.target.id;
75+
}
76+
});
77+
}
78+
79+
if (newActiveId) {
80+
setActiveId(newActiveId);
81+
}
82+
},
83+
{
84+
rootMargin: '-20% 0px -60% 0px',
85+
threshold: 0.1,
86+
}
87+
);
88+
89+
// Observe all headings
90+
tocItems.forEach(({ id }) => {
91+
const element = document.getElementById(id);
92+
if (element) {
93+
observer.observe(element);
94+
}
95+
});
96+
97+
98+
return () => {
99+
tocItems.forEach(({ id }) => {
100+
const element = document.getElementById(id);
101+
if (element) {
102+
observer.unobserve(element);
103+
}
104+
});
105+
};
106+
}, [tocItems]);
107+
108+
useEffect(() => {
109+
const handleScroll = () => {
110+
if (window.scrollY < 100) {
111+
setActiveId('introduction');
112+
}
113+
};
114+
115+
window.addEventListener('scroll', handleScroll, { passive: true });
116+
return () => window.removeEventListener('scroll', handleScroll);
117+
}, []);
118+
119+
const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
120+
e.preventDefault();
121+
const element = id === 'introduction'
122+
? document.documentElement // Scroll to top for introduction
123+
: document.getElementById(id);
124+
125+
if (element) {
126+
const yOffset = -80; // Adjust this value to match your header height
127+
const y = id === 'introduction'
128+
? 0
129+
: element.getBoundingClientRect().top + window.pageYOffset + yOffset;
130+
131+
window.scrollTo({ top: y, behavior: 'smooth' });
132+
}
133+
}, []);
134+
135+
if (tocItems.length === 0) {
136+
return null;
137+
}
138+
139+
return (
140+
<nav
141+
className={cn(
142+
'hidden xl:block sticky top-24 h-[calc(100vh-6rem)] overflow-y-auto',
143+
'pr-4',
144+
className
145+
)}
146+
aria-label="Table of contents"
147+
style={{
148+
scrollbarWidth: 'thin',
149+
scrollbarColor: 'rgb(203 213 225) transparent',
150+
}}
151+
>
152+
<div className="space-y-2 pb-8">
153+
<h4 className="font-semibold text-slate-900 dark:text-slate-100 mb-4 text-sm uppercase tracking-wide">
154+
On This Page
155+
</h4>
156+
<ul className="space-y-2 text-sm border-l-2 border-slate-200 dark:border-slate-700">
157+
{tocItems.map((item) => (
158+
<li
159+
key={item.id}
160+
className={cn('transition-all duration-200', {
161+
'pl-4': item.level === 2,
162+
'pl-8': item.level === 3,
163+
})}
164+
>
165+
<a
166+
key={item.id}
167+
href={`#${item.id}`}
168+
onClick={(e) => handleClick(e, item.id)}
169+
className={cn(
170+
'block py-2 text-sm transition-colors duration-200',
171+
activeId === item.id || (item.id === 'introduction' && !activeId)
172+
? 'text-primary font-medium'
173+
: 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-300',
174+
item.level === 3 ? 'pl-2' : ''
175+
)}
176+
>
177+
{item.text}
178+
</a>
179+
</li>
180+
))}
181+
</ul>
182+
</div>
183+
</nav>
184+
);
185+
};
186+
187+
export default TableOfContents;

pages/learn/[slug].page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import Head from 'next/head';
3+
import { useRouter } from 'next/router';
34
import StyledMarkdown from '~/components/StyledMarkdown';
45
import { getLayout } from '~/components/Sidebar';
56
import getStaticMarkdownPaths from '~/lib/getStaticMarkdownPaths';
@@ -23,6 +24,7 @@ export default function StaticMarkdownPage({
2324
frontmatter: any;
2425
content: any;
2526
}) {
27+
const router = useRouter();
2628
const fileRenderType = '_md';
2729
const newTitle = 'JSON Schema - ' + frontmatter.title;
2830
return (
@@ -31,7 +33,7 @@ export default function StaticMarkdownPage({
3133
<title>{newTitle}</title>
3234
</Head>
3335
<Headline1>{frontmatter.title}</Headline1>
34-
<StyledMarkdown markdown={content} />
36+
<StyledMarkdown key={router.asPath} markdown={content} />
3537
<NextPrevButton
3638
prevLabel={frontmatter?.prev?.label}
3739
prevURL={frontmatter?.prev?.url}

pages/overview/[slug].page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import Head from 'next/head';
3+
import { useRouter } from 'next/router';
34
import { getLayout } from '~/components/Sidebar';
45
import StyledMarkdown from '~/components/StyledMarkdown';
56
import getStaticMarkdownPaths from '~/lib/getStaticMarkdownPaths';
@@ -23,6 +24,7 @@ export default function StaticMarkdownPage({
2324
frontmatter: any;
2425
content: any;
2526
}) {
27+
const router = useRouter();
2628
const fileRenderType = '_md';
2729
const newTitle = 'JSON Schema - ' + frontmatter.title;
2830

@@ -32,7 +34,7 @@ export default function StaticMarkdownPage({
3234
<title>{newTitle}</title>
3335
</Head>
3436
<Headline1>{frontmatter.title}</Headline1>
35-
<StyledMarkdown markdown={content} />
37+
<StyledMarkdown key={router.asPath} markdown={content} />
3638
<NextPrevButton
3739
prevLabel={frontmatter.prev?.label}
3840
prevURL={frontmatter.prev?.url}

pages/understanding-json-schema/[slug].page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import Head from 'next/head';
3+
import { useRouter } from 'next/router';
34
import StyledMarkdown from '~/components/StyledMarkdown';
45
import { getLayout } from '~/components/Sidebar';
56
import getStaticMarkdownPaths from '~/lib/getStaticMarkdownPaths';
@@ -23,6 +24,7 @@ export default function StaticMarkdownPage({
2324
frontmatter: any;
2425
content: any;
2526
}) {
27+
const router = useRouter();
2628
const fileRenderType = '_md';
2729
const newTitle = 'JSON Schema - ' + frontmatter.title;
2830
return (
@@ -31,7 +33,7 @@ export default function StaticMarkdownPage({
3133
<title>{newTitle}</title>
3234
</Head>
3335
<Headline1>{frontmatter.title || 'NO TITLE!'}</Headline1>
34-
<StyledMarkdown markdown={content} />
36+
<StyledMarkdown key={router.asPath} markdown={content} />
3537
<NextPrevButton
3638
prevLabel={frontmatter?.prev?.label}
3739
prevURL={frontmatter?.prev?.url}

pages/understanding-json-schema/reference/[slug].page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import Head from 'next/head';
3+
import { useRouter } from 'next/router';
34
import { getLayout } from '~/components/Sidebar';
45
import { Headline1 } from '~/components/Headlines';
56
import StyledMarkdown from '~/components/StyledMarkdown';
@@ -26,6 +27,7 @@ export default function StaticMarkdownPage({
2627
frontmatter: any;
2728
content: any;
2829
}) {
30+
const router = useRouter();
2931
const newTitle = 'JSON Schema - ' + frontmatter.title;
3032
const fileRenderType = '_md';
3133
return (
@@ -34,7 +36,7 @@ export default function StaticMarkdownPage({
3436
<title>{newTitle}</title>
3537
</Head>
3638
<Headline1>{frontmatter.title || 'NO TITLE!'}</Headline1>
37-
<StyledMarkdown markdown={content} />
39+
<StyledMarkdown key={router.asPath} markdown={content} />
3840
<NextPrevButton
3941
prevLabel={frontmatter?.prev?.label}
4042
prevURL={frontmatter?.prev?.url}

0 commit comments

Comments
 (0)