@duro-app/ui 5.3.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.
- package/dist/{Table-Cm5cCnZD.js → Table-DYN-bp0e.js} +388 -387
- package/dist/Table-DYN-bp0e.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/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/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/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/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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2945 -2484
- package/dist/index.js.map +1 -1
- package/dist/shared/navigate.d.ts +25 -0
- package/dist/shared/navigate.d.ts.map +1 -0
- 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/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/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/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/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 +9 -0
- package/src/shared/linkParts.typecheck.tsx +47 -0
- package/src/shared/navigate.ts +62 -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
|
@@ -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
|
-
|
|
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,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
|
|