@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.
- package/dist/{Table-Cm5cCnZD.js → Table-B1sWL3GA.js} +668 -650
- package/dist/Table-B1sWL3GA.js.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +37 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts +3 -0
- package/dist/components/Breadcrumb/Breadcrumb.meta.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts +18 -0
- package/dist/components/Breadcrumb/Breadcrumb.stories.d.ts.map +1 -0
- package/dist/components/Breadcrumb/styles.css.d.ts +46 -0
- package/dist/components/Breadcrumb/styles.css.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/Inline/Inline.meta.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.d.ts +8 -1
- package/dist/components/LinkButton/LinkButton.d.ts.map +1 -1
- package/dist/components/LinkButton/LinkButton.stories.d.ts +9 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +4 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +14 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/PageNav/PageNav.d.ts +30 -0
- package/dist/components/PageNav/PageNav.d.ts.map +1 -0
- package/dist/components/PageNav/PageNav.meta.d.ts +3 -0
- package/dist/components/PageNav/PageNav.meta.d.ts.map +1 -0
- package/dist/components/PageNav/PageNav.stories.d.ts +16 -0
- package/dist/components/PageNav/PageNav.stories.d.ts.map +1 -0
- package/dist/components/PageNav/styles.css.d.ts +51 -0
- package/dist/components/PageNav/styles.css.d.ts.map +1 -0
- package/dist/components/Popover/Popover.d.ts +14 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
- package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
- package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
- package/dist/components/Popover/styles.css.d.ts +15 -0
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +4 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +9 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/TableOfContents/TableOfContents.d.ts +53 -0
- package/dist/components/TableOfContents/TableOfContents.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContents.meta.d.ts +3 -0
- package/dist/components/TableOfContents/TableOfContents.meta.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContents.stories.d.ts +21 -0
- package/dist/components/TableOfContents/TableOfContents.stories.d.ts.map +1 -0
- package/dist/components/TableOfContents/TableOfContentsContext.d.ts +11 -0
- package/dist/components/TableOfContents/TableOfContentsContext.d.ts.map +1 -0
- package/dist/components/TableOfContents/styles.css.d.ts +114 -0
- package/dist/components/TableOfContents/styles.css.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.d.ts +8 -1
- package/dist/components/TextLink/TextLink.d.ts.map +1 -1
- package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
- package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts +2 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +1 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/Tree.d.ts +13 -1
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/Tree.meta.d.ts.map +1 -1
- package/dist/components/Tree/Tree.stories.d.ts +9 -0
- package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +5 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/docs/clickLink.d.ts +4 -0
- package/dist/docs/clickLink.d.ts.map +1 -0
- package/dist/docs/coarsePointer.d.ts +15 -0
- package/dist/docs/coarsePointer.d.ts.map +1 -0
- package/dist/docs/themedSurface.d.ts +8 -0
- package/dist/docs/themedSurface.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2718 -2212
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +7 -0
- package/dist/shared/ModalContext.d.ts.map +1 -1
- package/dist/shared/exports.typecheck.d.ts +9 -0
- package/dist/shared/exports.typecheck.d.ts.map +1 -0
- package/dist/shared/navigate.d.ts +25 -0
- package/dist/shared/navigate.d.ts.map +1 -0
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Breadcrumb/Breadcrumb.meta.ts +37 -0
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +110 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +95 -0
- package/src/components/Breadcrumb/styles.css.ts +76 -0
- package/src/components/Button/Button.tsx +2 -1
- package/src/components/Button/styles.css.ts +3 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
- package/src/components/Inline/Inline.meta.ts +2 -1
- package/src/components/LinkButton/LinkButton.meta.ts +1 -1
- package/src/components/LinkButton/LinkButton.stories.tsx +42 -1
- package/src/components/LinkButton/LinkButton.tsx +9 -0
- package/src/components/LinkButton/styles.css.ts +4 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.tsx +17 -2
- package/src/components/Menu/styles.css.ts +18 -0
- package/src/components/PageNav/PageNav.meta.ts +35 -0
- package/src/components/PageNav/PageNav.stories.tsx +146 -0
- package/src/components/PageNav/PageNav.tsx +93 -0
- package/src/components/PageNav/styles.css.ts +100 -0
- package/src/components/Popover/Popover.meta.ts +2 -1
- package/src/components/Popover/Popover.tsx +29 -4
- package/src/components/Popover/PopoverLayerContext.ts +3 -0
- package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
- package/src/components/Popover/styles.css.ts +21 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.tsx +5 -1
- package/src/components/Select/styles.css.ts +12 -0
- package/src/components/TableOfContents/TableOfContents.meta.ts +41 -0
- package/src/components/TableOfContents/TableOfContents.stories.tsx +222 -0
- package/src/components/TableOfContents/TableOfContents.tsx +173 -0
- package/src/components/TableOfContents/TableOfContentsContext.ts +20 -0
- package/src/components/TableOfContents/styles.css.ts +147 -0
- package/src/components/TextLink/TextLink.meta.ts +1 -1
- package/src/components/TextLink/TextLink.stories.tsx +42 -1
- package/src/components/TextLink/TextLink.tsx +9 -0
- package/src/components/Toggle/Toggle.tsx +2 -1
- package/src/components/Toggle/styles.css.ts +2 -0
- package/src/components/Tree/Tree.meta.ts +2 -1
- package/src/components/Tree/Tree.stories.tsx +93 -1
- package/src/components/Tree/Tree.tsx +69 -27
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/docs/clickLink.ts +24 -0
- package/src/docs/coarsePointer.ts +42 -0
- package/src/docs/themedSurface.tsx +23 -0
- package/src/index.ts +11 -0
- package/src/shared/ModalContext.ts +8 -1
- package/src/shared/exports.typecheck.ts +16 -0
- package/src/shared/linkParts.typecheck.tsx +47 -0
- package/src/shared/navigate.ts +62 -0
- package/src/shared/types.ts +3 -0
- package/dist/Table-Cm5cCnZD.js.map +0 -1
- package/dist/shared/length.typecheck.d.ts +0 -31
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
323
|
+
style={[styles.row, styles.rowLink, ...rowStyles]}
|
|
282
324
|
>
|
|
283
|
-
|
|
284
|
-
</html.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|