diff --git a/.changeset/table-of-contents-polish.md b/.changeset/table-of-contents-polish.md new file mode 100644 index 0000000000..12e31a7ca9 --- /dev/null +++ b/.changeset/table-of-contents-polish.md @@ -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 diff --git a/packages/kumo-docs-astro/src/components/demos/TableOfContentsDemo.tsx b/packages/kumo-docs-astro/src/components/demos/TableOfContentsDemo.tsx index 9e08352258..65ab12d120 100644 --- a/packages/kumo-docs-astro/src/components/demos/TableOfContentsDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/TableOfContentsDemo.tsx @@ -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 ( @@ -84,6 +85,14 @@ export function TableOfContentsGroupDemo() { Overview + + + Basic example + + + Advanced example + + Installation @@ -92,7 +101,7 @@ export function TableOfContentsGroupDemo() { Configuration - + Props diff --git a/packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx b/packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx index 7874e1a88c..a0e90a9c9c 100644 --- a/packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx +++ b/packages/kumo-docs-astro/src/components/docs/TableOfContents.tsx @@ -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[]; @@ -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[] { @@ -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", @@ -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 []; @@ -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) => ( - + {groupHeadings(headings).map((group) => ( + + + {group.h3s.map((h3) => ( + + ))} + ))} On this page - {headings.map((heading) => ( - handleClick(heading.slug)} - > - {heading.text} - - ))} + {groupHeadings(headings).map((group) => { + if (group.h3s.length === 0) { + return ( + handleClick(group.h2.slug)} + > + {group.h2.text} + + ); + } + return ( + handleClick(group.h2.slug)} + > + {group.h3s.map((h3) => ( + handleClick(h3.slug)} + > + {h3.text} + + ))} + + ); + })} ); diff --git a/packages/kumo-docs-astro/src/pages/components/table-of-contents.mdx b/packages/kumo-docs-astro/src/pages/components/table-of-contents.mdx index 71318c8965..fffcc66588 100644 --- a/packages/kumo-docs-astro/src/pages/components/table-of-contents.mdx +++ b/packages/kumo-docs-astro/src/pages/components/table-of-contents.mdx @@ -107,7 +107,9 @@ export default function Example() {

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").

@@ -133,33 +135,32 @@ export default function Example() { +#### React Router + ```tsx -// React Router } active> Introduction - -// Next.js -} active> - Introduction - - -// Button (no navigation) -} onClick={handleClick}> - Introduction - ``` -#### With Next.js +#### Next.js ```tsx import Link from "next/link"; -} href="/intro" active> +} active> Introduction ; ``` +#### Button (no navigation) + +```tsx +} onClick={handleClick}> + Introduction + +``` + {/* API Reference */} @@ -168,5 +169,31 @@ import Link from "next/link"; ## API Reference - +### `TableOfContents` + +

Root nav container with a default `aria-label` of "Table of contents".

+ + +### `TableOfContents.Title` + +

Optional uppercase heading displayed above the list (renders a `

`).

+ + +### `TableOfContents.List` + +

List container with a left border rail.

+ + +### `TableOfContents.Item` + +

+ Individual navigation link. Set `active` for the current section. Use the + `render` prop to swap the anchor for a router link or button. +

+ + +### `TableOfContents.Group` + +

Groups items under a labeled section with indented children. Pass `href` to make the label a clickable link, or omit for a plain title.

+ diff --git a/packages/kumo/scripts/component-registry/index.test.ts b/packages/kumo/scripts/component-registry/index.test.ts index 5aa37cea87..553b191dc4 100644 --- a/packages/kumo/scripts/component-registry/index.test.ts +++ b/packages/kumo/scripts/component-registry/index.test.ts @@ -678,3 +678,268 @@ describe("jsonSchemaTypeToString", () => { ); }); }); + +// Tests for sub-component detection utilities + +import { writeFileSync, unlinkSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + detectSubComponents, + extractSubComponentProps, + extractPropsFromInterface, +} from "./sub-components.js"; + +function writeTempFile(content: string): { + filePath: string; + cleanup: () => void; +} { + const dir = mkdtempSync(join(tmpdir(), "kumo-test-")); + const filePath = join(dir, "test.tsx"); + writeFileSync(filePath, content); + return { filePath, cleanup: () => unlinkSync(filePath) }; +} + +const cliFlags = { + includeInheritedProps: false, + noCache: true, + verbose: false, +}; + +describe("detectSubComponents", () => { + it("detects sub-components from Object.assign with function declarations", () => { + const { filePath, cleanup } = writeTempFile(` +function GroupComponent({ legend, children }: GroupProps) { + return
{children}
; +} + +const Root = () =>
; + +export const MyComponent = Object.assign(Root, { + Group: GroupComponent, +}); +`); + + try { + const result = detectSubComponents(filePath); + expect(result).toHaveLength(1); + expect(result[0].name).toBe("Group"); + expect(result[0].valueName).toBe("GroupComponent"); + expect(result[0].propsType).toBe("GroupProps"); + } finally { + cleanup(); + } + }); + + it("detects sub-components from Object.assign with multi-line forwardRef", () => { + const { filePath, cleanup } = writeTempFile(` +import { forwardRef } from "react"; + +export interface MyItemProps { + active?: boolean; + label: string; +} + +const MyItem = forwardRef< + HTMLLIElement, + MyItemProps +>(({ active, label, ...props }, ref) => ( +
  • {label}
  • +)); + +const Root = forwardRef( + (props, ref) =>