@duro-app/ui 5.4.0 → 5.6.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 (147) hide show
  1. package/dist/{Table-Cm5cCnZD.js → Table-B1sWL3GA.js} +668 -650
  2. package/dist/Table-B1sWL3GA.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/Button/Button.d.ts +2 -1
  12. package/dist/components/Button/Button.d.ts.map +1 -1
  13. package/dist/components/Button/styles.css.d.ts +1 -0
  14. package/dist/components/Button/styles.css.d.ts.map +1 -1
  15. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
  16. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  17. package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
  18. package/dist/components/LinkButton/LinkButton.d.ts +8 -1
  19. package/dist/components/LinkButton/LinkButton.d.ts.map +1 -1
  20. package/dist/components/LinkButton/LinkButton.stories.d.ts +9 -0
  21. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  22. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  23. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  24. package/dist/components/Menu/Menu.d.ts +4 -1
  25. package/dist/components/Menu/Menu.d.ts.map +1 -1
  26. package/dist/components/Menu/styles.css.d.ts +14 -0
  27. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  28. package/dist/components/PageNav/PageNav.d.ts +30 -0
  29. package/dist/components/PageNav/PageNav.d.ts.map +1 -0
  30. package/dist/components/PageNav/PageNav.meta.d.ts +3 -0
  31. package/dist/components/PageNav/PageNav.meta.d.ts.map +1 -0
  32. package/dist/components/PageNav/PageNav.stories.d.ts +16 -0
  33. package/dist/components/PageNav/PageNav.stories.d.ts.map +1 -0
  34. package/dist/components/PageNav/styles.css.d.ts +51 -0
  35. package/dist/components/PageNav/styles.css.d.ts.map +1 -0
  36. package/dist/components/Popover/Popover.d.ts +14 -2
  37. package/dist/components/Popover/Popover.d.ts.map +1 -1
  38. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  39. package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
  40. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
  41. package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
  42. package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
  43. package/dist/components/Popover/styles.css.d.ts +15 -0
  44. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  45. package/dist/components/Select/Select.d.ts +4 -1
  46. package/dist/components/Select/Select.d.ts.map +1 -1
  47. package/dist/components/Select/styles.css.d.ts +9 -0
  48. package/dist/components/Select/styles.css.d.ts.map +1 -1
  49. package/dist/components/TableOfContents/TableOfContents.d.ts +53 -0
  50. package/dist/components/TableOfContents/TableOfContents.d.ts.map +1 -0
  51. package/dist/components/TableOfContents/TableOfContents.meta.d.ts +3 -0
  52. package/dist/components/TableOfContents/TableOfContents.meta.d.ts.map +1 -0
  53. package/dist/components/TableOfContents/TableOfContents.stories.d.ts +21 -0
  54. package/dist/components/TableOfContents/TableOfContents.stories.d.ts.map +1 -0
  55. package/dist/components/TableOfContents/TableOfContentsContext.d.ts +11 -0
  56. package/dist/components/TableOfContents/TableOfContentsContext.d.ts.map +1 -0
  57. package/dist/components/TableOfContents/styles.css.d.ts +114 -0
  58. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -0
  59. package/dist/components/TextLink/TextLink.d.ts +8 -1
  60. package/dist/components/TextLink/TextLink.d.ts.map +1 -1
  61. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  62. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -1
  63. package/dist/components/Toggle/Toggle.d.ts +2 -1
  64. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  65. package/dist/components/Toggle/styles.css.d.ts +1 -0
  66. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  67. package/dist/components/Tree/Tree.d.ts +13 -1
  68. package/dist/components/Tree/Tree.d.ts.map +1 -1
  69. package/dist/components/Tree/Tree.meta.d.ts.map +1 -1
  70. package/dist/components/Tree/Tree.stories.d.ts +9 -0
  71. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  72. package/dist/components/Tree/styles.css.d.ts +5 -0
  73. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  74. package/dist/docs/clickLink.d.ts +4 -0
  75. package/dist/docs/clickLink.d.ts.map +1 -0
  76. package/dist/docs/coarsePointer.d.ts +15 -0
  77. package/dist/docs/coarsePointer.d.ts.map +1 -0
  78. package/dist/docs/themedSurface.d.ts +8 -0
  79. package/dist/docs/themedSurface.d.ts.map +1 -0
  80. package/dist/index.css +1 -1
  81. package/dist/index.d.ts +6 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +2718 -2212
  84. package/dist/index.js.map +1 -1
  85. package/dist/shared/ModalContext.d.ts +7 -0
  86. package/dist/shared/ModalContext.d.ts.map +1 -1
  87. package/dist/shared/exports.typecheck.d.ts +9 -0
  88. package/dist/shared/exports.typecheck.d.ts.map +1 -0
  89. package/dist/shared/navigate.d.ts +25 -0
  90. package/dist/shared/navigate.d.ts.map +1 -0
  91. package/dist/shared/types.d.ts +2 -0
  92. package/dist/shared/types.d.ts.map +1 -1
  93. package/dist/table.js +1 -1
  94. package/package.json +2 -2
  95. package/src/components/Breadcrumb/Breadcrumb.meta.ts +37 -0
  96. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +110 -0
  97. package/src/components/Breadcrumb/Breadcrumb.tsx +95 -0
  98. package/src/components/Breadcrumb/styles.css.ts +76 -0
  99. package/src/components/Button/Button.tsx +2 -1
  100. package/src/components/Button/styles.css.ts +3 -0
  101. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
  102. package/src/components/Inline/Inline.meta.ts +2 -1
  103. package/src/components/LinkButton/LinkButton.meta.ts +1 -1
  104. package/src/components/LinkButton/LinkButton.stories.tsx +42 -1
  105. package/src/components/LinkButton/LinkButton.tsx +9 -0
  106. package/src/components/LinkButton/styles.css.ts +4 -0
  107. package/src/components/Menu/Menu.meta.ts +1 -1
  108. package/src/components/Menu/Menu.tsx +17 -2
  109. package/src/components/Menu/styles.css.ts +18 -0
  110. package/src/components/PageNav/PageNav.meta.ts +35 -0
  111. package/src/components/PageNav/PageNav.stories.tsx +146 -0
  112. package/src/components/PageNav/PageNav.tsx +93 -0
  113. package/src/components/PageNav/styles.css.ts +100 -0
  114. package/src/components/Popover/Popover.meta.ts +2 -1
  115. package/src/components/Popover/Popover.tsx +29 -4
  116. package/src/components/Popover/PopoverLayerContext.ts +3 -0
  117. package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
  118. package/src/components/Popover/styles.css.ts +21 -0
  119. package/src/components/Select/Select.meta.ts +1 -1
  120. package/src/components/Select/Select.tsx +5 -1
  121. package/src/components/Select/styles.css.ts +12 -0
  122. package/src/components/TableOfContents/TableOfContents.meta.ts +41 -0
  123. package/src/components/TableOfContents/TableOfContents.stories.tsx +222 -0
  124. package/src/components/TableOfContents/TableOfContents.tsx +173 -0
  125. package/src/components/TableOfContents/TableOfContentsContext.ts +20 -0
  126. package/src/components/TableOfContents/styles.css.ts +147 -0
  127. package/src/components/TextLink/TextLink.meta.ts +1 -1
  128. package/src/components/TextLink/TextLink.stories.tsx +42 -1
  129. package/src/components/TextLink/TextLink.tsx +9 -0
  130. package/src/components/Toggle/Toggle.tsx +2 -1
  131. package/src/components/Toggle/styles.css.ts +2 -0
  132. package/src/components/Tree/Tree.meta.ts +2 -1
  133. package/src/components/Tree/Tree.stories.tsx +93 -1
  134. package/src/components/Tree/Tree.tsx +69 -27
  135. package/src/components/Tree/styles.css.ts +6 -0
  136. package/src/docs/clickLink.ts +24 -0
  137. package/src/docs/coarsePointer.ts +42 -0
  138. package/src/docs/themedSurface.tsx +23 -0
  139. package/src/index.ts +11 -0
  140. package/src/shared/ModalContext.ts +8 -1
  141. package/src/shared/exports.typecheck.ts +16 -0
  142. package/src/shared/linkParts.typecheck.tsx +47 -0
  143. package/src/shared/navigate.ts +62 -0
  144. package/src/shared/types.ts +3 -0
  145. package/dist/Table-Cm5cCnZD.js.map +0 -1
  146. package/dist/shared/length.typecheck.d.ts +0 -31
  147. package/dist/shared/length.typecheck.d.ts.map +0 -1
@@ -1,6 +1,9 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect} from 'storybook/test'
2
+ import {expect, fn} from 'storybook/test'
3
3
  import {TextLink} from './TextLink'
4
+ import {clickLink} from '../../docs/clickLink'
5
+ import {onThemeSurface} from '../../docs/themedSurface'
6
+ import type {OnNavigate} from '../../shared/navigate'
4
7
  import {Text} from '../Text/Text'
5
8
  import {Inline} from '../Inline/Inline'
6
9
 
@@ -61,3 +64,41 @@ export const KeyboardFocus: Story = {
61
64
  await expect(getComputedStyle(link).outlineStyle).toBe('solid')
62
65
  },
63
66
  }
67
+
68
+ /**
69
+ * Client-side navigation: `onNavigate` gets a plain primary click (the app
70
+ * calls `event.preventDefault()`, then its router's navigate); a modified
71
+ * click, or a link with `target`, keeps the browser default.
72
+ */
73
+ export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
74
+ parameters: {a11y: {test: 'error'}},
75
+ decorators: [onThemeSurface],
76
+ args: {onNavigate: fn()},
77
+ render: (args) => (
78
+ <>
79
+ <TextLink href="#billing" onNavigate={args.onNavigate}>
80
+ Billing settings
81
+ </TextLink>{' '}
82
+ <TextLink href="#help" target="_blank" onNavigate={args.onNavigate}>
83
+ Help
84
+ </TextLink>
85
+ </>
86
+ ),
87
+ play: async ({args, canvas}) => {
88
+ const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
89
+ onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
90
+ event.preventDefault(),
91
+ )
92
+ const link = canvas.getByRole('link', {name: 'Billing settings'})
93
+ await expect(clickLink(link).defaultPrevented).toBe(true)
94
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
95
+ await expect(onNavigate.mock.calls[0][0]).toBe('#billing')
96
+ for (const init of [{metaKey: true}, {ctrlKey: true}, {shiftKey: true}, {altKey: true}]) {
97
+ await expect(clickLink(link, init).defaultPrevented).toBe(false)
98
+ }
99
+ // a target opens where it says, not through the router
100
+ const help = canvas.getByRole('link', {name: 'Help'})
101
+ await expect(clickLink(help).defaultPrevented).toBe(false)
102
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
103
+ },
104
+ }
@@ -1,6 +1,7 @@
1
1
  import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
+ import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
4
5
 
5
6
  export type TextLinkVariant = 'default' | 'subtle'
6
7
 
@@ -17,6 +18,12 @@ interface TextLinkProps {
17
18
  variant?: TextLinkVariant
18
19
  /** Accessible name, when the visible text alone does not say where it goes. */
19
20
  'aria-label'?: string
21
+ /**
22
+ * Client-side navigation: called for a plain primary click (no modifier
23
+ * key, no `target`); the browser default runs for every other click. Call
24
+ * `event.preventDefault()`, then your router's navigate.
25
+ */
26
+ onNavigate?: OnNavigate
20
27
  children: ReactNode
21
28
  }
22
29
 
@@ -30,6 +37,7 @@ export function TextLink({
30
37
  rel,
31
38
  variant = 'default',
32
39
  'aria-label': ariaLabel,
40
+ onNavigate,
33
41
  children,
34
42
  }: TextLinkProps) {
35
43
  return (
@@ -38,6 +46,7 @@ export function TextLink({
38
46
  target={target}
39
47
  rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
40
48
  aria-label={ariaLabel}
49
+ onClick={linkClickHandler(href, onNavigate, target)}
41
50
  style={[styles.base, styles[variant]]}
42
51
  >
43
52
  {children}
@@ -4,9 +4,10 @@ import {useControllableValue} from '../../hooks/useControllableValue'
4
4
  import {useToggleGroup} from '../ToggleGroup/ToggleGroupContext'
5
5
  import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
6
6
  import {mergeRefs} from '../../shared/mergeRefs'
7
+ import type {ControlSize} from '../../shared/types'
7
8
  import {styles} from './styles.css'
8
9
 
9
- export type ToggleSize = 'default' | 'small'
10
+ export type ToggleSize = ControlSize
10
11
 
11
12
  interface ToggleProps {
12
13
  /** Controlled pressed state (standalone usage). */
@@ -37,6 +37,8 @@ export const styles = css.create({
37
37
  fontSize: typography.fontSizeXs,
38
38
  borderRadius: radii.sm,
39
39
  gap: spacing.xs,
40
+ // Every small control is at least controlSm tall, whatever it holds (5.6).
41
+ minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
40
42
  },
41
43
  // wrapped toggles: a fixed block size per size, the touch target under a
42
44
  // coarse pointer. ToggleGroup's maxRows arithmetic reads the same tokens.
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. Compound component — Root is required.',
5
+ 'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required.',
6
6
  whenToUse: [
7
7
  'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
8
8
  'Picking one node of a hierarchy to show its details beside the tree',
9
+ 'A docs tree of sections and pages, each Item with href (and onNavigate for a client router)',
9
10
  ],
10
11
  whenNotToUse: [
11
12
  'Site or app navigation — use SideNav (a rail advertises destinations; a tree browses data)',
@@ -1,8 +1,11 @@
1
1
  import {useState} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import type {Meta, StoryObj} from '@storybook/react'
4
- import {expect} from 'storybook/test'
4
+ import {expect, fn} from 'storybook/test'
5
5
  import {Tree} from './Tree'
6
+ import {clickLink} from '../../docs/clickLink'
7
+ import {onThemeSurface} from '../../docs/themedSurface'
8
+ import type {OnNavigate} from '../../shared/navigate'
6
9
 
7
10
  const meta: Meta = {
8
11
  title: 'Components/Tree',
@@ -149,3 +152,92 @@ export const Controlled: Story = {
149
152
  await expect(canvas.getByLabelText('Selected')).toHaveTextContent('finance')
150
153
  },
151
154
  }
155
+
156
+ function DocsTree(props: {onNavigate?: OnNavigate; defaultValue?: string}) {
157
+ const {onNavigate} = props
158
+ return (
159
+ <Tree.Root
160
+ aria-label="Docs"
161
+ defaultExpanded={['architecture', 'operations']}
162
+ defaultValue={props.defaultValue}
163
+ >
164
+ <Tree.Item value="start" label="Start here" href="#start" onNavigate={onNavigate} />
165
+ <Tree.Item value="architecture" label="Architecture" meta="4">
166
+ <Tree.Item
167
+ value="clusters"
168
+ label="Clusters and bootstrap"
169
+ href="#clusters"
170
+ onNavigate={onNavigate}
171
+ />
172
+ <Tree.Item value="ai-ops" label="AI-ops platform" href="#ai-ops" onNavigate={onNavigate} />
173
+ <Tree.Item
174
+ value="secrets"
175
+ label="Secrets and Vault"
176
+ href="#secrets"
177
+ onNavigate={onNavigate}
178
+ />
179
+ </Tree.Item>
180
+ <Tree.Item value="operations" label="Operations" meta="31">
181
+ <Tree.Item value="runbooks" label="Runbooks" href="#runbooks" onNavigate={onNavigate} />
182
+ </Tree.Item>
183
+ </Tree.Root>
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Items with `href`: each row is a real link (cmd-click opens a tab, "Copy
189
+ * link" works) that stays out of the tab order, so the tree keeps one tab stop
190
+ * and its keyboard. Enter follows the link through `onNavigate`.
191
+ */
192
+ export const WithLinks: StoryObj<{onNavigate: OnNavigate}> = {
193
+ parameters: {
194
+ a11y: {
195
+ test: 'error',
196
+ // holds-until: https://github.com/fredericrous/duro-design-system/issues/79
197
+ // — the selected row's accent on bgCardHover is 6.76:1, under AAA's
198
+ // 7:1 (AA passes). Re-enable this rule when that pair reaches 7:1.
199
+ options: {rules: {'color-contrast-enhanced': {enabled: false}}},
200
+ },
201
+ },
202
+ decorators: [onThemeSurface],
203
+ args: {onNavigate: fn()},
204
+ render: (args) => <DocsTree onNavigate={args.onNavigate} defaultValue="ai-ops" />,
205
+ play: async ({args, canvas, userEvent}) => {
206
+ const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
207
+ onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
208
+ event.preventDefault(),
209
+ )
210
+ const item = (name: RegExp) => canvas.getByRole('treeitem', {name})
211
+
212
+ // a real link, outside the tab order
213
+ const link = canvas.getByRole('link', {name: /Clusters and bootstrap/})
214
+ await expect(link).toHaveAttribute('href', '#clusters')
215
+ await expect(link).toHaveAttribute('tabindex', '-1')
216
+
217
+ // one tab stop (the selection), and the arrow keys still move focus
218
+ await userEvent.tab()
219
+ await expect(item(/AI-ops platform/)).toHaveFocus()
220
+ await userEvent.keyboard('{ArrowUp}')
221
+ await expect(item(/Clusters and bootstrap/)).toHaveFocus()
222
+ await userEvent.keyboard('{ArrowDown}{ArrowDown}')
223
+ await expect(item(/Secrets and Vault/)).toHaveFocus()
224
+ await userEvent.keyboard('{Home}')
225
+ await expect(item(/Start here/)).toHaveFocus()
226
+
227
+ // Enter follows the link: onNavigate, then the item is selected
228
+ await userEvent.keyboard('{End}{ArrowUp}{ArrowUp}{Enter}')
229
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
230
+ await expect(onNavigate.mock.calls[0][0]).toBe('#secrets')
231
+ await expect(item(/Secrets and Vault/)).toHaveAttribute('aria-selected', 'true')
232
+
233
+ // a plain click goes to the router; a meta-click keeps the browser
234
+ // default and does not select
235
+ await expect(clickLink(link).defaultPrevented).toBe(true)
236
+ await expect(onNavigate).toHaveBeenCalledTimes(2)
237
+ await expect(onNavigate.mock.calls[1][0]).toBe('#clusters')
238
+ const runbooks = canvas.getByRole('link', {name: /Runbooks/})
239
+ await expect(clickLink(runbooks, {metaKey: true}).defaultPrevented).toBe(false)
240
+ await expect(onNavigate).toHaveBeenCalledTimes(2)
241
+ await expect(item(/Runbooks/)).toHaveAttribute('aria-selected', 'false')
242
+ },
243
+ }
@@ -12,13 +12,19 @@ import {
12
12
  import {html} from 'react-strict-dom'
13
13
  import {styles} from './styles.css'
14
14
  import {TreeContext, TreeLevelContext, useTree} from './TreeContext'
15
+ import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
15
16
 
16
17
  /* Tree — a hierarchy of items (the WAI-ARIA tree pattern): single selection,
17
18
  * expandable branches, one tab stop (roving tabindex), and the keyboard of a
18
19
  * file explorer — ↑/↓ move between visible items, → opens a branch then goes
19
20
  * to its first child, ← closes it then goes to the parent, Home/End, Enter
20
21
  * or Space select, and typing jumps to the next item whose label starts with
21
- * the typed letters. */
22
+ * the typed letters.
23
+ *
24
+ * An item with `href` renders its row as a real link (cmd-click opens a tab,
25
+ * "Copy link" works) that stays out of the tab order: the treeitem keeps the
26
+ * single tab stop and the keyboard above, and Enter or Space on it follows
27
+ * the link, through `onNavigate` when given. */
22
28
 
23
29
  // --- Root ---
24
30
 
@@ -144,10 +150,15 @@ function Root({
144
150
  focusItem(list[list.length - 1])
145
151
  return
146
152
  case 'Enter':
147
- case ' ':
153
+ case ' ': {
148
154
  e.preventDefault()
149
- onSelect(val)
155
+ // an item with href: follow its link (the click selects it and
156
+ // runs onNavigate, or the browser navigates)
157
+ const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
158
+ if (link) link.click()
159
+ else onSelect(val)
150
160
  return
161
+ }
151
162
  }
152
163
  // typeahead: a printable character, no modifier
153
164
  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
@@ -237,9 +248,20 @@ interface ItemProps {
237
248
  children?: ReactNode
238
249
  /** trailing content on the row (a count, a status) */
239
250
  meta?: ReactNode
251
+ /**
252
+ * Makes the row a link to this URL. A plain click (or Enter / Space)
253
+ * selects the item and calls `onNavigate`; a modified click keeps the
254
+ * browser default (new tab, new window) and does not select.
255
+ */
256
+ href?: string
257
+ /**
258
+ * Client-side navigation for `href`: called for a plain primary click. Call
259
+ * `event.preventDefault()`, then your router's navigate.
260
+ */
261
+ onNavigate?: OnNavigate
240
262
  }
241
263
 
242
- function Item({value, label, textValue, children, meta}: ItemProps) {
264
+ function Item({value, label, textValue, children, meta, href, onNavigate}: ItemProps) {
243
265
  const {selectedValue, onSelect, isExpanded, toggle, focusValue, keyboardFocus} = useTree()
244
266
  const level = useContext(TreeLevelContext)
245
267
  const hasChildren = Children.count(children) > 0
@@ -249,6 +271,32 @@ function Item({value, label, textValue, children, meta}: ItemProps) {
249
271
  // named by its own label only: a branch's content includes its children,
250
272
  // and a name computed from content would read the whole subtree
251
273
  const labelId = useId()
274
+ const rowStyles = [
275
+ styles.indent(level),
276
+ selected && styles.rowSelected,
277
+ keyboardFocus && focusValue === value && styles.rowFocused,
278
+ ]
279
+ const rowContent = (
280
+ <>
281
+ <html.span
282
+ aria-hidden
283
+ onClick={(e: {stopPropagation: () => void; preventDefault: () => void}) => {
284
+ if (!hasChildren) return
285
+ // the chevron only toggles: it never selects, nor follows a link
286
+ e.stopPropagation()
287
+ e.preventDefault()
288
+ toggle(value)
289
+ }}
290
+ style={[styles.chevron, open && styles.chevronOpen, !hasChildren && styles.chevronLeaf]}
291
+ >
292
+ ▸
293
+ </html.span>
294
+ <html.span id={labelId} style={styles.label}>
295
+ {label}
296
+ </html.span>
297
+ {meta != null ? <html.span style={styles.meta}>{meta}</html.span> : null}
298
+ </>
299
+ )
252
300
 
253
301
  return (
254
302
  <html.li
@@ -262,31 +310,25 @@ function Item({value, label, textValue, children, meta}: ItemProps) {
262
310
  data-tree-text={text}
263
311
  style={styles.item}
264
312
  >
265
- <html.div
266
- onClick={() => onSelect(value)}
267
- style={[
268
- styles.row,
269
- styles.indent(level),
270
- selected && styles.rowSelected,
271
- keyboardFocus && focusValue === value && styles.rowFocused,
272
- ]}
273
- >
274
- <html.span
275
- aria-hidden
276
- onClick={(e: {stopPropagation: () => void}) => {
277
- if (!hasChildren) return
278
- e.stopPropagation()
279
- toggle(value)
313
+ {href !== undefined ? (
314
+ <html.a
315
+ href={href}
316
+ tabIndex={-1}
317
+ data-tree-link=""
318
+ onClick={(e: LinkClickEvent) => {
319
+ if (!isPlainClick(e)) return
320
+ onSelect(value)
321
+ onNavigate?.(href, e)
280
322
  }}
281
- style={[styles.chevron, open && styles.chevronOpen, !hasChildren && styles.chevronLeaf]}
323
+ style={[styles.row, styles.rowLink, ...rowStyles]}
282
324
  >
283
- ▸
284
- </html.span>
285
- <html.span id={labelId} style={styles.label}>
286
- {label}
287
- </html.span>
288
- {meta != null ? <html.span style={styles.meta}>{meta}</html.span> : null}
289
- </html.div>
325
+ {rowContent}
326
+ </html.a>
327
+ ) : (
328
+ <html.div onClick={() => onSelect(value)} style={[styles.row, ...rowStyles]}>
329
+ {rowContent}
330
+ </html.div>
331
+ )}
290
332
  {open ? (
291
333
  <TreeLevelContext.Provider value={level + 1}>
292
334
  <html.ul role="group" style={styles.group}>
@@ -50,6 +50,12 @@ export const styles = css.create({
50
50
  transitionDuration: duration.fast,
51
51
  transitionTimingFunction: easing.standard,
52
52
  },
53
+ // the row of an item with href: a real link that looks like the others
54
+ rowLink: {
55
+ textDecorationLine: 'none',
56
+ outlineWidth: 0,
57
+ outlineStyle: 'none',
58
+ },
53
59
  // one indent step per level below the top
54
60
  indent: (level: number) => ({
55
61
  paddingLeft: `calc(${spacing.sm} + ${Math.max(0, level - 1)} * ${spacing.lg})`,
@@ -0,0 +1,24 @@
1
+ /* Story helper for the onNavigate contract (shared/navigate.ts): dispatches a
2
+ * click on a link and reports whether anything before the window — the link
3
+ * part, or the onNavigate it called — prevented the default. The window
4
+ * listener then prevents it itself, so a story never navigates the frame or
5
+ * opens a tab, whatever modifier the click carries. */
6
+ export function clickLink(
7
+ link: Element,
8
+ init: Pick<MouseEventInit, 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'button'> = {},
9
+ ): {defaultPrevented: boolean} {
10
+ let defaultPrevented = false
11
+ const onClick = (event: MouseEvent) => {
12
+ defaultPrevented = event.defaultPrevented
13
+ event.preventDefault()
14
+ }
15
+ window.addEventListener('click', onClick)
16
+ try {
17
+ link.dispatchEvent(
18
+ new MouseEvent('click', {bubbles: true, cancelable: true, button: 0, ...init}),
19
+ )
20
+ } finally {
21
+ window.removeEventListener('click', onClick)
22
+ }
23
+ return {defaultPrevented}
24
+ }
@@ -0,0 +1,42 @@
1
+ import {expect, waitFor} from 'storybook/test'
2
+
3
+ /**
4
+ * `vitest/browser` throws when imported outside Vitest, so it is loaded on
5
+ * call (as Menu's stories do): the stories still render in a dev Storybook.
6
+ * `CDPSession` is empty until the provider's typings load; `send` exists at runtime.
7
+ */
8
+ const cdpSend = async (method: string, params: object) => {
9
+ const {cdp} = await import('vitest/browser')
10
+ return (cdp() as unknown as {send: (method: string, params: object) => Promise<unknown>}).send(
11
+ method,
12
+ params,
13
+ )
14
+ }
15
+
16
+ /**
17
+ * Runs `check` with Chrome's touch emulation on, so `(pointer: coarse)` matches
18
+ * (as DrawerScroll's CoarsePointerRows does), then turns it off again so the
19
+ * stories after this one see a mouse.
20
+ */
21
+ export async function withCoarsePointer(check: () => Promise<void>) {
22
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: true, maxTouchPoints: 1})
23
+ try {
24
+ await waitFor(() => expect(matchMedia('(pointer: coarse)').matches).toBe(true))
25
+ await check()
26
+ } finally {
27
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: false})
28
+ await waitFor(() => expect(matchMedia('(pointer: coarse)').matches).toBe(false))
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Turns touch emulation off and waits for a mouse. A story that measures the
34
+ * fine-pointer layout calls it first: the browser is shared by every story
35
+ * file, and one that times out mid-emulation (DrawerScroll's, under load)
36
+ * leaves `(pointer: coarse)` on for whatever runs next.
37
+ * holds-until: #81 (DrawerScroll turns emulation off in a `finally`).
38
+ */
39
+ export async function withFinePointer() {
40
+ await cdpSend('Emulation.setTouchEmulationEnabled', {enabled: false})
41
+ await waitFor(() => expect(matchMedia('(pointer: coarse)').matches).toBe(false))
42
+ }
@@ -0,0 +1,23 @@
1
+ import type {Decorator} from '@storybook/react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
4
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
5
+
6
+ const styles = css.create({
7
+ surface: {
8
+ padding: spacing.md,
9
+ backgroundColor: colors.bg,
10
+ color: colors.text,
11
+ },
12
+ })
13
+
14
+ /**
15
+ * Paints the theme's page background behind a story. The vitest browser frame
16
+ * has a white page whatever the theme, so axe would measure the dark theme's
17
+ * text against white; on this surface `color-contrast` checks the real pair.
18
+ */
19
+ export const onThemeSurface: Decorator = (Story) => (
20
+ <html.div style={styles.surface}>
21
+ <Story />
22
+ </html.div>
23
+ )
package/src/index.ts CHANGED
@@ -12,7 +12,9 @@ export {ActionBarProvider} from './components/ActionBar/ActionBarProvider'
12
12
  export {Alert, type AlertVariant} from './components/Alert/Alert'
13
13
  export {Icon, type IconName} from './components/Icon'
14
14
  export {Badge, type BadgeVariant, type BadgeSize} from './components/Badge/Badge'
15
+ export {Breadcrumb} from './components/Breadcrumb/Breadcrumb'
15
16
  export {Button, type ButtonVariant, type ButtonSize} from './components/Button/Button'
17
+ export type {ControlSize} from './shared/types'
16
18
  export {ButtonGroup, type ButtonGroupProps} from './components/ButtonGroup/ButtonGroup'
17
19
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
18
20
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
@@ -44,6 +46,7 @@ export {
44
46
  type LinkButtonSize,
45
47
  } from './components/LinkButton/LinkButton'
46
48
  export {Menu} from './components/Menu/Menu'
49
+ export {PageNav} from './components/PageNav/PageNav'
47
50
  export {Panel} from './components/Panel/Panel'
48
51
  export {RadioGroup} from './components/RadioGroup/RadioGroup'
49
52
  export {ScrollArea} from './components/ScrollArea/ScrollArea'
@@ -76,6 +79,10 @@ export {TagGroup} from './components/TagGroup/TagGroup'
76
79
  // stays optional for apps that never render a data table. Importing Table
77
80
  // from there gives the same object with those attached.
78
81
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
82
+ export {
83
+ TableOfContents,
84
+ type TableOfContentsVariant,
85
+ } from './components/TableOfContents/TableOfContents'
79
86
  export {Tabs} from './components/Tabs/Tabs'
80
87
  export {Tree} from './components/Tree/Tree'
81
88
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
@@ -97,6 +104,7 @@ export {Tooltip} from './components/Tooltip/Tooltip'
97
104
  export {Toolbar, type ToolbarProps} from './components/Toolbar/Toolbar'
98
105
  export {Popover} from './components/Popover/Popover'
99
106
  export type {PopoverHandle} from './components/Popover/Popover'
107
+ export {useInModal} from './shared/ModalContext'
100
108
  export {Switch} from './components/Switch/Switch'
101
109
 
102
110
  // Layout primitives
@@ -107,6 +115,9 @@ export {Grid} from './components/Grid/Grid'
107
115
  export type {GridTrack} from './shared/length'
108
116
  export {PageShell, type PageShellMaxWidth, type PageShellPadding} from './components/PageShell'
109
117
 
118
+ // Router-agnostic links: the onNavigate contract every link part shares
119
+ export type {LinkClickEvent, OnNavigate} from './shared/navigate'
120
+
110
121
  // Hooks
111
122
  export {useContainerQuery, type ContainerSize} from './hooks/useContainerQuery'
112
123
 
@@ -13,6 +13,13 @@ import {createContext, useContext} from 'react'
13
13
  */
14
14
  export const ModalContext = createContext(false)
15
15
 
16
- export function useInModal() {
16
+ /**
17
+ * True inside a Dialog or Drawer, false outside. A floating surface that is not
18
+ * a Duro Popover picks its layer with it, the way a Popover does:
19
+ * `useInModal() ? layers.popover : layers.floating`, and for one that must
20
+ * cover another floating surface (a link editor over a format bar)
21
+ * `useInModal() ? layers.popoverRaised : layers.floatingRaised`.
22
+ */
23
+ export function useInModal(): boolean {
17
24
  return useContext(ModalContext)
18
25
  }
@@ -0,0 +1,16 @@
1
+ // Compile-only checks that 5.6's additions are on the package root, where a
2
+ // consumer imports them. `pnpm typecheck` fails if one is dropped from
3
+ // index.ts or changes shape. Never imported at runtime.
4
+
5
+ import type {ButtonSize, ControlSize, ToggleSize, useInModal} from '../index'
6
+
7
+ type Same<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false
8
+
9
+ // A floating surface that is not a Popover picks its layer with it.
10
+ export const useInModalIsBoolean: Same<ReturnType<typeof useInModal>, boolean> = true
11
+ export const useInModalTakesNothing: Same<Parameters<typeof useInModal>, []> = true
12
+
13
+ // The shared size, and the two sizes that alias it.
14
+ export const controlSizes: Same<ControlSize, 'default' | 'small'> = true
15
+ export const buttonSizeIsControlSize: Same<ButtonSize, ControlSize> = true
16
+ export const toggleSizeIsControlSize: Same<ToggleSize, ControlSize> = true
@@ -0,0 +1,47 @@
1
+ // Compile-only fixtures for the exclusive prop sets of the docs-nav parts.
2
+ // `pnpm typecheck` fails if an illegal combination becomes assignable, or a
3
+ // legal one stops being. Never imported at runtime.
4
+
5
+ import type {Breadcrumb} from '../components/Breadcrumb/Breadcrumb'
6
+ import type {TableOfContents} from '../components/TableOfContents/TableOfContents'
7
+ import type {OnNavigate} from './navigate'
8
+
9
+ /** `true` when every member of T is assignable to U. */
10
+ type Assignable<T, U> = [T] extends [U] ? true : false
11
+
12
+ /** The props a function component takes. */
13
+ type PropsOf<C> = C extends (props: infer P) => unknown ? P : never
14
+
15
+ type CrumbProps = PropsOf<typeof Breadcrumb.Item>
16
+ type TocRootProps = PropsOf<typeof TableOfContents.Root>
17
+ type Children = {children: string}
18
+
19
+ // Breadcrumb.Item: a link, or the current page — never both, never neither.
20
+ export const crumbLink: Assignable<{href: string} & Children, CrumbProps> = true
21
+ export const crumbLinkNavigate: Assignable<
22
+ {href: string; onNavigate: OnNavigate} & Children,
23
+ CrumbProps
24
+ > = true
25
+ export const crumbCurrent: Assignable<{current: true} & Children, CrumbProps> = true
26
+ export const crumbCurrentWithHref: Assignable<
27
+ {current: true; href: string} & Children,
28
+ CrumbProps
29
+ > = false
30
+ export const crumbCurrentWithNavigate: Assignable<
31
+ {current: true; onNavigate: OnNavigate} & Children,
32
+ CrumbProps
33
+ > = false
34
+ export const crumbNeither: Assignable<Children, CrumbProps> = false
35
+
36
+ // TableOfContents.Root: the disclosure props belong to the menu variant only.
37
+ type TocBase = {'aria-label': string} & Children
38
+ export const tocList: Assignable<TocBase, TocRootProps> = true
39
+ export const tocMenuOpen: Assignable<
40
+ {variant: 'menu'; open: boolean; onOpenChange: (open: boolean) => void} & TocBase,
41
+ TocRootProps
42
+ > = true
43
+ export const tocListOpen: Assignable<{open: boolean} & TocBase, TocRootProps> = false
44
+ export const tocListDefaultOpen: Assignable<
45
+ {variant: 'list'; defaultOpen: boolean} & TocBase,
46
+ TocRootProps
47
+ > = false