Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/table-of-contents-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@cloudflare/kumo": minor
---

Polish TableOfContents indicator and semantic HTML

- Replace pill/background-tint hover with left-border indicator pattern
- Switch to semantic `ul`/`li` HTML structure
- Add `href` and `active` props to `TableOfContents.Group` for clickable labels
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function TableOfContentsNoActiveDemo() {
);
}

/** Shows both group modes: clickable group labels (with `href`) and plain title labels (without `href`). */
export function TableOfContentsGroupDemo() {
return (
<DemoWrapper>
Expand All @@ -84,6 +85,14 @@ export function TableOfContentsGroupDemo() {
<TableOfContents.Item active className="cursor-pointer">
Overview
</TableOfContents.Item>
<TableOfContents.Group label="Examples" href="#examples-demo">
<TableOfContents.Item className="cursor-pointer">
Basic example
</TableOfContents.Item>
<TableOfContents.Item className="cursor-pointer">
Advanced example
</TableOfContents.Item>
</TableOfContents.Group>
<TableOfContents.Group label="Getting Started">
<TableOfContents.Item className="cursor-pointer">
Installation
Expand All @@ -92,7 +101,7 @@ export function TableOfContentsGroupDemo() {
Configuration
</TableOfContents.Item>
</TableOfContents.Group>
<TableOfContents.Group label="API">
<TableOfContents.Group label="API" href="#api-demo">
<TableOfContents.Item className="cursor-pointer">
Props
</TableOfContents.Item>
Expand Down
87 changes: 69 additions & 18 deletions packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export interface TocHeading {
text: string;
}

interface HeadingGroup {
h2: TocHeading;
h3s: TocHeading[];
}

interface TableOfContentsProps {
/** Static headings (MDX pages). Omit to scrape from the DOM (.astro pages). */
headings?: TocHeading[];
Expand All @@ -19,7 +24,7 @@ interface TableOfContentsProps {
}

/**
* Scrape h2 elements from the rendered `.kumo-prose` container.
* Scrape h2 and h3 elements from the rendered `.kumo-prose` container.
* Only runs client-side for .astro pages that don't pass headings statically.
*/
function scrapeHeadings(): TocHeading[] {
Expand All @@ -28,15 +33,31 @@ function scrapeHeadings(): TocHeading[] {
const content = document.querySelector(".kumo-prose");
if (!content) return [];

return Array.from(content.querySelectorAll("h2"))
return Array.from(content.querySelectorAll("h2, h3"))
.filter((el) => el.id)
.map((el) => ({
depth: 2,
depth: Number(el.tagName[1]),
slug: el.id,
text: el.textContent?.trim() ?? "",
}));
}

/**
* Group a flat list of headings into h2 → h3[] pairs for nested TOC rendering.
* h3 headings that appear before any h2 are dropped.
*/
function groupHeadings(headings: TocHeading[]): HeadingGroup[] {
const groups: HeadingGroup[] = [];
for (const heading of headings) {
if (heading.depth === 2) {
groups.push({ h2: heading, h3s: [] });
} else if (heading.depth === 3 && groups.length > 0) {
groups[groups.length - 1].h3s.push(heading);
}
}
return groups;
}

export function TableOfContents({
headings: headingsProp,
layout = "sidebar",
Expand All @@ -50,7 +71,7 @@ export function TableOfContents({

const headings = useMemo(() => {
if (headingsProp && headingsProp.length > 0) {
return headingsProp.filter((h) => h.depth <= 2);
return headingsProp.filter((h) => h.depth <= 3);
}
// Only scrape after mount to avoid hydration mismatch
if (!hasMounted) return [];
Expand Down Expand Up @@ -143,10 +164,16 @@ export function TableOfContents({
}}
className="w-full appearance-none rounded-lg border border-kumo-hairline bg-kumo-base px-4 py-2.5 pr-10 text-sm text-kumo-default"
>
{headings.map((heading) => (
<option key={heading.slug} value={heading.slug}>
{heading.text}
</option>
{groupHeadings(headings).map((group) => (
<optgroup key={group.h2.slug} label={group.h2.text}>
<option value={group.h2.slug}>{group.h2.text}</option>
{group.h3s.map((h3) => (
<option key={h3.slug} value={h3.slug}>
{" "}
{h3.text}
</option>
))}
</optgroup>
))}
</select>
<CaretDownIcon
Expand All @@ -163,16 +190,40 @@ export function TableOfContents({
<TOC>
<TOC.Title>On this page</TOC.Title>
<TOC.List ref={navRef}>
{headings.map((heading) => (
<TOC.Item
key={heading.slug}
href={`#${heading.slug}`}
active={activeId === heading.slug}
onClick={() => handleClick(heading.slug)}
>
{heading.text}
</TOC.Item>
))}
{groupHeadings(headings).map((group) => {
if (group.h3s.length === 0) {
return (
<TOC.Item
key={group.h2.slug}
href={`#${group.h2.slug}`}
active={activeId === group.h2.slug}
onClick={() => handleClick(group.h2.slug)}
>
{group.h2.text}
</TOC.Item>
);
}
return (
<TOC.Group
key={group.h2.slug}
label={group.h2.text}
href={`#${group.h2.slug}`}
active={activeId === group.h2.slug}
onClick={() => handleClick(group.h2.slug)}
>
{group.h3s.map((h3) => (
<TOC.Item
key={h3.slug}
href={`#${h3.slug}`}
active={activeId === h3.slug}
onClick={() => handleClick(h3.slug)}
>
{h3.text}
</TOC.Item>
))}
</TOC.Group>
);
})}
</TOC.List>
</TOC>
);
Expand Down
57 changes: 42 additions & 15 deletions packages/kumo-docs-astro/src/pages/components/table-of-contents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ export default function Example() {

<p>
Use `TableOfContents.Group` to organize items into labeled sections with
indented children.
indented children. Groups support two modes: pass an `href` to make the group
label a clickable link (like "Examples" and "API" below), or omit it for a
plain non-interactive title (like "Getting Started").
</p>
<ComponentExample demo="TableOfContentsGroupDemo">
<TableOfContentsGroupDemo client:visible />
Expand All @@ -133,33 +135,32 @@ export default function Example() {
<TableOfContentsRenderPropDemo client:load />
</ComponentExample>

#### React Router

```tsx
// React Router
<TableOfContents.Item render={<Link to="/intro" />} active>
Introduction
</TableOfContents.Item>

// Next.js
<TableOfContents.Item render={<Link href="/intro" />} active>
Introduction
</TableOfContents.Item>

// Button (no navigation)
<TableOfContents.Item render={<button type="button" />} onClick={handleClick}>
Introduction
</TableOfContents.Item>
```

#### With Next.js
#### Next.js

```tsx
import Link from "next/link";

<TableOfContents.Item render={<Link />} href="/intro" active>
<TableOfContents.Item render={<Link href="/intro" />} active>
Introduction
</TableOfContents.Item>;
```

#### Button (no navigation)

```tsx
<TableOfContents.Item render={<button type="button" />} onClick={handleClick}>
Introduction
</TableOfContents.Item>
```

</ComponentSection>

{/* API Reference */}
Expand All @@ -168,5 +169,31 @@ import Link from "next/link";

## API Reference

<PropsTable component="TableOfContents" />
### `TableOfContents`

<p>Root nav container with a default `aria-label` of "Table of contents".</p>
<PropsTable component="TableOfContents" />

### `TableOfContents.Title`

<p>Optional uppercase heading displayed above the list (renders a `<p>`).</p>
<PropsTable component="TableOfContents.Title" />

### `TableOfContents.List`

<p>List container with a left border rail.</p>
<PropsTable component="TableOfContents.List" />

### `TableOfContents.Item`

<p>
Individual navigation link. Set `active` for the current section. Use the
`render` prop to swap the anchor for a router link or button.
</p>
<PropsTable component="TableOfContents.Item" />

### `TableOfContents.Group`

<p>Groups items under a labeled section with indented children. Pass `href` to make the label a clickable link, or omit for a plain title.</p>
<PropsTable component="TableOfContents.Group" />
</ComponentSection>
Loading
Loading