Skip to content

Commit bd85e46

Browse files
committed
Add collapsible TOC with toggle and styles
Introduce a collapsible table-of-contents feature. Added a TOCContext (TOCProvider and useTOCCollapsed) to manage collapsed state, a new DocItem/Layout that wraps content with the provider and adjusts main/TOC column widths when collapsed, and a custom TOC theme component with a left-edge toggle and a Copy link button that copies the current URL to clipboard. New CSS modules provide responsive layout, animations for collapsing/expanding, and styling for the TOC panel and toggle. Files added: src/contexts/TOCContext.tsx, src/theme/DocItem/Layout/*, src/theme/TOC/*.
1 parent dd43c7c commit bd85e46

File tree

5 files changed

+344
-0
lines changed

5 files changed

+344
-0
lines changed

src/contexts/TOCContext.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React, { createContext, useContext, useState, useMemo, type ReactNode } from "react";
2+
3+
type TOCContextValue = {
4+
collapsed: boolean;
5+
setCollapsed: (v: boolean | ((prev: boolean) => boolean)) => void;
6+
};
7+
8+
const TOCContext = createContext<TOCContextValue>({
9+
collapsed: false,
10+
setCollapsed: () => {},
11+
});
12+
13+
export function TOCProvider({ children }: { children: ReactNode }) {
14+
const [collapsed, setCollapsed] = useState(false);
15+
const value = useMemo(() => ({ collapsed, setCollapsed }), [collapsed]);
16+
return <TOCContext.Provider value={value}>{children}</TOCContext.Provider>;
17+
}
18+
19+
export function useTOCCollapsed() {
20+
return useContext(TOCContext);
21+
}

src/theme/DocItem/Layout/index.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, { type ReactNode } from "react";
2+
import clsx from "clsx";
3+
import { useWindowSize } from "@docusaurus/theme-common";
4+
import { useDoc } from "@docusaurus/plugin-content-docs/client";
5+
import DocItemPaginator from "@theme/DocItem/Paginator";
6+
import DocVersionBanner from "@theme/DocVersionBanner";
7+
import DocVersionBadge from "@theme/DocVersionBadge";
8+
import DocItemFooter from "@theme/DocItem/Footer";
9+
import DocItemTOCMobile from "@theme/DocItem/TOC/Mobile";
10+
import DocItemTOCDesktop from "@theme/DocItem/TOC/Desktop";
11+
import DocItemContent from "@theme/DocItem/Content";
12+
import DocBreadcrumbs from "@theme/DocBreadcrumbs";
13+
import ContentVisibility from "@theme/ContentVisibility";
14+
import type { Props } from "@theme/DocItem/Layout";
15+
import { TOCProvider, useTOCCollapsed } from "@site/src/contexts/TOCContext";
16+
17+
import styles from "./styles.module.css";
18+
19+
function useDocTOC() {
20+
const { frontMatter, toc } = useDoc();
21+
const windowSize = useWindowSize();
22+
23+
const hidden = frontMatter.hide_table_of_contents;
24+
const canRender = !hidden && toc.length > 0;
25+
26+
const mobile = canRender ? <DocItemTOCMobile /> : undefined;
27+
28+
const desktop =
29+
canRender && (windowSize === "desktop" || windowSize === "ssr") ? (
30+
<DocItemTOCDesktop />
31+
) : undefined;
32+
33+
return { hidden, mobile, desktop };
34+
}
35+
36+
function DocItemLayoutInner({ children }: Props): ReactNode {
37+
const docTOC = useDocTOC();
38+
const { metadata } = useDoc();
39+
const { collapsed } = useTOCCollapsed();
40+
41+
return (
42+
<div className="row">
43+
<div
44+
className={clsx(
45+
"col",
46+
!docTOC.hidden && !collapsed && styles.docItemCol
47+
)}
48+
>
49+
<ContentVisibility metadata={metadata} />
50+
<DocVersionBanner />
51+
<div className={styles.docItemContainer}>
52+
<article>
53+
<DocBreadcrumbs />
54+
<DocVersionBadge />
55+
{docTOC.mobile}
56+
<DocItemContent>{children}</DocItemContent>
57+
<DocItemFooter />
58+
</article>
59+
<DocItemPaginator />
60+
</div>
61+
</div>
62+
{docTOC.desktop && (
63+
<div
64+
className={clsx(
65+
"col",
66+
collapsed ? styles.tocColCollapsed : "col--3"
67+
)}
68+
>
69+
{docTOC.desktop}
70+
</div>
71+
)}
72+
</div>
73+
);
74+
}
75+
76+
export default function DocItemLayout({ children }: Props): ReactNode {
77+
return (
78+
<TOCProvider>
79+
<DocItemLayoutInner>{children}</DocItemLayoutInner>
80+
</TOCProvider>
81+
);
82+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.docItemContainer header + *,
2+
.docItemContainer article > *:first-child {
3+
margin-top: 0;
4+
}
5+
6+
@media (min-width: 997px) {
7+
.docItemCol {
8+
max-width: 75% !important;
9+
}
10+
}
11+
12+
/* Collapsed TOC column: shrink to just the toggle button width */
13+
.tocColCollapsed {
14+
flex: 0 0 auto !important;
15+
max-width: fit-content !important;
16+
width: auto !important;
17+
transition: flex 300ms ease, max-width 300ms ease;
18+
}

src/theme/TOC/index.tsx

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React, { type ReactNode, useState, useCallback } from "react";
2+
import clsx from "clsx";
3+
import TOCItems from "@theme/TOCItems";
4+
import type { Props } from "@theme/TOC";
5+
import { useTOCCollapsed } from "@site/src/contexts/TOCContext";
6+
7+
import styles from "./styles.module.css";
8+
9+
const LINK_CLASS_NAME = "table-of-contents__link toc-highlight";
10+
const LINK_ACTIVE_CLASS_NAME = "table-of-contents__link--active";
11+
12+
function CopyLinkButton() {
13+
const [copied, setCopied] = useState(false);
14+
15+
const handleCopy = useCallback(() => {
16+
navigator.clipboard.writeText(window.location.href).then(() => {
17+
setCopied(true);
18+
setTimeout(() => setCopied(false), 2000);
19+
});
20+
}, []);
21+
22+
return (
23+
<button
24+
type="button"
25+
className={styles.copyLinkButton}
26+
onClick={handleCopy}
27+
title="Copy link"
28+
>
29+
{copied ? (
30+
<svg
31+
width="14"
32+
height="14"
33+
viewBox="0 0 24 24"
34+
fill="none"
35+
stroke="currentColor"
36+
strokeWidth="2"
37+
strokeLinecap="round"
38+
strokeLinejoin="round"
39+
>
40+
<polyline points="20 6 9 17 4 12" />
41+
</svg>
42+
) : (
43+
<svg
44+
width="14"
45+
height="14"
46+
viewBox="0 0 24 24"
47+
fill="none"
48+
stroke="currentColor"
49+
strokeWidth="2"
50+
strokeLinecap="round"
51+
strokeLinejoin="round"
52+
>
53+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
54+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
55+
</svg>
56+
)}
57+
{copied ? "Copied!" : "Copy link"}
58+
</button>
59+
);
60+
}
61+
62+
function ToggleArrow({ collapsed }: Readonly<{ collapsed: boolean }>) {
63+
return (
64+
<svg
65+
className={styles.toggleArrow}
66+
width="16"
67+
height="16"
68+
viewBox="0 0 24 24"
69+
fill="none"
70+
stroke="currentColor"
71+
strokeWidth="2"
72+
strokeLinecap="round"
73+
strokeLinejoin="round"
74+
>
75+
{collapsed ? (
76+
/* arrow pointing left = click to expand (show panel) */
77+
<polyline points="15 18 9 12 15 6" />
78+
) : (
79+
/* arrow pointing right = click to collapse (hide panel) */
80+
<polyline points="9 18 15 12 9 6" />
81+
)}
82+
</svg>
83+
);
84+
}
85+
86+
export default function TOC({ className, ...props }: Props): ReactNode {
87+
const { collapsed, setCollapsed } = useTOCCollapsed();
88+
89+
return (
90+
<div className={clsx(styles.tocWrapper, collapsed && styles.tocWrapperCollapsed)}>
91+
{/* Toggle button — always visible on the left edge */}
92+
<button
93+
type="button"
94+
className={styles.tocCollapseBtn}
95+
onClick={() => setCollapsed((v) => !v)}
96+
aria-expanded={!collapsed}
97+
aria-label={collapsed ? "Show table of contents" : "Hide table of contents"}
98+
title={collapsed ? "Show table of contents" : "Hide table of contents"}
99+
>
100+
<ToggleArrow collapsed={collapsed} />
101+
</button>
102+
103+
{/* Panel content — slides out to the right when collapsed */}
104+
<div className={clsx(styles.tocPanel, collapsed && styles.tocPanelCollapsed)}>
105+
<div
106+
className={clsx(styles.tableOfContents, "thin-scrollbar", className)}
107+
>
108+
<div className={styles.tocHeader}>
109+
<span className={styles.tocLabel}>ON THIS PAGE</span>
110+
<CopyLinkButton />
111+
</div>
112+
113+
<TOCItems
114+
{...props}
115+
linkClassName={LINK_CLASS_NAME}
116+
linkActiveClassName={LINK_ACTIVE_CLASS_NAME}
117+
/>
118+
</div>
119+
</div>
120+
</div>
121+
);
122+
}

src/theme/TOC/styles.module.css

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* ── Outer wrapper: holds toggle + panel side by side ── */
2+
.tocWrapper {
3+
position: sticky;
4+
top: calc(var(--ifm-navbar-height) + 1rem);
5+
max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem));
6+
display: flex;
7+
align-items: flex-start;
8+
}
9+
10+
/* ── Toggle button on the left edge ── */
11+
.tocCollapseBtn {
12+
flex-shrink: 0;
13+
display: flex;
14+
align-items: center;
15+
justify-content: center;
16+
width: 28px;
17+
height: 28px;
18+
border: 1px solid var(--ifm-toc-border-color);
19+
border-radius: 0.375rem;
20+
background: var(--ifm-background-surface-color, var(--ifm-background-color));
21+
cursor: pointer;
22+
color: var(--ifm-color-emphasis-600);
23+
transition: color 150ms ease, border-color 150ms ease;
24+
}
25+
26+
.tocCollapseBtn:hover {
27+
color: var(--ifm-color-primary);
28+
border-color: var(--ifm-color-primary);
29+
}
30+
31+
.toggleArrow {
32+
display: block;
33+
}
34+
35+
/* ── Sliding panel ── */
36+
.tocPanel {
37+
overflow: hidden;
38+
transition: max-width 300ms ease, opacity 250ms ease, margin-left 300ms ease;
39+
max-width: 260px;
40+
opacity: 1;
41+
margin-left: 0.5rem;
42+
}
43+
44+
.tocPanelCollapsed {
45+
max-width: 0;
46+
opacity: 0;
47+
margin-left: 0;
48+
}
49+
50+
/* ── Inner scrollable area ── */
51+
.tableOfContents {
52+
max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem));
53+
overflow-y: auto;
54+
width: 260px;
55+
}
56+
57+
/* ── Header row ── */
58+
.tocHeader {
59+
display: flex;
60+
align-items: center;
61+
justify-content: space-between;
62+
margin-bottom: 0.5rem;
63+
padding-bottom: 0.5rem;
64+
border-bottom: 1px solid var(--ifm-toc-border-color);
65+
}
66+
67+
.tocLabel {
68+
font-size: 0.7rem;
69+
font-weight: 700;
70+
letter-spacing: 0.08em;
71+
text-transform: uppercase;
72+
color: var(--ifm-color-primary);
73+
}
74+
75+
/* ── Copy link button ── */
76+
.copyLinkButton {
77+
display: inline-flex;
78+
align-items: center;
79+
gap: 0.3rem;
80+
background: none;
81+
border: none;
82+
padding: 0.2rem 0.4rem;
83+
border-radius: 0.375rem;
84+
cursor: pointer;
85+
font-size: 0.72rem;
86+
font-weight: 500;
87+
color: var(--ifm-color-emphasis-600);
88+
transition: background 150ms ease, color 150ms ease;
89+
white-space: nowrap;
90+
}
91+
92+
.copyLinkButton:hover {
93+
background: var(--ifm-color-emphasis-100);
94+
color: var(--ifm-color-primary);
95+
}
96+
97+
@media (max-width: 996px) {
98+
.tocWrapper {
99+
display: none;
100+
}
101+
}

0 commit comments

Comments
 (0)