@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
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it).',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -10,6 +10,7 @@ import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
|
10
10
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
11
11
|
import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
|
|
12
12
|
import {mergeRefs} from '../../shared/mergeRefs'
|
|
13
|
+
import type {ControlSize} from '../../shared/types'
|
|
13
14
|
|
|
14
15
|
// --- Root ---
|
|
15
16
|
interface RootProps {
|
|
@@ -38,9 +39,12 @@ function Root({name, defaultValue, value, onValueChange, initialLabels, children
|
|
|
38
39
|
function Trigger({
|
|
39
40
|
children,
|
|
40
41
|
'aria-label': ariaLabel,
|
|
42
|
+
size = 'default',
|
|
41
43
|
}: {
|
|
42
44
|
children: ReactNode
|
|
43
45
|
'aria-label'?: string
|
|
46
|
+
/** `small` matches a small Toggle or Button beside it. Default: `default`. */
|
|
47
|
+
size?: ControlSize
|
|
44
48
|
}) {
|
|
45
49
|
const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
|
|
46
50
|
const localRef = useRef<HTMLButtonElement>(null)
|
|
@@ -74,7 +78,7 @@ function Trigger({
|
|
|
74
78
|
aria-activedescendant={highlightedId ?? undefined}
|
|
75
79
|
aria-describedby={describedBy || undefined}
|
|
76
80
|
aria-invalid={fieldCtx?.invalid || undefined}
|
|
77
|
-
style={[styles.trigger, grouped.style]}
|
|
81
|
+
style={[styles.trigger, size === 'small' && styles.triggerSmall, grouped.style]}
|
|
78
82
|
>
|
|
79
83
|
{children}
|
|
80
84
|
</html.button>
|
|
@@ -37,6 +37,18 @@ export const styles = css.create({
|
|
|
37
37
|
transitionProperty: 'border-color',
|
|
38
38
|
transitionDuration: duration.fast,
|
|
39
39
|
},
|
|
40
|
+
// size="small": Toggle sizeSmall's values, as longhands so they win over the
|
|
41
|
+
// trigger's own longhands. Every small control is at least controlSm tall,
|
|
42
|
+
// whatever it holds, and the touch target under a coarse pointer.
|
|
43
|
+
triggerSmall: {
|
|
44
|
+
gap: spacing.xs,
|
|
45
|
+
paddingTop: spacing.xs,
|
|
46
|
+
paddingBottom: spacing.xs,
|
|
47
|
+
paddingLeft: spacing.sm,
|
|
48
|
+
paddingRight: spacing.sm,
|
|
49
|
+
fontSize: typography.fontSizeXs,
|
|
50
|
+
minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
|
|
51
|
+
},
|
|
40
52
|
value: {
|
|
41
53
|
color: colors.text,
|
|
42
54
|
},
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'The "On this page" list of a long page\'s sections: a labelled nav of in-page links (href="#id", level 2 or 3). value is the id of the section being read; that item gets aria-current="location", the nav marker bar and a heavier weight. variant="menu" puts the list behind a disclosure button (aria-expanded) for narrow screens; the disclosure state (open, defaultOpen and its change callback) exists on the menu variant only. No scroll-spy inside: the app computes value. Compound component — Root is required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A long document page (docs, a runbook, an ADR) with H2/H3 sections',
|
|
8
|
+
'variant="menu" for the same list on a narrow screen, above the body',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Navigation between pages — use SideNav, Tree or Breadcrumb',
|
|
12
|
+
'Switching views of one record — use Tabs',
|
|
13
|
+
'A page with no H2/H3 — render nothing instead of an empty list',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['Root', 'Item'],
|
|
17
|
+
},
|
|
18
|
+
relatedTo: [
|
|
19
|
+
{
|
|
20
|
+
component: 'Tabs',
|
|
21
|
+
kind: 'contrast',
|
|
22
|
+
relationship:
|
|
23
|
+
'Tabs swap the content in place; TableOfContents links to sections of one long page',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
component: 'PageNav',
|
|
27
|
+
kind: 'contrast',
|
|
28
|
+
relationship: 'TableOfContents moves within the page; PageNav moves to the next page',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
example: `<TableOfContents.Root aria-label="On this page" label="On this page" value={activeId}>
|
|
32
|
+
<TableOfContents.Item href="#components">The components</TableOfContents.Item>
|
|
33
|
+
<TableOfContents.Item href="#wiring">Wiring</TableOfContents.Item>
|
|
34
|
+
<TableOfContents.Item href="#webhooks" level={3}>Webhooks</TableOfContents.Item>
|
|
35
|
+
</TableOfContents.Root>
|
|
36
|
+
|
|
37
|
+
// narrow screens
|
|
38
|
+
<TableOfContents.Root aria-label="On this page" variant="menu" value={activeId}>
|
|
39
|
+
…
|
|
40
|
+
</TableOfContents.Root>`,
|
|
41
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import {css, html} from 'react-strict-dom'
|
|
3
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
4
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
5
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
6
|
+
import {TableOfContents} from './TableOfContents'
|
|
7
|
+
import type {TableOfContentsVariant} from './TableOfContentsContext'
|
|
8
|
+
import {clickLink} from '../../docs/clickLink'
|
|
9
|
+
import {onThemeSurface} from '../../docs/themedSurface'
|
|
10
|
+
import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
|
|
11
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
12
|
+
import type {OnNavigate} from '../../shared/navigate'
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: 'Components/TableOfContents',
|
|
16
|
+
parameters: {a11y: {test: 'error'}},
|
|
17
|
+
decorators: [onThemeSurface],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj
|
|
22
|
+
|
|
23
|
+
const styles = css.create({
|
|
24
|
+
aside: {width: sizes.gridColXs},
|
|
25
|
+
phone: {width: sizes.pageXs},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function Sections(props: {
|
|
29
|
+
value?: string | null
|
|
30
|
+
variant?: TableOfContentsVariant
|
|
31
|
+
onNavigate?: OnNavigate
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<TableOfContents.Root
|
|
35
|
+
aria-label="On this page"
|
|
36
|
+
label="On this page"
|
|
37
|
+
value={props.value}
|
|
38
|
+
variant={props.variant}
|
|
39
|
+
>
|
|
40
|
+
<TableOfContents.Item href="#components" onNavigate={props.onNavigate}>
|
|
41
|
+
The components
|
|
42
|
+
</TableOfContents.Item>
|
|
43
|
+
<TableOfContents.Item href="#wiring" onNavigate={props.onNavigate}>
|
|
44
|
+
Wiring
|
|
45
|
+
</TableOfContents.Item>
|
|
46
|
+
<TableOfContents.Item href="#webhooks" level={3} onNavigate={props.onNavigate}>
|
|
47
|
+
Webhooks
|
|
48
|
+
</TableOfContents.Item>
|
|
49
|
+
<TableOfContents.Item href="#lifecycle" onNavigate={props.onNavigate}>
|
|
50
|
+
Alert lifecycle
|
|
51
|
+
</TableOfContents.Item>
|
|
52
|
+
<TableOfContents.Item href="#next" onNavigate={props.onNavigate}>
|
|
53
|
+
Where to read next
|
|
54
|
+
</TableOfContents.Item>
|
|
55
|
+
</TableOfContents.Root>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const List: Story = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<html.div style={styles.aside}>
|
|
62
|
+
<Sections value="wiring" />
|
|
63
|
+
</html.div>
|
|
64
|
+
),
|
|
65
|
+
play: async ({canvas}) => {
|
|
66
|
+
await expect(canvas.getByRole('navigation', {name: 'On this page'})).toBeInTheDocument()
|
|
67
|
+
const active = canvas.getByRole('link', {name: 'Wiring'})
|
|
68
|
+
await expect(active).toHaveAttribute('aria-current', 'location')
|
|
69
|
+
await expect(canvas.getByRole('link', {name: 'The components'})).not.toHaveAttribute(
|
|
70
|
+
'aria-current',
|
|
71
|
+
)
|
|
72
|
+
// not colour alone: the marker bar and a heavier weight
|
|
73
|
+
await expect(getComputedStyle(active).boxShadow).toContain('inset')
|
|
74
|
+
const other = canvas.getByRole('link', {name: 'The components'})
|
|
75
|
+
await expect(Number(getComputedStyle(active).fontWeight)).toBeGreaterThan(
|
|
76
|
+
Number(getComputedStyle(other).fontWeight),
|
|
77
|
+
)
|
|
78
|
+
// a level-3 item is indented under its H2
|
|
79
|
+
const h3 = canvas.getByRole('link', {name: 'Webhooks'})
|
|
80
|
+
await expect(parseFloat(getComputedStyle(h3).paddingLeft)).toBeGreaterThan(
|
|
81
|
+
parseFloat(getComputedStyle(other).paddingLeft),
|
|
82
|
+
)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const KeyboardOrder: Story = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<html.div style={styles.aside}>
|
|
89
|
+
<Sections value="components" />
|
|
90
|
+
</html.div>
|
|
91
|
+
),
|
|
92
|
+
play: async ({canvas, userEvent}) => {
|
|
93
|
+
for (const name of [
|
|
94
|
+
'The components',
|
|
95
|
+
'Wiring',
|
|
96
|
+
'Webhooks',
|
|
97
|
+
'Alert lifecycle',
|
|
98
|
+
'Where to read next',
|
|
99
|
+
]) {
|
|
100
|
+
await userEvent.tab()
|
|
101
|
+
await expect(canvas.getByRole('link', {name})).toHaveFocus()
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The app owns the scroll-spy and passes the section being read as `value`. */
|
|
107
|
+
export const ControlledValue: Story = {
|
|
108
|
+
render: function Render() {
|
|
109
|
+
const [value, setValue] = useState('components')
|
|
110
|
+
return (
|
|
111
|
+
<html.div style={styles.aside}>
|
|
112
|
+
<Sections
|
|
113
|
+
value={value}
|
|
114
|
+
onNavigate={(href, event) => {
|
|
115
|
+
event.preventDefault()
|
|
116
|
+
setValue(href.slice(1))
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
</html.div>
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
play: async ({canvas, userEvent}) => {
|
|
123
|
+
await userEvent.click(canvas.getByRole('link', {name: 'Alert lifecycle'}))
|
|
124
|
+
await expect(canvas.getByRole('link', {name: 'Alert lifecycle'})).toHaveAttribute(
|
|
125
|
+
'aria-current',
|
|
126
|
+
'location',
|
|
127
|
+
)
|
|
128
|
+
await expect(canvas.getByRole('link', {name: 'The components'})).not.toHaveAttribute(
|
|
129
|
+
'aria-current',
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const Menu: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<html.div style={styles.phone}>
|
|
137
|
+
<Sections value="wiring" variant="menu" />
|
|
138
|
+
</html.div>
|
|
139
|
+
),
|
|
140
|
+
play: async ({canvas, userEvent}) => {
|
|
141
|
+
const trigger = canvas.getByRole('button', {name: 'On this page'})
|
|
142
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
143
|
+
await expect(canvas.queryByRole('link')).toBeNull()
|
|
144
|
+
|
|
145
|
+
// opens from the keyboard; the list follows the button in the tab order
|
|
146
|
+
await userEvent.tab()
|
|
147
|
+
await expect(trigger).toHaveFocus()
|
|
148
|
+
await userEvent.keyboard('{Enter}')
|
|
149
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
150
|
+
await expect(trigger).toHaveAttribute('aria-controls', canvas.getByRole('list').id)
|
|
151
|
+
await expect(canvas.getByRole('link', {name: 'Wiring'})).toHaveAttribute(
|
|
152
|
+
'aria-current',
|
|
153
|
+
'location',
|
|
154
|
+
)
|
|
155
|
+
await userEvent.tab()
|
|
156
|
+
await expect(canvas.getByRole('link', {name: 'The components'})).toHaveFocus()
|
|
157
|
+
|
|
158
|
+
// Escape closes it and gives focus back to the button
|
|
159
|
+
await userEvent.keyboard('{Escape}')
|
|
160
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
161
|
+
await expect(trigger).toHaveFocus()
|
|
162
|
+
await expect(canvas.queryByRole('link')).toBeNull()
|
|
163
|
+
|
|
164
|
+
// following an item closes it too
|
|
165
|
+
await userEvent.click(trigger)
|
|
166
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
167
|
+
clickLink(canvas.getByRole('link', {name: 'Webhooks'}))
|
|
168
|
+
await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false'))
|
|
169
|
+
|
|
170
|
+
// a touch target: the button is at least 44px tall
|
|
171
|
+
await expect(trigger.getBoundingClientRect().height).toBeGreaterThanOrEqual(44)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const ClientNavigation: StoryObj<{onNavigate: OnNavigate}> = {
|
|
176
|
+
args: {onNavigate: fn()},
|
|
177
|
+
render: (args) => (
|
|
178
|
+
<html.div style={styles.aside}>
|
|
179
|
+
<Sections value="components" onNavigate={args.onNavigate} />
|
|
180
|
+
</html.div>
|
|
181
|
+
),
|
|
182
|
+
play: async ({args, canvas}) => {
|
|
183
|
+
const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
|
|
184
|
+
const link = canvas.getByRole('link', {name: 'Wiring'})
|
|
185
|
+
// the part never prevents the default itself: with a handler that leaves
|
|
186
|
+
// the event alone, the browser still follows the anchor
|
|
187
|
+
await expect(clickLink(link).defaultPrevented).toBe(false)
|
|
188
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
189
|
+
await expect(onNavigate.mock.calls[0][0]).toBe('#wiring')
|
|
190
|
+
await expect(clickLink(link, {metaKey: true}).defaultPrevented).toBe(false)
|
|
191
|
+
await expect(onNavigate).toHaveBeenCalledTimes(1)
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A touch screen gets 44px items in the list variant too (coarse pointer); a
|
|
197
|
+
* mouse keeps the artboard's compact rail.
|
|
198
|
+
* It drives Chrome's touch emulation through Vitest's CDP session, so it runs
|
|
199
|
+
* as a test and stays out of the dev sidebar (there is no Vitest there).
|
|
200
|
+
*/
|
|
201
|
+
export const TouchTargets: Story = {
|
|
202
|
+
tags: ['!dev', '!autodocs'],
|
|
203
|
+
render: () => (
|
|
204
|
+
<html.div style={styles.aside}>
|
|
205
|
+
<Sections value="wiring" />
|
|
206
|
+
</html.div>
|
|
207
|
+
),
|
|
208
|
+
play: async ({canvas}) => {
|
|
209
|
+
await withFinePointer()
|
|
210
|
+
const links = () => canvas.getAllByRole('link')
|
|
211
|
+
for (const link of links()) {
|
|
212
|
+
await expect(link.getBoundingClientRect().height).toBeLessThan(SIZES_PX.touchTarget)
|
|
213
|
+
}
|
|
214
|
+
await withCoarsePointer(async () => {
|
|
215
|
+
for (const link of links()) {
|
|
216
|
+
await waitFor(() =>
|
|
217
|
+
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {type ReactNode, useCallback, useId, useMemo, useRef} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
5
|
+
import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
|
|
6
|
+
import {
|
|
7
|
+
TableOfContentsContext,
|
|
8
|
+
useTableOfContents,
|
|
9
|
+
type TableOfContentsVariant,
|
|
10
|
+
} from './TableOfContentsContext'
|
|
11
|
+
|
|
12
|
+
/* TableOfContents — the "On this page" list of a long page's sections: a
|
|
13
|
+
* labelled `nav` of in-page links. The section being read is `value`; its item
|
|
14
|
+
* carries `aria-current="location"` and a marker bar plus a heavier weight, so
|
|
15
|
+
* colour is never the only cue. Which section is being read is the app's to
|
|
16
|
+
* work out (an IntersectionObserver over the headings): the component does no
|
|
17
|
+
* scroll-spy.
|
|
18
|
+
*
|
|
19
|
+
* `variant="menu"` is the narrow-screen form: a disclosure button (with
|
|
20
|
+
* `aria-expanded`) that shows the same list below it, and closes again when an
|
|
21
|
+
* item is followed or on Escape. */
|
|
22
|
+
|
|
23
|
+
// --- Root ---
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The disclosure state (`open`, `defaultOpen`, `onOpenChange`) exists only on
|
|
27
|
+
* the menu: the list variant has no button, so it takes none of them.
|
|
28
|
+
*/
|
|
29
|
+
type RootProps = {
|
|
30
|
+
/** Names the landmark ("On this page"); required, from your text catalog. */
|
|
31
|
+
'aria-label': string
|
|
32
|
+
/** The id of the section being read; the Item whose href is `#<value>` is active. */
|
|
33
|
+
value?: string | null
|
|
34
|
+
/**
|
|
35
|
+
* Visible title: the caption above the list, or the menu button's text.
|
|
36
|
+
* The menu button falls back to `aria-label`; the list shows none without it.
|
|
37
|
+
*/
|
|
38
|
+
label?: ReactNode
|
|
39
|
+
children: ReactNode
|
|
40
|
+
} & (
|
|
41
|
+
| {
|
|
42
|
+
/** `list` (default) for a sidebar; `menu` for a narrow screen, behind a disclosure button (only `menu` takes open / defaultOpen / onOpenChange). */
|
|
43
|
+
variant?: 'list'
|
|
44
|
+
open?: never
|
|
45
|
+
defaultOpen?: never
|
|
46
|
+
onOpenChange?: never
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
/** `list` (default) for a sidebar; `menu` for a narrow screen, behind a disclosure button (only `menu` takes open / defaultOpen / onOpenChange). */
|
|
50
|
+
variant: 'menu'
|
|
51
|
+
/** Whether the list is shown (controlled). */
|
|
52
|
+
open?: boolean
|
|
53
|
+
/** Whether the list starts shown (uncontrolled; default false). */
|
|
54
|
+
defaultOpen?: boolean
|
|
55
|
+
onOpenChange?: (open: boolean) => void
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
function Root({
|
|
60
|
+
'aria-label': ariaLabel,
|
|
61
|
+
value = null,
|
|
62
|
+
variant = 'list',
|
|
63
|
+
label,
|
|
64
|
+
open: openProp,
|
|
65
|
+
defaultOpen = false,
|
|
66
|
+
onOpenChange,
|
|
67
|
+
children,
|
|
68
|
+
}: RootProps) {
|
|
69
|
+
const [open, setOpen] = useControllableValue(openProp, defaultOpen, onOpenChange)
|
|
70
|
+
const listId = useId()
|
|
71
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
72
|
+
const isMenu = variant === 'menu'
|
|
73
|
+
|
|
74
|
+
const close = useCallback(() => {
|
|
75
|
+
if (isMenu && open) setOpen(false)
|
|
76
|
+
}, [isMenu, open, setOpen])
|
|
77
|
+
|
|
78
|
+
const ctx = useMemo(() => ({value, variant, close}), [value, variant, close])
|
|
79
|
+
|
|
80
|
+
const list = (
|
|
81
|
+
<html.ul id={isMenu ? listId : undefined} style={[styles.list, isMenu && styles.listMenu]}>
|
|
82
|
+
{children}
|
|
83
|
+
</html.ul>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<TableOfContentsContext.Provider value={ctx}>
|
|
88
|
+
<html.nav
|
|
89
|
+
aria-label={ariaLabel}
|
|
90
|
+
onKeyDown={(e: {key: string}) => {
|
|
91
|
+
if (isMenu && open && e.key === 'Escape') {
|
|
92
|
+
setOpen(false)
|
|
93
|
+
triggerRef.current?.focus()
|
|
94
|
+
}
|
|
95
|
+
}}
|
|
96
|
+
style={styles.root}
|
|
97
|
+
>
|
|
98
|
+
{isMenu ? (
|
|
99
|
+
<>
|
|
100
|
+
<html.button
|
|
101
|
+
ref={triggerRef}
|
|
102
|
+
type="button"
|
|
103
|
+
aria-expanded={open}
|
|
104
|
+
aria-controls={open ? listId : undefined}
|
|
105
|
+
onClick={() => setOpen(!open)}
|
|
106
|
+
style={[styles.trigger, styles.focusable]}
|
|
107
|
+
>
|
|
108
|
+
<html.span>{label ?? ariaLabel}</html.span>
|
|
109
|
+
<html.span aria-hidden style={[styles.chevron, open && styles.chevronOpen]}>
|
|
110
|
+
▾
|
|
111
|
+
</html.span>
|
|
112
|
+
</html.button>
|
|
113
|
+
{open ? list : null}
|
|
114
|
+
</>
|
|
115
|
+
) : (
|
|
116
|
+
<>
|
|
117
|
+
{label != null ? <html.div style={styles.caption}>{label}</html.div> : null}
|
|
118
|
+
{list}
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
</html.nav>
|
|
122
|
+
</TableOfContentsContext.Provider>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Item ---
|
|
127
|
+
|
|
128
|
+
interface ItemProps {
|
|
129
|
+
/** The section's anchor, `#<heading id>`. */
|
|
130
|
+
href: string
|
|
131
|
+
/** The heading's level: 2, or 3 (indented under the 2 before it). */
|
|
132
|
+
level?: 2 | 3
|
|
133
|
+
/**
|
|
134
|
+
* Client-side navigation: called for a plain primary click (no modifier
|
|
135
|
+
* key). Call `event.preventDefault()`, then scroll or navigate yourself.
|
|
136
|
+
*/
|
|
137
|
+
onNavigate?: OnNavigate
|
|
138
|
+
children: ReactNode
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function Item({href, level = 2, onNavigate, children}: ItemProps) {
|
|
142
|
+
const {value, variant, close} = useTableOfContents()
|
|
143
|
+
const active = value != null && href === `#${value}`
|
|
144
|
+
return (
|
|
145
|
+
<html.li style={styles.item}>
|
|
146
|
+
<html.a
|
|
147
|
+
href={href}
|
|
148
|
+
aria-current={active ? 'location' : undefined}
|
|
149
|
+
onClick={(e: LinkClickEvent) => {
|
|
150
|
+
if (!isPlainClick(e)) return
|
|
151
|
+
onNavigate?.(href, e)
|
|
152
|
+
close()
|
|
153
|
+
}}
|
|
154
|
+
style={[
|
|
155
|
+
styles.link,
|
|
156
|
+
styles.focusable,
|
|
157
|
+
variant === 'menu' && styles.linkMenu,
|
|
158
|
+
level === 3 && styles.level3,
|
|
159
|
+
active && styles.linkActive,
|
|
160
|
+
]}
|
|
161
|
+
>
|
|
162
|
+
{children}
|
|
163
|
+
</html.a>
|
|
164
|
+
</html.li>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const TableOfContents = {
|
|
169
|
+
Root,
|
|
170
|
+
Item,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type {TableOfContentsVariant}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
|
|
3
|
+
export type TableOfContentsVariant = 'list' | 'menu'
|
|
4
|
+
|
|
5
|
+
export interface TableOfContentsContextValue {
|
|
6
|
+
/** the id of the section being read (an Item is active when its href is `#<value>`) */
|
|
7
|
+
value: string | null
|
|
8
|
+
variant: TableOfContentsVariant
|
|
9
|
+
/** closes the menu variant's disclosure (no-op for the list) */
|
|
10
|
+
close: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const TableOfContentsContext = createContext<TableOfContentsContextValue | null>(null)
|
|
14
|
+
|
|
15
|
+
export function useTableOfContents() {
|
|
16
|
+
const ctx = useContext(TableOfContentsContext)
|
|
17
|
+
if (!ctx)
|
|
18
|
+
throw new Error('TableOfContents compound components must be used within TableOfContents.Root')
|
|
19
|
+
return ctx
|
|
20
|
+
}
|
|
@@ -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")',
|