@duro-app/ui 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/{Table-Cm5cCnZD.js → Table-DYN-bp0e.js} +388 -387
  2. package/dist/Table-DYN-bp0e.js.map +1 -0
  3. package/dist/components/Breadcrumb/Breadcrumb.d.ts +37 -0
  4. package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
  5. package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts +3 -0
  6. package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts.map +1 -0
  7. package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts +18 -0
  8. package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts.map +1 -0
  9. package/dist/components/Breadcrumb/styles.css.d.ts +46 -0
  10. package/dist/components/Breadcrumb/styles.css.d.ts.map +1 -0
  11. package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
  12. package/dist/components/LinkButton/LinkButton.d.ts +8 -1
  13. package/dist/components/LinkButton/LinkButton.d.ts.map +1 -1
  14. package/dist/components/LinkButton/LinkButton.stories.d.ts +9 -0
  15. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  16. package/dist/components/PageNav/PageNav.d.ts +30 -0
  17. package/dist/components/PageNav/PageNav.d.ts.map +1 -0
  18. package/dist/components/PageNav/PageNav.meta.d.ts +3 -0
  19. package/dist/components/PageNav/PageNav.meta.d.ts.map +1 -0
  20. package/dist/components/PageNav/PageNav.stories.d.ts +16 -0
  21. package/dist/components/PageNav/PageNav.stories.d.ts.map +1 -0
  22. package/dist/components/PageNav/styles.css.d.ts +51 -0
  23. package/dist/components/PageNav/styles.css.d.ts.map +1 -0
  24. package/dist/components/TableOfContents/TableOfContents.d.ts +53 -0
  25. package/dist/components/TableOfContents/TableOfContents.d.ts.map +1 -0
  26. package/dist/components/TableOfContents/TableOfContents.meta.d.ts +3 -0
  27. package/dist/components/TableOfContents/TableOfContents.meta.d.ts.map +1 -0
  28. package/dist/components/TableOfContents/TableOfContents.stories.d.ts +21 -0
  29. package/dist/components/TableOfContents/TableOfContents.stories.d.ts.map +1 -0
  30. package/dist/components/TableOfContents/TableOfContentsContext.d.ts +11 -0
  31. package/dist/components/TableOfContents/TableOfContentsContext.d.ts.map +1 -0
  32. package/dist/components/TableOfContents/styles.css.d.ts +114 -0
  33. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -0
  34. package/dist/components/TextLink/TextLink.d.ts +8 -1
  35. package/dist/components/TextLink/TextLink.d.ts.map +1 -1
  36. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  37. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -1
  38. package/dist/components/Tree/Tree.d.ts +13 -1
  39. package/dist/components/Tree/Tree.d.ts.map +1 -1
  40. package/dist/components/Tree/Tree.meta.d.ts.map +1 -1
  41. package/dist/components/Tree/Tree.stories.d.ts +9 -0
  42. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  43. package/dist/components/Tree/styles.css.d.ts +5 -0
  44. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  45. package/dist/docs/clickLink.d.ts +4 -0
  46. package/dist/docs/clickLink.d.ts.map +1 -0
  47. package/dist/docs/coarsePointer.d.ts +15 -0
  48. package/dist/docs/coarsePointer.d.ts.map +1 -0
  49. package/dist/docs/themedSurface.d.ts +8 -0
  50. package/dist/docs/themedSurface.d.ts.map +1 -0
  51. package/dist/index.css +1 -1
  52. package/dist/index.d.ts +4 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2945 -2484
  55. package/dist/index.js.map +1 -1
  56. package/dist/shared/navigate.d.ts +25 -0
  57. package/dist/shared/navigate.d.ts.map +1 -0
  58. package/dist/table.js +1 -1
  59. package/package.json +2 -2
  60. package/src/components/Breadcrumb/Breadcrumb.meta.ts +37 -0
  61. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +110 -0
  62. package/src/components/Breadcrumb/Breadcrumb.tsx +95 -0
  63. package/src/components/Breadcrumb/styles.css.ts +76 -0
  64. package/src/components/Inline/Inline.meta.ts +2 -1
  65. package/src/components/LinkButton/LinkButton.meta.ts +1 -1
  66. package/src/components/LinkButton/LinkButton.stories.tsx +42 -1
  67. package/src/components/LinkButton/LinkButton.tsx +9 -0
  68. package/src/components/PageNav/PageNav.meta.ts +35 -0
  69. package/src/components/PageNav/PageNav.stories.tsx +146 -0
  70. package/src/components/PageNav/PageNav.tsx +93 -0
  71. package/src/components/PageNav/styles.css.ts +100 -0
  72. package/src/components/TableOfContents/TableOfContents.meta.ts +41 -0
  73. package/src/components/TableOfContents/TableOfContents.stories.tsx +222 -0
  74. package/src/components/TableOfContents/TableOfContents.tsx +173 -0
  75. package/src/components/TableOfContents/TableOfContentsContext.ts +20 -0
  76. package/src/components/TableOfContents/styles.css.ts +147 -0
  77. package/src/components/TextLink/TextLink.meta.ts +1 -1
  78. package/src/components/TextLink/TextLink.stories.tsx +42 -1
  79. package/src/components/TextLink/TextLink.tsx +9 -0
  80. package/src/components/Tree/Tree.meta.ts +2 -1
  81. package/src/components/Tree/Tree.stories.tsx +93 -1
  82. package/src/components/Tree/Tree.tsx +69 -27
  83. package/src/components/Tree/styles.css.ts +6 -0
  84. package/src/docs/clickLink.ts +24 -0
  85. package/src/docs/coarsePointer.ts +42 -0
  86. package/src/docs/themedSurface.tsx +23 -0
  87. package/src/index.ts +9 -0
  88. package/src/shared/linkParts.typecheck.tsx +47 -0
  89. package/src/shared/navigate.ts +62 -0
  90. package/dist/Table-Cm5cCnZD.js.map +0 -1
  91. package/dist/shared/length.typecheck.d.ts +0 -31
  92. package/dist/shared/length.typecheck.d.ts.map +0 -1
@@ -0,0 +1,93 @@
1
+ import {createContext, type ReactNode, useContext} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
5
+
6
+ /* PageNav — the previous and next pages in reading order, at the foot of a
7
+ * page: a labelled `nav` holding up to two link cards. Each card shows a small
8
+ * direction label ("Previous", from your text catalog) over the page title.
9
+ * Prev holds the left slot and Next the right one even when the other is
10
+ * missing, so Next stays right-aligned on the last-but-one page too; on a
11
+ * narrow container (below the `xs` breakpoint) the two stack.
12
+ *
13
+ * Not `Pagination` (numbered result pages, in `@duro-app/ui/table`). */
14
+
15
+ // Prev and Next place themselves in Root's two-column grid.
16
+ const PageNavContext = createContext(false)
17
+
18
+ function usePageNav() {
19
+ if (!useContext(PageNavContext))
20
+ throw new Error('PageNav compound components must be used within PageNav.Root')
21
+ }
22
+
23
+ // --- Root ---
24
+
25
+ interface RootProps {
26
+ /** Names the landmark ("Pages"); required, from your text catalog. */
27
+ 'aria-label': string
28
+ /** A `PageNav.Prev` and/or a `PageNav.Next`. */
29
+ children: ReactNode
30
+ }
31
+
32
+ function Root({'aria-label': ariaLabel, children}: RootProps) {
33
+ return (
34
+ <PageNavContext.Provider value={true}>
35
+ <html.nav aria-label={ariaLabel} style={styles.root}>
36
+ <html.div style={styles.grid}>{children}</html.div>
37
+ </html.nav>
38
+ </PageNavContext.Provider>
39
+ )
40
+ }
41
+
42
+ // --- Prev / Next ---
43
+
44
+ interface LinkProps {
45
+ href: string
46
+ /** The page's title. */
47
+ title: ReactNode
48
+ /** The direction label above the title ("Previous", "Next"), from your text catalog. */
49
+ label: ReactNode
50
+ /**
51
+ * Client-side navigation: called for a plain primary click (no modifier
52
+ * key). Call `event.preventDefault()`, then your router's navigate.
53
+ */
54
+ onNavigate?: OnNavigate
55
+ }
56
+
57
+ function Prev({href, title, label, onNavigate}: LinkProps) {
58
+ usePageNav()
59
+ return (
60
+ <html.a
61
+ href={href}
62
+ onClick={linkClickHandler(href, onNavigate)}
63
+ style={[styles.card, styles.prev]}
64
+ >
65
+ <html.span style={styles.label}>
66
+ <html.span aria-hidden>←</html.span> {label}
67
+ </html.span>
68
+ <html.span style={styles.title}>{title}</html.span>
69
+ </html.a>
70
+ )
71
+ }
72
+
73
+ function Next({href, title, label, onNavigate}: LinkProps) {
74
+ usePageNav()
75
+ return (
76
+ <html.a
77
+ href={href}
78
+ onClick={linkClickHandler(href, onNavigate)}
79
+ style={[styles.card, styles.next]}
80
+ >
81
+ <html.span style={styles.label}>
82
+ {label} <html.span aria-hidden>→</html.span>
83
+ </html.span>
84
+ <html.span style={styles.title}>{title}</html.span>
85
+ </html.a>
86
+ )
87
+ }
88
+
89
+ export const PageNav = {
90
+ Root,
91
+ Prev,
92
+ Next,
93
+ }
@@ -0,0 +1,100 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+ import {breakpoints} from '@duro-app/tokens/tokens/breakpoints.css'
7
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
8
+
9
+ // Below this container width the two cards stack (Mobile.dc.html); above it
10
+ // they sit side by side, each in its own half (Reader.dc.html).
11
+ const STACK_BP = breakpoints.xs
12
+ const STACKED = `@container (max-width: ${STACK_BP})`
13
+
14
+ // The picked docs browser (docs/mockups/docs-nav/Reader.dc.html and
15
+ // Mobile.dc.html, `.pn .card`): two interactive cards, a small muted
16
+ // direction label over the title, Next aligned right.
17
+ export const styles = css.create({
18
+ // hosts the container query: a container cannot query itself
19
+ root: {
20
+ containerType: 'inline-size',
21
+ minWidth: 0,
22
+ },
23
+ grid: {
24
+ display: 'grid',
25
+ gridTemplateColumns: {
26
+ default: 'minmax(0, 1fr) minmax(0, 1fr)',
27
+ [STACKED]: 'minmax(0, 1fr)',
28
+ },
29
+ gap: spacing.md,
30
+ },
31
+ card: {
32
+ display: 'flex',
33
+ flexDirection: 'column',
34
+ padding: spacing.md,
35
+ fontFamily: typography.fontFamily,
36
+ color: colors.text,
37
+ textDecorationLine: 'none',
38
+ backgroundColor: {
39
+ default: colors.bgCard,
40
+ ':hover': colors.bgCardHover,
41
+ },
42
+ borderWidth: borders.hairline,
43
+ borderStyle: 'solid',
44
+ borderColor: colors.border,
45
+ borderRadius: radii.md,
46
+ cursor: 'pointer',
47
+ transitionProperty: 'background-color',
48
+ transitionDuration: duration.fast,
49
+ transitionTimingFunction: easing.standard,
50
+ outlineWidth: {
51
+ default: 0,
52
+ ':focus-visible': borders.focusRing,
53
+ },
54
+ outlineStyle: {
55
+ default: 'none',
56
+ ':focus-visible': 'solid',
57
+ },
58
+ outlineColor: {
59
+ default: 'transparent',
60
+ ':focus-visible': colors.accent,
61
+ },
62
+ outlineOffset: {
63
+ default: 0,
64
+ ':focus-visible': borders.focusOffset,
65
+ },
66
+ },
67
+ // each side keeps its slot: Next stays in the right half without a Prev
68
+ prev: {
69
+ gridColumn: {
70
+ default: '1',
71
+ [STACKED]: 'auto',
72
+ },
73
+ gridRow: {
74
+ default: '1',
75
+ [STACKED]: 'auto',
76
+ },
77
+ },
78
+ next: {
79
+ gridColumn: {
80
+ default: '2',
81
+ [STACKED]: 'auto',
82
+ },
83
+ gridRow: {
84
+ default: '1',
85
+ [STACKED]: 'auto',
86
+ },
87
+ textAlign: {
88
+ default: 'right',
89
+ [STACKED]: 'left',
90
+ },
91
+ },
92
+ label: {
93
+ fontSize: typography.fontSizeXs,
94
+ color: colors.textMuted,
95
+ },
96
+ title: {
97
+ fontSize: typography.fontSizeSm,
98
+ overflowWrap: 'anywhere',
99
+ },
100
+ })
@@ -0,0 +1,41 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'The "On this page" list of a long page\'s sections: a labelled nav of in-page links (href="#id", level 2 or 3). value is the id of the section being read; that item gets aria-current="location", the nav marker bar and a heavier weight. variant="menu" puts the list behind a disclosure button (aria-expanded) for narrow screens; the disclosure state (open, defaultOpen and its change callback) exists on the menu variant only. No scroll-spy inside: the app computes value. Compound component — Root is required.',
6
+ whenToUse: [
7
+ 'A long document page (docs, a runbook, an ADR) with H2/H3 sections',
8
+ 'variant="menu" for the same list on a narrow screen, above the body',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Navigation between pages — use SideNav, Tree or Breadcrumb',
12
+ 'Switching views of one record — use Tabs',
13
+ 'A page with no H2/H3 — render nothing instead of an empty list',
14
+ ],
15
+ anatomy: {
16
+ required: ['Root', 'Item'],
17
+ },
18
+ relatedTo: [
19
+ {
20
+ component: 'Tabs',
21
+ kind: 'contrast',
22
+ relationship:
23
+ 'Tabs swap the content in place; TableOfContents links to sections of one long page',
24
+ },
25
+ {
26
+ component: 'PageNav',
27
+ kind: 'contrast',
28
+ relationship: 'TableOfContents moves within the page; PageNav moves to the next page',
29
+ },
30
+ ],
31
+ example: `<TableOfContents.Root aria-label="On this page" label="On this page" value={activeId}>
32
+ <TableOfContents.Item href="#components">The components</TableOfContents.Item>
33
+ <TableOfContents.Item href="#wiring">Wiring</TableOfContents.Item>
34
+ <TableOfContents.Item href="#webhooks" level={3}>Webhooks</TableOfContents.Item>
35
+ </TableOfContents.Root>
36
+
37
+ // narrow screens
38
+ <TableOfContents.Root aria-label="On this page" variant="menu" value={activeId}>
39
+ …
40
+ </TableOfContents.Root>`,
41
+ }
@@ -0,0 +1,222 @@
1
+ import {useState} from 'react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import type {Meta, StoryObj} from '@storybook/react'
4
+ import {expect, fn, waitFor} from 'storybook/test'
5
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
6
+ import {TableOfContents} from './TableOfContents'
7
+ import type {TableOfContentsVariant} from './TableOfContentsContext'
8
+ import {clickLink} from '../../docs/clickLink'
9
+ import {onThemeSurface} from '../../docs/themedSurface'
10
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
11
+ import {SIZES_PX} from '@duro-app/tokens/keys'
12
+ import type {OnNavigate} from '../../shared/navigate'
13
+
14
+ const meta: Meta = {
15
+ title: 'Components/TableOfContents',
16
+ parameters: {a11y: {test: 'error'}},
17
+ decorators: [onThemeSurface],
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj
22
+
23
+ const styles = css.create({
24
+ aside: {width: sizes.gridColXs},
25
+ phone: {width: sizes.pageXs},
26
+ })
27
+
28
+ function Sections(props: {
29
+ value?: string | null
30
+ variant?: TableOfContentsVariant
31
+ onNavigate?: OnNavigate
32
+ }) {
33
+ return (
34
+ <TableOfContents.Root
35
+ aria-label="On this page"
36
+ label="On this page"
37
+ value={props.value}
38
+ variant={props.variant}
39
+ >
40
+ <TableOfContents.Item href="#components" onNavigate={props.onNavigate}>
41
+ The components
42
+ </TableOfContents.Item>
43
+ <TableOfContents.Item href="#wiring" onNavigate={props.onNavigate}>
44
+ Wiring
45
+ </TableOfContents.Item>
46
+ <TableOfContents.Item href="#webhooks" level={3} onNavigate={props.onNavigate}>
47
+ Webhooks
48
+ </TableOfContents.Item>
49
+ <TableOfContents.Item href="#lifecycle" onNavigate={props.onNavigate}>
50
+ Alert lifecycle
51
+ </TableOfContents.Item>
52
+ <TableOfContents.Item href="#next" onNavigate={props.onNavigate}>
53
+ Where to read next
54
+ </TableOfContents.Item>
55
+ </TableOfContents.Root>
56
+ )
57
+ }
58
+
59
+ export const List: Story = {
60
+ render: () => (
61
+ <html.div style={styles.aside}>
62
+ <Sections value="wiring" />
63
+ </html.div>
64
+ ),
65
+ play: async ({canvas}) => {
66
+ await expect(canvas.getByRole('navigation', {name: 'On this page'})).toBeInTheDocument()
67
+ const active = canvas.getByRole('link', {name: 'Wiring'})
68
+ await expect(active).toHaveAttribute('aria-current', 'location')
69
+ await expect(canvas.getByRole('link', {name: 'The components'})).not.toHaveAttribute(
70
+ 'aria-current',
71
+ )
72
+ // not colour alone: the marker bar and a heavier weight
73
+ await expect(getComputedStyle(active).boxShadow).toContain('inset')
74
+ const other = canvas.getByRole('link', {name: 'The components'})
75
+ await expect(Number(getComputedStyle(active).fontWeight)).toBeGreaterThan(
76
+ Number(getComputedStyle(other).fontWeight),
77
+ )
78
+ // a level-3 item is indented under its H2
79
+ const h3 = canvas.getByRole('link', {name: 'Webhooks'})
80
+ await expect(parseFloat(getComputedStyle(h3).paddingLeft)).toBeGreaterThan(
81
+ parseFloat(getComputedStyle(other).paddingLeft),
82
+ )
83
+ },
84
+ }
85
+
86
+ export const KeyboardOrder: Story = {
87
+ render: () => (
88
+ <html.div style={styles.aside}>
89
+ <Sections value="components" />
90
+ </html.div>
91
+ ),
92
+ play: async ({canvas, userEvent}) => {
93
+ for (const name of [
94
+ 'The components',
95
+ 'Wiring',
96
+ 'Webhooks',
97
+ 'Alert lifecycle',
98
+ 'Where to read next',
99
+ ]) {
100
+ await userEvent.tab()
101
+ await expect(canvas.getByRole('link', {name})).toHaveFocus()
102
+ }
103
+ },
104
+ }
105
+
106
+ /** The app owns the scroll-spy and passes the section being read as `value`. */
107
+ export const ControlledValue: Story = {
108
+ render: function Render() {
109
+ const [value, setValue] = useState('components')
110
+ return (
111
+ <html.div style={styles.aside}>
112
+ <Sections
113
+ value={value}
114
+ onNavigate={(href, event) => {
115
+ event.preventDefault()
116
+ setValue(href.slice(1))
117
+ }}
118
+ />
119
+ </html.div>
120
+ )
121
+ },
122
+ play: async ({canvas, userEvent}) => {
123
+ await userEvent.click(canvas.getByRole('link', {name: 'Alert lifecycle'}))
124
+ await expect(canvas.getByRole('link', {name: 'Alert lifecycle'})).toHaveAttribute(
125
+ 'aria-current',
126
+ 'location',
127
+ )
128
+ await expect(canvas.getByRole('link', {name: 'The components'})).not.toHaveAttribute(
129
+ 'aria-current',
130
+ )
131
+ },
132
+ }
133
+
134
+ export const Menu: Story = {
135
+ render: () => (
136
+ <html.div style={styles.phone}>
137
+ <Sections value="wiring" variant="menu" />
138
+ </html.div>
139
+ ),
140
+ play: async ({canvas, userEvent}) => {
141
+ const trigger = canvas.getByRole('button', {name: 'On this page'})
142
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
143
+ await expect(canvas.queryByRole('link')).toBeNull()
144
+
145
+ // opens from the keyboard; the list follows the button in the tab order
146
+ await userEvent.tab()
147
+ await expect(trigger).toHaveFocus()
148
+ await userEvent.keyboard('{Enter}')
149
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
150
+ await expect(trigger).toHaveAttribute('aria-controls', canvas.getByRole('list').id)
151
+ await expect(canvas.getByRole('link', {name: 'Wiring'})).toHaveAttribute(
152
+ 'aria-current',
153
+ 'location',
154
+ )
155
+ await userEvent.tab()
156
+ await expect(canvas.getByRole('link', {name: 'The components'})).toHaveFocus()
157
+
158
+ // Escape closes it and gives focus back to the button
159
+ await userEvent.keyboard('{Escape}')
160
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
161
+ await expect(trigger).toHaveFocus()
162
+ await expect(canvas.queryByRole('link')).toBeNull()
163
+
164
+ // following an item closes it too
165
+ await userEvent.click(trigger)
166
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true')
167
+ clickLink(canvas.getByRole('link', {name: 'Webhooks'}))
168
+ await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false'))
169
+
170
+ // a touch target: the button is at least 44px tall
171
+ await expect(trigger.getBoundingClientRect().height).toBeGreaterThanOrEqual(44)
172
+ },
173
+ }
174
+
175
+ export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
176
+ args: {onNavigate: fn()},
177
+ render: (args) => (
178
+ <html.div style={styles.aside}>
179
+ <Sections value="components" onNavigate={args.onNavigate} />
180
+ </html.div>
181
+ ),
182
+ play: async ({args, canvas}) => {
183
+ const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
184
+ const link = canvas.getByRole('link', {name: 'Wiring'})
185
+ // the part never prevents the default itself: with a handler that leaves
186
+ // the event alone, the browser still follows the anchor
187
+ await expect(clickLink(link).defaultPrevented).toBe(false)
188
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
189
+ await expect(onNavigate.mock.calls[0][0]).toBe('#wiring')
190
+ await expect(clickLink(link, {metaKey: true}).defaultPrevented).toBe(false)
191
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
192
+ },
193
+ }
194
+
195
+ /**
196
+ * A touch screen gets 44px items in the list variant too (coarse pointer); a
197
+ * mouse keeps the artboard's compact rail.
198
+ * It drives Chrome's touch emulation through Vitest's CDP session, so it runs
199
+ * as a test and stays out of the dev sidebar (there is no Vitest there).
200
+ */
201
+ export const TouchTargets: Story = {
202
+ tags: ['!dev', '!autodocs'],
203
+ render: () => (
204
+ <html.div style={styles.aside}>
205
+ <Sections value="wiring" />
206
+ </html.div>
207
+ ),
208
+ play: async ({canvas}) => {
209
+ await withFinePointer()
210
+ const links = () => canvas.getAllByRole('link')
211
+ for (const link of links()) {
212
+ await expect(link.getBoundingClientRect().height).toBeLessThan(SIZES_PX.touchTarget)
213
+ }
214
+ await withCoarsePointer(async () => {
215
+ for (const link of links()) {
216
+ await waitFor(() =>
217
+ expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget),
218
+ )
219
+ }
220
+ })
221
+ },
222
+ }
@@ -0,0 +1,173 @@
1
+ import {type ReactNode, useCallback, useId, useMemo, useRef} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+ import {useControllableValue} from '../../hooks/useControllableValue'
5
+ import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
6
+ import {
7
+ TableOfContentsContext,
8
+ useTableOfContents,
9
+ type TableOfContentsVariant,
10
+ } from './TableOfContentsContext'
11
+
12
+ /* TableOfContents — the "On this page" list of a long page's sections: a
13
+ * labelled `nav` of in-page links. The section being read is `value`; its item
14
+ * carries `aria-current="location"` and a marker bar plus a heavier weight, so
15
+ * colour is never the only cue. Which section is being read is the app's to
16
+ * work out (an IntersectionObserver over the headings): the component does no
17
+ * scroll-spy.
18
+ *
19
+ * `variant="menu"` is the narrow-screen form: a disclosure button (with
20
+ * `aria-expanded`) that shows the same list below it, and closes again when an
21
+ * item is followed or on Escape. */
22
+
23
+ // --- Root ---
24
+
25
+ /**
26
+ * The disclosure state (`open`, `defaultOpen`, `onOpenChange`) exists only on
27
+ * the menu: the list variant has no button, so it takes none of them.
28
+ */
29
+ type RootProps = {
30
+ /** Names the landmark ("On this page"); required, from your text catalog. */
31
+ 'aria-label': string
32
+ /** The id of the section being read; the Item whose href is `#<value>` is active. */
33
+ value?: string | null
34
+ /**
35
+ * Visible title: the caption above the list, or the menu button's text.
36
+ * The menu button falls back to `aria-label`; the list shows none without it.
37
+ */
38
+ label?: ReactNode
39
+ children: ReactNode
40
+ } & (
41
+ | {
42
+ /** `list` (default) for a sidebar; `menu` for a narrow screen, behind a disclosure button (only `menu` takes open / defaultOpen / onOpenChange). */
43
+ variant?: 'list'
44
+ open?: never
45
+ defaultOpen?: never
46
+ onOpenChange?: never
47
+ }
48
+ | {
49
+ /** `list` (default) for a sidebar; `menu` for a narrow screen, behind a disclosure button (only `menu` takes open / defaultOpen / onOpenChange). */
50
+ variant: 'menu'
51
+ /** Whether the list is shown (controlled). */
52
+ open?: boolean
53
+ /** Whether the list starts shown (uncontrolled; default false). */
54
+ defaultOpen?: boolean
55
+ onOpenChange?: (open: boolean) => void
56
+ }
57
+ )
58
+
59
+ function Root({
60
+ 'aria-label': ariaLabel,
61
+ value = null,
62
+ variant = 'list',
63
+ label,
64
+ open: openProp,
65
+ defaultOpen = false,
66
+ onOpenChange,
67
+ children,
68
+ }: RootProps) {
69
+ const [open, setOpen] = useControllableValue(openProp, defaultOpen, onOpenChange)
70
+ const listId = useId()
71
+ const triggerRef = useRef<HTMLButtonElement>(null)
72
+ const isMenu = variant === 'menu'
73
+
74
+ const close = useCallback(() => {
75
+ if (isMenu && open) setOpen(false)
76
+ }, [isMenu, open, setOpen])
77
+
78
+ const ctx = useMemo(() => ({value, variant, close}), [value, variant, close])
79
+
80
+ const list = (
81
+ <html.ul id={isMenu ? listId : undefined} style={[styles.list, isMenu && styles.listMenu]}>
82
+ {children}
83
+ </html.ul>
84
+ )
85
+
86
+ return (
87
+ <TableOfContentsContext.Provider value={ctx}>
88
+ <html.nav
89
+ aria-label={ariaLabel}
90
+ onKeyDown={(e: {key: string}) => {
91
+ if (isMenu && open && e.key === 'Escape') {
92
+ setOpen(false)
93
+ triggerRef.current?.focus()
94
+ }
95
+ }}
96
+ style={styles.root}
97
+ >
98
+ {isMenu ? (
99
+ <>
100
+ <html.button
101
+ ref={triggerRef}
102
+ type="button"
103
+ aria-expanded={open}
104
+ aria-controls={open ? listId : undefined}
105
+ onClick={() => setOpen(!open)}
106
+ style={[styles.trigger, styles.focusable]}
107
+ >
108
+ <html.span>{label ?? ariaLabel}</html.span>
109
+ <html.span aria-hidden style={[styles.chevron, open && styles.chevronOpen]}>
110
+ ▾
111
+ </html.span>
112
+ </html.button>
113
+ {open ? list : null}
114
+ </>
115
+ ) : (
116
+ <>
117
+ {label != null ? <html.div style={styles.caption}>{label}</html.div> : null}
118
+ {list}
119
+ </>
120
+ )}
121
+ </html.nav>
122
+ </TableOfContentsContext.Provider>
123
+ )
124
+ }
125
+
126
+ // --- Item ---
127
+
128
+ interface ItemProps {
129
+ /** The section's anchor, `#<heading id>`. */
130
+ href: string
131
+ /** The heading's level: 2, or 3 (indented under the 2 before it). */
132
+ level?: 2 | 3
133
+ /**
134
+ * Client-side navigation: called for a plain primary click (no modifier
135
+ * key). Call `event.preventDefault()`, then scroll or navigate yourself.
136
+ */
137
+ onNavigate?: OnNavigate
138
+ children: ReactNode
139
+ }
140
+
141
+ function Item({href, level = 2, onNavigate, children}: ItemProps) {
142
+ const {value, variant, close} = useTableOfContents()
143
+ const active = value != null && href === `#${value}`
144
+ return (
145
+ <html.li style={styles.item}>
146
+ <html.a
147
+ href={href}
148
+ aria-current={active ? 'location' : undefined}
149
+ onClick={(e: LinkClickEvent) => {
150
+ if (!isPlainClick(e)) return
151
+ onNavigate?.(href, e)
152
+ close()
153
+ }}
154
+ style={[
155
+ styles.link,
156
+ styles.focusable,
157
+ variant === 'menu' && styles.linkMenu,
158
+ level === 3 && styles.level3,
159
+ active && styles.linkActive,
160
+ ]}
161
+ >
162
+ {children}
163
+ </html.a>
164
+ </html.li>
165
+ )
166
+ }
167
+
168
+ export const TableOfContents = {
169
+ Root,
170
+ Item,
171
+ }
172
+
173
+ export type {TableOfContentsVariant}
@@ -0,0 +1,20 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ export type TableOfContentsVariant = 'list' | 'menu'
4
+
5
+ export interface TableOfContentsContextValue {
6
+ /** the id of the section being read (an Item is active when its href is `#<value>`) */
7
+ value: string | null
8
+ variant: TableOfContentsVariant
9
+ /** closes the menu variant's disclosure (no-op for the list) */
10
+ close: () => void
11
+ }
12
+
13
+ export const TableOfContentsContext = createContext<TableOfContentsContextValue | null>(null)
14
+
15
+ export function useTableOfContents() {
16
+ const ctx = useContext(TableOfContentsContext)
17
+ if (!ctx)
18
+ throw new Error('TableOfContents compound components must be used within TableOfContents.Root')
19
+ return ctx
20
+ }