From e258c76040f80d212eff2a98d9af2ed940c37312 Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Sat, 7 Nov 2026 00:39:55 -0400 Subject: [PATCH 1/3] feat(web): add Table of Contents --- .../components/web-demos/index.generated.tsx | 5 + .../table-of-contents/controlled.tsx | 79 +++ .../web-demos/table-of-contents/nested.tsx | 36 ++ .../web-demos/table-of-contents/overview.tsx | 71 +++ .../web-demos/table-of-contents/scrolling.tsx | 77 +++ .../web-demos/table-of-contents/thumb.tsx | 26 + .../content/docs/web/components/index.mdx | 2 +- .../content/docs/web/components/meta.json | 1 + .../docs/web/components/table-of-contents.mdx | 476 ++++++++++++++++++ apps/docs/public/r/web/registry.json | 23 + apps/docs/public/r/web/table-of-contents.json | 26 + .../public/skills/panelui-web/components.md | 1 + .../components/table-of-contents.md | 199 ++++++++ apps/docs/scripts/web-meta.json | 10 + .../scripts/web-usage/table-of-contents.json | 39 ++ .../components/table-of-contents/index.tsx | 455 +++++++++++++++++ .../src/components/table-of-contents/spy.ts | 61 +++ packages/web/src/index.ts | 12 + .../web/test/table-of-contents-spy.test.mjs | 54 ++ skills/panelui-web/components.md | 1 + .../components/table-of-contents.md | 199 ++++++++ 21 files changed, 1852 insertions(+), 1 deletion(-) create mode 100644 apps/docs/components/web-demos/table-of-contents/controlled.tsx create mode 100644 apps/docs/components/web-demos/table-of-contents/nested.tsx create mode 100644 apps/docs/components/web-demos/table-of-contents/overview.tsx create mode 100644 apps/docs/components/web-demos/table-of-contents/scrolling.tsx create mode 100644 apps/docs/components/web-demos/table-of-contents/thumb.tsx create mode 100644 apps/docs/content/docs/web/components/table-of-contents.mdx create mode 100644 apps/docs/public/r/web/table-of-contents.json create mode 100644 apps/docs/public/skills/panelui-web/components/table-of-contents.md create mode 100644 apps/docs/scripts/web-usage/table-of-contents.json create mode 100644 packages/web/src/components/table-of-contents/index.tsx create mode 100644 packages/web/src/components/table-of-contents/spy.ts create mode 100644 packages/web/test/table-of-contents-spy.test.mjs create mode 100644 skills/panelui-web/components/table-of-contents.md diff --git a/apps/docs/components/web-demos/index.generated.tsx b/apps/docs/components/web-demos/index.generated.tsx index 13a0e93e7..696f4aaa6 100644 --- a/apps/docs/components/web-demos/index.generated.tsx +++ b/apps/docs/components/web-demos/index.generated.tsx @@ -421,6 +421,11 @@ export const WEB_DEMOS: Record = { 'switch/pending': dynamic(() => import('./switch/pending')), 'switch/sizes': dynamic(() => import('./switch/sizes')), 'switch/thumb': dynamic(() => import('./switch/thumb')), + 'table-of-contents/controlled': dynamic(() => import('./table-of-contents/controlled')), + 'table-of-contents/nested': dynamic(() => import('./table-of-contents/nested')), + 'table-of-contents/overview': dynamic(() => import('./table-of-contents/overview')), + 'table-of-contents/scrolling': dynamic(() => import('./table-of-contents/scrolling')), + 'table-of-contents/thumb': dynamic(() => import('./table-of-contents/thumb')), 'table/dense': dynamic(() => import('./table/dense')), 'table/frame': dynamic(() => import('./table/frame')), 'table/overview': dynamic(() => import('./table/overview')), diff --git a/apps/docs/components/web-demos/table-of-contents/controlled.tsx b/apps/docs/components/web-demos/table-of-contents/controlled.tsx new file mode 100644 index 000000000..b20f2b549 --- /dev/null +++ b/apps/docs/components/web-demos/table-of-contents/controlled.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from 'panelui-web/button'; +import { TableOfContents } from 'panelui-web/table-of-contents'; + +const STEPS = [ + { + value: 'details', + label: 'Details', + heading: 'Order details', + text: 'Three items, placed on 2 November and shipping to 14 Corfield Road. The invoice is addressed to the card holder.', + }, + { + value: 'delivery', + label: 'Delivery', + heading: 'Delivery', + text: 'Standard delivery, three to five working days. The parcel is with the carrier and the tracking link is live.', + }, + { + value: 'payment', + label: 'Payment', + heading: 'Payment', + text: 'Paid in full with the card ending 4417. Nothing else is due, and the receipt is in the order email.', + }, +]; + +export default function TableOfContentsControlled() { + const [active, setActive] = useState('details'); + const at = Math.max( + 0, + STEPS.findIndex((step) => step.value === active) + ); + const section = STEPS[at]!; + + return ( +
+
+

Invoice 2041

+
+ + +
+
+ +
+ + On this page + + {STEPS.map((step) => ( + + {step.label} + + ))} + + + +
+

+ Step {at + 1} of {STEPS.length} +

+

+ {section.heading} +

+

{section.text}

+
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/table-of-contents/nested.tsx b/apps/docs/components/web-demos/table-of-contents/nested.tsx new file mode 100644 index 000000000..12e1e7390 --- /dev/null +++ b/apps/docs/components/web-demos/table-of-contents/nested.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { TableOfContents } from 'panelui-web/table-of-contents'; + +export default function TableOfContentsNested() { + return ( +
+ + On this page + + + Overview + + + Installation + + + From npm + + + By hand + + + Configuration + + + Presets + + + API reference + + + +
+ ); +} diff --git a/apps/docs/components/web-demos/table-of-contents/overview.tsx b/apps/docs/components/web-demos/table-of-contents/overview.tsx new file mode 100644 index 000000000..817fb296e --- /dev/null +++ b/apps/docs/components/web-demos/table-of-contents/overview.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useRef } from 'react'; +import { TableOfContents } from 'panelui-web/table-of-contents'; + +const SECTIONS = [ + { + value: 'introduction', + label: 'Introduction', + text: 'Orders leave the warehouse within two working days. What follows is how a parcel gets from there to a doorstep, and what to do when it does not arrive at all.', + }, + { + value: 'shipping', + label: 'Shipping', + text: 'Standard delivery is free over £40 and takes three to five working days. Express orders placed before noon are sent the same day, and a tracking link arrives by email as soon as the carrier has scanned the parcel.', + }, + { + value: 'returns', + label: 'Returns', + text: 'Anything unused can come back within thirty days. Print the label from the order page, put the item in its original box, and leave it at any drop-off point; the refund is issued when it reaches the warehouse.', + }, + { + value: 'exchanges', + label: 'Exchanges', + text: 'For a different size or colour, send the first item back and place a new order. It is quicker than a two-way exchange, and the new parcel is not held while the old one is in transit.', + }, + { + value: 'contact', + label: 'Contact', + text: 'The team answers from 09:00 to 18:00 on weekdays. Include the order number in the first message and the reply can be answered in one go rather than three.', + }, +]; + +export default function TableOfContentsOverview() { + const scroller = useRef(null); + + return ( +
+
+
+

Guide

+

Shipping and returns

+

+ Everything about getting an order to you, and getting it back to us. +

+ + {SECTIONS.map((section) => ( +
+

{section.label}

+

{section.text}

+
+ ))} +
+
+ + + On this page + + {SECTIONS.map((section) => ( + + {section.label} + + ))} + + +
+ ); +} diff --git a/apps/docs/components/web-demos/table-of-contents/scrolling.tsx b/apps/docs/components/web-demos/table-of-contents/scrolling.tsx new file mode 100644 index 000000000..7b9f5f634 --- /dev/null +++ b/apps/docs/components/web-demos/table-of-contents/scrolling.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useRef } from 'react'; +import { TableOfContents } from 'panelui-web/table-of-contents'; + +const SECTIONS = [ + { + value: 'highlights', + label: 'Highlights', + text: 'Charts can now stack a series by a second key, tooltips stay inside the plot at its edges, and a legend can be placed on any side. The theme gained a muted surface for panels that sit inside a card.', + }, + { + value: 'fixes', + label: 'Fixes', + text: 'A popover opened from a row no longer closes when the row is scrolled under the pointer. Menus keep their place after a resize, and the focus ring is drawn on the element that takes focus rather than the one that wraps it.', + }, + { + value: 'breaking', + label: 'Breaking changes', + text: 'The `tone` prop on Alert is now `variant`. The old name still works for this release and is removed in 3.0, with a warning in development. Nothing else changes shape.', + }, + { + value: 'upgrade', + label: 'Upgrade guide', + text: 'Upgrade the package, then run the codemod if the project uses `tone`. The theme file needs no edits: every token the release reads already exists in 2.3, and the new surface is an addition rather than a rename.', + }, + { + value: 'next', + label: 'What comes next', + text: 'Work has started on a table that sorts and filters without a data library, and on a command palette that can be opened from anywhere in an app. Both are in the next minor.', + }, +]; + +export default function TableOfContentsScrolling() { + const scroller = useRef(null); + + return ( +
+
+
+
+ Release notes + v2.4 +
+ +
+

What changed in 2.4

+

+ A small release: one new surface, a few fixes, and one rename that will be removed in 3.0. +

+ + {SECTIONS.map((section) => ( +
+

{section.label}

+

{section.text}

+
+ ))} +
+
+ + {/* The bar inside the scroller is why the reading line is pushed down: + without `offset`, a heading would count as read while it was still + behind the bar. */} + + On this page + + {SECTIONS.map((section) => ( + + {section.label} + + ))} + + +
+
+ ); +} diff --git a/apps/docs/components/web-demos/table-of-contents/thumb.tsx b/apps/docs/components/web-demos/table-of-contents/thumb.tsx new file mode 100644 index 000000000..e94f70db1 --- /dev/null +++ b/apps/docs/components/web-demos/table-of-contents/thumb.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { TableOfContents } from 'panelui-web/table-of-contents'; + +const SECTIONS = [ + { value: 'overview', label: 'Overview' }, + { value: 'start', label: 'Getting started' }, + { value: 'states', label: 'States' }, + { value: 'keyboard', label: 'Keyboard' }, + { value: 'api', label: 'API reference' }, +]; + +export default function TableOfContentsThumb() { + return ( + + On this page + + {SECTIONS.map((section) => ( + + {section.label} + + ))} + + + ); +} diff --git a/apps/docs/content/docs/web/components/index.mdx b/apps/docs/content/docs/web/components/index.mdx index 0867e3b9b..50fa1a717 100644 --- a/apps/docs/content/docs/web/components/index.mdx +++ b/apps/docs/content/docs/web/components/index.mdx @@ -1,6 +1,6 @@ --- title: Components -description: The 73 components in the web collection, each one running. +description: The 74 components in the web collection, each one running. full: true --- diff --git a/apps/docs/content/docs/web/components/meta.json b/apps/docs/content/docs/web/components/meta.json index e0772e97c..5b393ccb6 100644 --- a/apps/docs/content/docs/web/components/meta.json +++ b/apps/docs/content/docs/web/components/meta.json @@ -44,6 +44,7 @@ "stat", "switch", "table", + "table-of-contents", "tabs", "textarea", "tilt-card", diff --git a/apps/docs/content/docs/web/components/table-of-contents.mdx b/apps/docs/content/docs/web/components/table-of-contents.mdx new file mode 100644 index 000000000..8a4f8d34f --- /dev/null +++ b/apps/docs/content/docs/web/components/table-of-contents.mdx @@ -0,0 +1,476 @@ +--- +title: Table of Contents +description: The sections of a long page as a quiet rail, with one mark that moves to what you are reading. +status: new +--- + + + +```tsx lineNumbers +'use client'; + +import { useRef } from 'react'; +import { TableOfContents } from '@/components/panelui/table-of-contents'; + +const SECTIONS = [ + { + value: 'introduction', + label: 'Introduction', + text: 'Orders leave the warehouse within two working days. What follows is how a parcel gets from there to a doorstep, and what to do when it does not arrive at all.', + }, + { + value: 'shipping', + label: 'Shipping', + text: 'Standard delivery is free over £40 and takes three to five working days. Express orders placed before noon are sent the same day, and a tracking link arrives by email as soon as the carrier has scanned the parcel.', + }, + { + value: 'returns', + label: 'Returns', + text: 'Anything unused can come back within thirty days. Print the label from the order page, put the item in its original box, and leave it at any drop-off point; the refund is issued when it reaches the warehouse.', + }, + { + value: 'exchanges', + label: 'Exchanges', + text: 'For a different size or colour, send the first item back and place a new order. It is quicker than a two-way exchange, and the new parcel is not held while the old one is in transit.', + }, + { + value: 'contact', + label: 'Contact', + text: 'The team answers from 09:00 to 18:00 on weekdays. Include the order number in the first message and the reply can be answered in one go rather than three.', + }, +]; + +export default function TableOfContentsOverview() { + const scroller = useRef(null); + + return ( +
+
+
+

Guide

+

Shipping and returns

+

+ Everything about getting an order to you, and getting it back to us. +

+ + {SECTIONS.map((section) => ( +
+

{section.label}

+

{section.text}

+
+ ))} +
+
+ + + On this page + + {SECTIONS.map((section) => ( + + {section.label} + + ))} + + +
+ ); +} +``` + +
+ +Use it beside a long page that is read in sections: a documentation article, a settings screen, a statement of terms. It lists the headings once, quietly, and keeps one mark on the section at the top of the viewport, so a reader can see how far in they are and jump without losing their place. + +It is a navigation, not a printed contents table. Every row is a real link with a hash, so the URL follows the reader and a section can be sent to somebody else, and the current row is marked for a screen reader. Keep the labels to the page's own headings and the list to three levels: a rail of twenty equal rows stops saying where you are and becomes a second page. + +Scroll tracking is on by default. Pass `value` to hold the active section yourself, or a `root` when the page scrolls inside a panel rather than as a window. + +## Installation + + + + + + + +The source is copied into `components/panelui/`, where you can edit it. + + + + + + + +Import from `panelui-web/table-of-contents` instead of `@/components/panelui/table-of-contents`. Tailwind needs one line to read the package's classes, which is on the [installation page](/docs/web/installation#from-npm). + + + + + + + + + +Install the packages it uses: + + + + + + + +Copy the source into `components/panelui/table-of-contents/`, one file for each below: + + + + + + + + + + + +## Usage + +```tsx +import { TableOfContents } from "@/components/panelui/table-of-contents" +``` + +```tsx + + On this page + + + Install + + + Configure + + + API reference + + + +``` + +## Composition + +```tsx + + + + + + + + +``` + +- **`TableOfContents.Title`** — A small heading above the list, such as “On this page”. It is optional: the `