Skip to content

Commit 810fc86

Browse files
committed
feat: add TOC to docs
1 parent 501115d commit 810fc86

9 files changed

Lines changed: 168 additions & 4 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { Anchor } from '@tiny-design/react';
3+
import { useHeadings, HeadingItem } from '../../hooks/use-headings';
4+
import './table-of-contents.scss';
5+
6+
interface TocNode {
7+
heading: HeadingItem;
8+
children: HeadingItem[];
9+
}
10+
11+
const buildTree = (headings: HeadingItem[]): TocNode[] => {
12+
const tree: TocNode[] = [];
13+
let current: TocNode | null = null;
14+
15+
for (const heading of headings) {
16+
if (heading.level === 2) {
17+
current = { heading, children: [] };
18+
tree.push(current);
19+
} else if (heading.level === 3) {
20+
if (current) {
21+
current.children.push(heading);
22+
} else {
23+
// h3 without a preceding h2 — treat as top-level
24+
tree.push({ heading, children: [] });
25+
}
26+
}
27+
}
28+
29+
return tree;
30+
};
31+
32+
export const TableOfContents = (): React.ReactElement | null => {
33+
const headings = useHeadings();
34+
35+
if (headings.length === 0) {
36+
return null;
37+
}
38+
39+
const tree = buildTree(headings);
40+
41+
return (
42+
<div className="doc-toc">
43+
<Anchor type="line" offsetTop={80}>
44+
{tree.map((node) => (
45+
<Anchor.Link
46+
key={node.heading.id}
47+
href={`#${node.heading.id}`}
48+
title={node.heading.text}>
49+
{node.children.map((child) => (
50+
<Anchor.Link
51+
key={child.id}
52+
href={`#${child.id}`}
53+
title={child.text}
54+
/>
55+
))}
56+
</Anchor.Link>
57+
))}
58+
</Anchor>
59+
</div>
60+
);
61+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@use '../../variables' as *;
2+
3+
.doc-toc {
4+
position: fixed;
5+
top: 100px;
6+
right: 20px;
7+
width: 180px;
8+
max-height: calc(100vh - 120px);
9+
overflow-y: auto;
10+
font-size: 13px;
11+
12+
@media (max-width: $size-xl) {
13+
display: none;
14+
}
15+
}

apps/docs/src/containers/components/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getComponentMenu, RouterItem } from '../../routers';
44
import { SidebarMenu } from '../../components/sidebar-menu';
55
import { Layout, Loader, Divider } from '@tiny-design/react';
66
import { DocFooter } from '../../components/doc-footer';
7+
import { TableOfContents } from '../../components/table-of-contents';
78
import { useLocaleContext } from '../../context/locale-context';
89
import ComponentOverview from './overview';
910

@@ -52,6 +53,7 @@ const ComponentsPage = (): React.ReactElement => {
5253
<Divider className="doc-container__divider" />
5354
<DocFooter routers={flattenedRouters} />
5455
</Content>
56+
<TableOfContents />
5557
</Layout>
5658
</Layout>
5759
);

apps/docs/src/containers/guide/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getGuideMenu } from '../../routers';
44
import { SidebarMenu } from '../../components/sidebar-menu';
55
import { Layout, Loader, Divider } from '@tiny-design/react';
66
import { DocFooter } from '../../components/doc-footer';
7+
import { TableOfContents } from '../../components/table-of-contents';
78
import { useLocaleContext } from '../../context/locale-context';
89

910
const { Content } = Layout;
@@ -41,6 +42,7 @@ const GuidePage = (): React.ReactElement => {
4142
<Divider className="doc-container__divider" />
4243
<DocFooter routers={guideMenu} />
4344
</Content>
45+
<TableOfContents />
4446
</Layout>
4547
</Layout>
4648
);

apps/docs/src/containers/theme/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getThemeMenu } from '../../routers';
44
import { SidebarMenu } from '../../components/sidebar-menu';
55
import { Layout, Loader, Divider } from '@tiny-design/react';
66
import { DocFooter } from '../../components/doc-footer';
7+
import { TableOfContents } from '../../components/table-of-contents';
78
import { useLocaleContext } from '../../context/locale-context';
89

910
const { Content } = Layout;
@@ -41,6 +42,7 @@ const ThemePage = (): React.ReactElement => {
4142
<Divider className="doc-container__divider" />
4243
<DocFooter routers={themeMenu} />
4344
</Content>
45+
<TableOfContents />
4446
</Layout>
4547
</Layout>
4648
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useState, useEffect } from 'react';
2+
import { useLocation } from 'react-router-dom';
3+
4+
export interface HeadingItem {
5+
id: string;
6+
text: string;
7+
level: 2 | 3;
8+
}
9+
10+
const queryHeadings = (): HeadingItem[] => {
11+
const elements = document.querySelectorAll('.markdown h2[id], .markdown h3[id]');
12+
return Array.from(elements).map((el) => ({
13+
id: el.id,
14+
text: el.textContent?.replace(/\s*#$/, '') ?? '',
15+
level: (el.tagName === 'H2' ? 2 : 3) as 2 | 3,
16+
}));
17+
};
18+
19+
export const useHeadings = (): HeadingItem[] => {
20+
const { pathname } = useLocation();
21+
const [headings, setHeadings] = useState<HeadingItem[]>([]);
22+
23+
useEffect(() => {
24+
setHeadings([]);
25+
26+
// Try immediately in case content is already rendered
27+
const initial = queryHeadings();
28+
if (initial.length > 0) {
29+
setHeadings(initial);
30+
return;
31+
}
32+
33+
// Otherwise observe DOM changes until headings appear
34+
const container = document.querySelector('.doc-container__layout');
35+
if (!container) return;
36+
37+
const observer = new MutationObserver(() => {
38+
const found = queryHeadings();
39+
if (found.length > 0) {
40+
setHeadings(found);
41+
observer.disconnect();
42+
}
43+
});
44+
45+
observer.observe(container, { childList: true, subtree: true });
46+
47+
return () => observer.disconnect();
48+
}, [pathname]);
49+
50+
return headings;
51+
};

apps/docs/src/index.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,13 @@ body {
2727
}
2828

2929
&__layout {
30-
padding: 30px 30px 30px 280px;
30+
padding: 30px 230px 30px 280px;
3131
overflow-x: hidden;
3232

33+
@media (max-width: $size-xl) {
34+
padding: 30px 30px 30px 280px;
35+
}
36+
3337
@media (max-width: $size-md) {
3438
padding: 20px 16px;
3539
}

packages/react/src/anchor/anchor.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,12 @@ const Anchor = (props: AnchorProps): JSX.Element => {
7171
const container = getContainer();
7272
const containerRect = container.getBoundingClientRect();
7373
const elementRect = element.getBoundingClientRect();
74-
container.scrollTop += elementRect.top - containerRect.top;
74+
container.scrollTo({
75+
top: container.scrollTop + elementRect.top - containerRect.top,
76+
behavior: 'smooth',
77+
});
7578
} else {
76-
element.scrollIntoView(true);
79+
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
7780
}
7881
},
7982
[getContainer]
@@ -117,10 +120,24 @@ const Anchor = (props: AnchorProps): JSX.Element => {
117120
? container.getBoundingClientRect().top
118121
: 0;
119122

123+
// Check if scrolled to the bottom
124+
let isAtBottom = false;
125+
if (container) {
126+
isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
127+
} else {
128+
isAtBottom =
129+
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 1;
130+
}
131+
120132
let newActiveId = '';
121133
let maxTop = -Infinity;
122134
let firstId = '';
123135
let firstTop = Infinity;
136+
// Track the last heading visible in the viewport (for bottom-of-page case)
137+
let lastVisibleId = '';
138+
let lastVisibleTop = -Infinity;
139+
const viewportHeight = container ? container.clientHeight : window.innerHeight;
140+
124141
links.forEach((href) => {
125142
const id = href.replace('#', '');
126143
const el = document.getElementById(id);
@@ -134,8 +151,17 @@ const Anchor = (props: AnchorProps): JSX.Element => {
134151
maxTop = elTop;
135152
newActiveId = id;
136153
}
154+
// Track headings visible in the viewport
155+
if (elTop >= 0 && elTop <= viewportHeight && elTop > lastVisibleTop) {
156+
lastVisibleTop = elTop;
157+
lastVisibleId = id;
158+
}
137159
});
138-
if (!newActiveId && firstId) {
160+
161+
// When scrolled to the bottom, use the last visible heading
162+
if (isAtBottom && lastVisibleId) {
163+
newActiveId = lastVisibleId;
164+
} else if (!newActiveId && firstId) {
139165
newActiveId = firstId;
140166
}
141167

packages/react/src/layout/sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, SidebarProps>(
2020
className,
2121
style,
2222
children,
23+
collapsed: _collapsed,
2324
prefixCls: customisedCls,
2425
...otherProps
2526
} = props;

0 commit comments

Comments
 (0)