Skip to content

Commit 58b5777

Browse files
authored
feat: table of contents component (#362)
1 parent e9a0845 commit 58b5777

File tree

12 files changed

+677
-41
lines changed

12 files changed

+677
-41
lines changed

.changeset/lazy-cycles-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": minor
3+
---
4+
5+
Convert Table of Contents to exported Kumo component

packages/kumo-docs-astro/src/components/SidebarNav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const componentItems: NavItem[] = [
7272
{ label: "Skeleton Line", href: "/components/skeleton-line" },
7373
{ label: "Switch", href: "/components/switch" },
7474
{ label: "Table", href: "/components/table" },
75+
{ label: "Table of Contents", href: "/components/table-of-contents" },
7576
{ label: "Tabs", href: "/components/tabs" },
7677
{ label: "Text", href: "/components/text" },
7778
{ label: "Toast", href: "/components/toast" },

packages/kumo-docs-astro/src/components/demos/HomeGrid.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
SkeletonLine,
3030
Switch,
3131
Table,
32+
TableOfContents,
3233
Tabs,
3334
Text,
3435
Toasty,
@@ -80,6 +81,7 @@ const componentRoutes: Record<string, string> = {
8081
"skeleton-line": "/components/skeleton-line",
8182
switch: "/components/switch",
8283
table: "/components/table",
84+
"table-of-contents": "/components/table-of-contents",
8385
tabs: "/components/tabs",
8486
text: "/components/text",
8587
toast: "/components/toast",
@@ -602,6 +604,20 @@ export function HomeGrid() {
602604
</Table>
603605
),
604606
},
607+
{
608+
name: "TableOfContents",
609+
id: "table-of-contents",
610+
Component: (
611+
<TableOfContents>
612+
<TableOfContents.Title>On this page</TableOfContents.Title>
613+
<TableOfContents.List>
614+
<TableOfContents.Item active>Introduction</TableOfContents.Item>
615+
<TableOfContents.Item>Installation</TableOfContents.Item>
616+
<TableOfContents.Item>Usage</TableOfContents.Item>
617+
</TableOfContents.List>
618+
</TableOfContents>
619+
),
620+
},
605621
{
606622
name: "Text",
607623
id: "text",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { useState } from "react";
2+
import { TableOfContents } from "@cloudflare/kumo";
3+
4+
const headings = [
5+
{ text: "Introduction" },
6+
{ text: "Installation" },
7+
{ text: "Usage" },
8+
{ text: "API Reference" },
9+
{ text: "Examples" },
10+
];
11+
12+
function DemoWrapper({ children }: { children: React.ReactNode }) {
13+
return <div className="min-w-48">{children}</div>;
14+
}
15+
16+
export function TableOfContentsBasicDemo() {
17+
return (
18+
<DemoWrapper>
19+
<TableOfContents>
20+
<TableOfContents.Title>On this page</TableOfContents.Title>
21+
<TableOfContents.List>
22+
{headings.map((heading) => (
23+
<TableOfContents.Item
24+
key={heading.text}
25+
active={heading.text === "Usage"}
26+
className="cursor-pointer"
27+
>
28+
{heading.text}
29+
</TableOfContents.Item>
30+
))}
31+
</TableOfContents.List>
32+
</TableOfContents>
33+
</DemoWrapper>
34+
);
35+
}
36+
37+
export function TableOfContentsInteractiveDemo() {
38+
const [active, setActive] = useState("Introduction");
39+
40+
return (
41+
<DemoWrapper>
42+
<TableOfContents>
43+
<TableOfContents.Title>On this page</TableOfContents.Title>
44+
<TableOfContents.List>
45+
{headings.map((heading) => (
46+
<TableOfContents.Item
47+
key={heading.text}
48+
active={heading.text === active}
49+
onClick={() => setActive(heading.text)}
50+
className="cursor-pointer"
51+
>
52+
{heading.text}
53+
</TableOfContents.Item>
54+
))}
55+
</TableOfContents.List>
56+
</TableOfContents>
57+
</DemoWrapper>
58+
);
59+
}
60+
61+
export function TableOfContentsNoActiveDemo() {
62+
return (
63+
<DemoWrapper>
64+
<TableOfContents>
65+
<TableOfContents.Title>On this page</TableOfContents.Title>
66+
<TableOfContents.List>
67+
{headings.map((heading) => (
68+
<TableOfContents.Item key={heading.text} className="cursor-pointer">
69+
{heading.text}
70+
</TableOfContents.Item>
71+
))}
72+
</TableOfContents.List>
73+
</TableOfContents>
74+
</DemoWrapper>
75+
);
76+
}
77+
78+
export function TableOfContentsGroupDemo() {
79+
return (
80+
<DemoWrapper>
81+
<TableOfContents>
82+
<TableOfContents.Title>On this page</TableOfContents.Title>
83+
<TableOfContents.List>
84+
<TableOfContents.Item active className="cursor-pointer">
85+
Overview
86+
</TableOfContents.Item>
87+
<TableOfContents.Group label="Getting Started">
88+
<TableOfContents.Item className="cursor-pointer">
89+
Installation
90+
</TableOfContents.Item>
91+
<TableOfContents.Item className="cursor-pointer">
92+
Configuration
93+
</TableOfContents.Item>
94+
</TableOfContents.Group>
95+
<TableOfContents.Group label="API">
96+
<TableOfContents.Item className="cursor-pointer">
97+
Props
98+
</TableOfContents.Item>
99+
<TableOfContents.Item className="cursor-pointer">
100+
Events
101+
</TableOfContents.Item>
102+
</TableOfContents.Group>
103+
</TableOfContents.List>
104+
</TableOfContents>
105+
</DemoWrapper>
106+
);
107+
}
108+
109+
export function TableOfContentsWithoutTitleDemo() {
110+
return (
111+
<DemoWrapper>
112+
<TableOfContents>
113+
<TableOfContents.List>
114+
{headings.slice(0, 3).map((heading) => (
115+
<TableOfContents.Item
116+
key={heading.text}
117+
active={heading.text === "Introduction"}
118+
className="cursor-pointer"
119+
>
120+
{heading.text}
121+
</TableOfContents.Item>
122+
))}
123+
</TableOfContents.List>
124+
</TableOfContents>
125+
</DemoWrapper>
126+
);
127+
}
128+
129+
/** Demonstrates using the `render` prop with a custom link component. */
130+
export function TableOfContentsRenderPropDemo() {
131+
const [clicked, setClicked] = useState<string | null>(null);
132+
133+
return (
134+
<DemoWrapper>
135+
<div className="space-y-3">
136+
<TableOfContents>
137+
<TableOfContents.List>
138+
{["Introduction", "Installation", "Usage"].map((text) => (
139+
<TableOfContents.Item
140+
key={text}
141+
render={<button type="button" />}
142+
onClick={() => setClicked(text)}
143+
active={text === "Introduction"}
144+
>
145+
{text}
146+
</TableOfContents.Item>
147+
))}
148+
</TableOfContents.List>
149+
</TableOfContents>
150+
{clicked && (
151+
<p className="text-xs text-kumo-subtle">Clicked: {clicked}</p>
152+
)}
153+
</div>
154+
</DemoWrapper>
155+
);
156+
}

packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2-
import { cn } from "@cloudflare/kumo";
3-
import { CaretDown } from "@phosphor-icons/react";
2+
import { TableOfContents as TOC } from "@cloudflare/kumo";
3+
import { CaretDownIcon } from "@phosphor-icons/react";
44

55
export interface TocHeading {
66
depth: number;
@@ -149,7 +149,7 @@ export function TableOfContents({
149149
</option>
150150
))}
151151
</select>
152-
<CaretDown
152+
<CaretDownIcon
153153
size={16}
154154
weight="bold"
155155
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-kumo-subtle"
@@ -160,43 +160,20 @@ export function TableOfContents({
160160

161161
// Sidebar layout (default)
162162
return (
163-
<section>
164-
<p className="mb-3 text-xs font-semibold tracking-wide text-kumo-subtle uppercase">
165-
On this page
166-
</p>
167-
<nav
168-
aria-label="Table of contents"
169-
className="relative space-y-1.5 before:absolute before:inset-y-0 before:left-0.5 before:w-px before:bg-kumo-hairline"
170-
ref={navRef}
171-
>
172-
{headings.map((heading) => {
173-
const isActive = activeId === heading.slug;
174-
return (
175-
<a
176-
key={heading.slug}
177-
href={`#${heading.slug}`}
178-
onClick={() => handleClick(heading.slug)}
179-
className={cn(
180-
"group relative block truncate rounded-md py-1 pl-5 text-sm no-underline transition-all duration-500",
181-
isActive
182-
? "text-kumo-default font-medium"
183-
: "text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default hover:font-medium",
184-
)}
185-
>
186-
<span
187-
aria-hidden="true"
188-
className={cn(
189-
"absolute inset-y-0 left-0.5 w-0.5 rounded-full transition-all duration-200",
190-
isActive
191-
? "bg-kumo-brand opacity-100"
192-
: "bg-kumo-brand opacity-0 group-hover:opacity-60",
193-
)}
194-
/>
195-
<span className="block min-w-0 leading-5">{heading.text}</span>
196-
</a>
197-
);
198-
})}
199-
</nav>
200-
</section>
163+
<TOC>
164+
<TOC.Title>On this page</TOC.Title>
165+
<TOC.List ref={navRef}>
166+
{headings.map((heading) => (
167+
<TOC.Item
168+
key={heading.slug}
169+
href={`#${heading.slug}`}
170+
active={activeId === heading.slug}
171+
onClick={() => handleClick(heading.slug)}
172+
>
173+
{heading.text}
174+
</TOC.Item>
175+
))}
176+
</TOC.List>
177+
</TOC>
201178
);
202179
}

0 commit comments

Comments
 (0)