@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,147 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {microSpacing, radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typeScale, typography} from '@duro-app/tokens/tokens/typography.css'
5
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
6
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
7
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
8
+
9
+ // The picked docs browser: the list is Reader.dc.html's `.toc` / `.tocitem`
10
+ // (a hairline rail, the active item marked by the nav marker bar), the menu is
11
+ // Mobile.dc.html's `.mtoc` (docs/mockups/docs-nav/).
12
+ export const styles = css.create({
13
+ root: {
14
+ display: 'flex',
15
+ flexDirection: 'column',
16
+ minWidth: 0,
17
+ fontFamily: typography.fontFamily,
18
+ },
19
+ caption: {
20
+ marginBottom: spacing.sm,
21
+ fontSize: typography.fontSizeXs,
22
+ fontWeight: typography.fontWeightMedium,
23
+ letterSpacing: typeScale.letterSpacingWide,
24
+ textTransform: 'uppercase',
25
+ color: colors.textMuted,
26
+ },
27
+ list: {
28
+ display: 'flex',
29
+ flexDirection: 'column',
30
+ gap: spacing.xs,
31
+ margin: 0,
32
+ padding: 0,
33
+ listStyleType: 'none',
34
+ borderLeftWidth: borders.hairline,
35
+ borderLeftStyle: 'solid',
36
+ borderLeftColor: colors.border,
37
+ },
38
+ // the menu's list opens below its button, as a panel
39
+ listMenu: {
40
+ gap: 0,
41
+ marginTop: spacing.xs,
42
+ paddingTop: spacing.xs,
43
+ paddingBottom: spacing.xs,
44
+ borderWidth: borders.hairline,
45
+ borderStyle: 'solid',
46
+ borderColor: colors.border,
47
+ borderRadius: radii.sm,
48
+ backgroundColor: colors.bgCard,
49
+ },
50
+ item: {
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ },
54
+ // On a touch screen (coarse pointer, as Toggle does) each item is a touch
55
+ // target; a mouse keeps the artboard's compact rail.
56
+ link: {
57
+ display: {default: 'block', '@media (pointer: coarse)': 'flex'},
58
+ alignItems: {default: null, '@media (pointer: coarse)': 'center'},
59
+ minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
60
+ paddingTop: microSpacing.px2,
61
+ paddingBottom: microSpacing.px2,
62
+ paddingLeft: spacing.ms,
63
+ paddingRight: spacing.ms,
64
+ fontSize: typography.fontSizeSm,
65
+ fontWeight: typography.fontWeightNormal,
66
+ textDecorationLine: 'none',
67
+ color: {
68
+ default: colors.textMuted,
69
+ ':hover': colors.text,
70
+ },
71
+ cursor: 'pointer',
72
+ transitionProperty: 'color',
73
+ transitionDuration: duration.fast,
74
+ transitionTimingFunction: easing.standard,
75
+ },
76
+ // a touch target per item when the menu is the narrow-screen form
77
+ linkMenu: {
78
+ display: 'flex',
79
+ alignItems: 'center',
80
+ minHeight: sizes.touchTarget,
81
+ },
82
+ level3: {
83
+ paddingLeft: `calc(${spacing.ms} + ${spacing.md})`,
84
+ },
85
+ // not colour alone: the marker bar and a heavier weight
86
+ linkActive: {
87
+ color: colors.text,
88
+ fontWeight: typography.fontWeightMedium,
89
+ boxShadow: `inset ${sizes.navMarkerW} 0 0 ${colors.accent}`,
90
+ },
91
+ trigger: {
92
+ display: 'flex',
93
+ alignItems: 'center',
94
+ justifyContent: 'space-between',
95
+ gap: spacing.sm,
96
+ width: '100%',
97
+ minHeight: sizes.touchTarget,
98
+ paddingTop: spacing.sm,
99
+ paddingBottom: spacing.sm,
100
+ paddingLeft: spacing.ms,
101
+ paddingRight: spacing.ms,
102
+ fontFamily: typography.fontFamily,
103
+ fontSize: typography.fontSizeSm,
104
+ color: colors.text,
105
+ textAlign: 'left',
106
+ backgroundColor: {
107
+ default: 'transparent',
108
+ ':hover': colors.bgCardHover,
109
+ },
110
+ borderWidth: borders.hairline,
111
+ borderStyle: 'solid',
112
+ borderColor: colors.border,
113
+ borderRadius: radii.sm,
114
+ cursor: 'pointer',
115
+ transitionProperty: 'background-color',
116
+ transitionDuration: duration.fast,
117
+ transitionTimingFunction: easing.standard,
118
+ },
119
+ // the keyboard ring, inset so a neighbour or the panel edge never clips it
120
+ focusable: {
121
+ outlineWidth: {
122
+ default: 0,
123
+ ':focus-visible': borders.focusRing,
124
+ },
125
+ outlineStyle: {
126
+ default: 'none',
127
+ ':focus-visible': 'solid',
128
+ },
129
+ outlineColor: {
130
+ default: 'transparent',
131
+ ':focus-visible': colors.accent,
132
+ },
133
+ outlineOffset: {
134
+ default: 0,
135
+ ':focus-visible': `calc(-1 * ${borders.focusOffset})`,
136
+ },
137
+ },
138
+ chevron: {
139
+ color: colors.textMuted,
140
+ transitionProperty: 'transform',
141
+ transitionDuration: duration.fast,
142
+ transitionTimingFunction: easing.standard,
143
+ },
144
+ chevronOpen: {
145
+ transform: 'rotate(180deg)',
146
+ },
147
+ })
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer.',
5
+ 'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer. onNavigate(href, event) hands a plain primary click to a client router (the app calls event.preventDefault(), then navigates); modified clicks keep the browser default.',
6
6
  whenToUse: [
7
7
  'A link inside a sentence or paragraph',
8
8
  'A standalone text link under a list or card ("View all requests")',
@@ -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}
@@ -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,6 +12,7 @@ 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'
16
17
  export {ButtonGroup, type ButtonGroupProps} from './components/ButtonGroup/ButtonGroup'
17
18
  export {Callout, type CalloutVariant} from './components/Callout/Callout'
@@ -44,6 +45,7 @@ export {
44
45
  type LinkButtonSize,
45
46
  } from './components/LinkButton/LinkButton'
46
47
  export {Menu} from './components/Menu/Menu'
48
+ export {PageNav} from './components/PageNav/PageNav'
47
49
  export {Panel} from './components/Panel/Panel'
48
50
  export {RadioGroup} from './components/RadioGroup/RadioGroup'
49
51
  export {ScrollArea} from './components/ScrollArea/ScrollArea'
@@ -76,6 +78,10 @@ export {TagGroup} from './components/TagGroup/TagGroup'
76
78
  // stays optional for apps that never render a data table. Importing Table
77
79
  // from there gives the same object with those attached.
78
80
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
81
+ export {
82
+ TableOfContents,
83
+ type TableOfContentsVariant,
84
+ } from './components/TableOfContents/TableOfContents'
79
85
  export {Tabs} from './components/Tabs/Tabs'
80
86
  export {Tree} from './components/Tree/Tree'
81
87
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
@@ -107,6 +113,9 @@ export {Grid} from './components/Grid/Grid'
107
113
  export type {GridTrack} from './shared/length'
108
114
  export {PageShell, type PageShellMaxWidth, type PageShellPadding} from './components/PageShell'
109
115
 
116
+ // Router-agnostic links: the onNavigate contract every link part shares
117
+ export type {LinkClickEvent, OnNavigate} from './shared/navigate'
118
+
110
119
  // Hooks
111
120
  export {useContainerQuery, type ContainerSize} from './hooks/useContainerQuery'
112
121