@duro-app/ui 0.50.0 → 0.52.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/components/ActionBar/ActionBar.d.ts +6 -0
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.stories.d.ts +2 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/ActionBar/styles.css.d.ts +3 -0
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/Icon/Icon.d.ts +1 -1
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.d.ts +32 -3
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +4 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1123 -1069
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.meta.ts +2 -1
- package/src/components/ActionBar/ActionBar.stories.tsx +35 -0
- package/src/components/ActionBar/ActionBar.tsx +12 -1
- package/src/components/ActionBar/styles.css.ts +3 -0
- package/src/components/Icon/Icon.tsx +56 -0
- package/src/components/SideNav/SideNav.meta.ts +10 -7
- package/src/components/SideNav/SideNav.stories.tsx +63 -4
- package/src/components/SideNav/SideNav.tsx +50 -7
- package/src/components/SideNav/styles.css.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@tanstack/react-virtual": "^3.14.6",
|
|
66
|
-
"@duro-app/tokens": "^0.
|
|
66
|
+
"@duro-app/tokens": "^0.52.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -6,9 +6,10 @@ export const meta: ComponentMeta = {
|
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Bulk actions on table selections (delete, revoke, export)',
|
|
8
8
|
'Multi-select workflows where actions apply to all selected items',
|
|
9
|
+
'Selection-scoped verbs on a canvas/editor surface (set `bottomOffset` to clear any bottom chrome such as zoom controls)',
|
|
9
10
|
],
|
|
10
11
|
whenNotToUse: [
|
|
11
|
-
'Single-item actions — use inline buttons or a Menu',
|
|
12
|
+
'Single-item actions that have an inline home — use inline buttons or a Menu',
|
|
12
13
|
'Persistent toolbars — use Inline with buttons',
|
|
13
14
|
],
|
|
14
15
|
relatedTo: [
|
|
@@ -11,6 +11,7 @@ interface ActionBarStoryArgs {
|
|
|
11
11
|
onClearSelection: () => void
|
|
12
12
|
selectedItemCount?: number
|
|
13
13
|
isEmphasized?: boolean
|
|
14
|
+
bottomOffset?: number
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const meta: Meta<ActionBarStoryArgs> = {
|
|
@@ -24,6 +25,11 @@ const meta: Meta<ActionBarStoryArgs> = {
|
|
|
24
25
|
control: 'boolean',
|
|
25
26
|
description: 'Whether the action bar uses the emphasized (inverse) style',
|
|
26
27
|
},
|
|
28
|
+
bottomOffset: {
|
|
29
|
+
control: 'number',
|
|
30
|
+
description:
|
|
31
|
+
'Distance in px from the bottom of the viewport, for hosts whose bottom edge already carries chrome the bar must clear',
|
|
32
|
+
},
|
|
27
33
|
},
|
|
28
34
|
}
|
|
29
35
|
|
|
@@ -263,6 +269,35 @@ export const Hidden: Story = {
|
|
|
263
269
|
render: () => <SelectableTable initialSelected={new Set()} />,
|
|
264
270
|
}
|
|
265
271
|
|
|
272
|
+
export const BottomOffset: Story = {
|
|
273
|
+
render: () => (
|
|
274
|
+
<>
|
|
275
|
+
<Table.Root>
|
|
276
|
+
<Table.Header>
|
|
277
|
+
<Table.Row>
|
|
278
|
+
<Table.HeaderCell>Name</Table.HeaderCell>
|
|
279
|
+
</Table.Row>
|
|
280
|
+
</Table.Header>
|
|
281
|
+
<Table.Body>
|
|
282
|
+
<Table.Row>
|
|
283
|
+
<Table.Cell>Hero section</Table.Cell>
|
|
284
|
+
</Table.Row>
|
|
285
|
+
</Table.Body>
|
|
286
|
+
</Table.Root>
|
|
287
|
+
<ActionBar selectedItemCount={1} bottomOffset={72} onClearSelection={() => {}}>
|
|
288
|
+
<Button variant="secondary" size="small">
|
|
289
|
+
Resize
|
|
290
|
+
</Button>
|
|
291
|
+
</ActionBar>
|
|
292
|
+
</>
|
|
293
|
+
),
|
|
294
|
+
play: async () => {
|
|
295
|
+
const toolbar = document.querySelector('[role="toolbar"]')
|
|
296
|
+
await expect(toolbar).toBeInTheDocument()
|
|
297
|
+
await expect(getComputedStyle(toolbar as Element).bottom).toBe('72px')
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
|
|
266
301
|
export const AllVariants: Story = {
|
|
267
302
|
render: () => (
|
|
268
303
|
<html.div style={storyStyles.wrapper}>
|
|
@@ -14,6 +14,12 @@ export interface ActionBarProps {
|
|
|
14
14
|
onClearSelection: () => void
|
|
15
15
|
/** Whether the bar can be dismissed. When false, the close button is hidden. @default true */
|
|
16
16
|
dismissible?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Distance in px between the bar and the bottom of the viewport, for hosts
|
|
19
|
+
* with their own bottom chrome (zoom controls, players) the bar must clear.
|
|
20
|
+
* Defaults to the spacing.lg token.
|
|
21
|
+
*/
|
|
22
|
+
bottomOffset?: number
|
|
17
23
|
/** Action buttons to display. */
|
|
18
24
|
children: ReactNode
|
|
19
25
|
}
|
|
@@ -24,6 +30,7 @@ function ActionBarContent({
|
|
|
24
30
|
isEmphasized = false,
|
|
25
31
|
onClearSelection,
|
|
26
32
|
dismissible = true,
|
|
33
|
+
bottomOffset,
|
|
27
34
|
children,
|
|
28
35
|
}: ActionBarProps) {
|
|
29
36
|
const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
|
|
@@ -33,7 +40,11 @@ function ActionBarContent({
|
|
|
33
40
|
<html.div
|
|
34
41
|
role="toolbar"
|
|
35
42
|
aria-label={label}
|
|
36
|
-
style={[
|
|
43
|
+
style={[
|
|
44
|
+
styles.overlay,
|
|
45
|
+
bottomOffset != null && styles.overlayOffset(bottomOffset),
|
|
46
|
+
isEmphasized && styles.overlayEmphasized,
|
|
47
|
+
]}
|
|
37
48
|
>
|
|
38
49
|
<html.span style={[styles.selectedCount, isEmphasized && styles.selectedCountEmphasized]}>
|
|
39
50
|
{label}
|
|
@@ -21,6 +21,13 @@ export type IconName =
|
|
|
21
21
|
| 'git-branch'
|
|
22
22
|
| 'menu'
|
|
23
23
|
| 'pin'
|
|
24
|
+
// Infrastructure / inventory glyphs
|
|
25
|
+
| 'server'
|
|
26
|
+
| 'hard-drive'
|
|
27
|
+
| 'box'
|
|
28
|
+
| 'image'
|
|
29
|
+
| 'tag'
|
|
30
|
+
| 'pie-chart'
|
|
24
31
|
// People / access / admin glyphs
|
|
25
32
|
| 'users'
|
|
26
33
|
| 'user-plus'
|
|
@@ -159,6 +166,55 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
|
|
|
159
166
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
160
167
|
</>
|
|
161
168
|
),
|
|
169
|
+
// ---- infrastructure / inventory ----
|
|
170
|
+
// Two stacked rack units — hosts, nodes, machines.
|
|
171
|
+
server: (
|
|
172
|
+
<>
|
|
173
|
+
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
|
|
174
|
+
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
|
|
175
|
+
<line x1="6" y1="6" x2="6.01" y2="6" />
|
|
176
|
+
<line x1="6" y1="18" x2="6.01" y2="18" />
|
|
177
|
+
</>
|
|
178
|
+
),
|
|
179
|
+
// Disk enclosure — volumes, persistent storage.
|
|
180
|
+
'hard-drive': (
|
|
181
|
+
<>
|
|
182
|
+
<line x1="22" y1="12" x2="2" y2="12" />
|
|
183
|
+
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
184
|
+
<line x1="6" y1="16" x2="6.01" y2="16" />
|
|
185
|
+
<line x1="10" y1="16" x2="10.01" y2="16" />
|
|
186
|
+
</>
|
|
187
|
+
),
|
|
188
|
+
// Isometric crate — a deployable unit of work.
|
|
189
|
+
box: (
|
|
190
|
+
<>
|
|
191
|
+
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
192
|
+
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
|
193
|
+
<line x1="12" y1="22.08" x2="12" y2="12" />
|
|
194
|
+
</>
|
|
195
|
+
),
|
|
196
|
+
// Picture frame — container images, artefacts.
|
|
197
|
+
image: (
|
|
198
|
+
<>
|
|
199
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
200
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
201
|
+
<polyline points="21 15 16 10 5 21" />
|
|
202
|
+
</>
|
|
203
|
+
),
|
|
204
|
+
// Luggage tag — labels, annotations, metadata.
|
|
205
|
+
tag: (
|
|
206
|
+
<>
|
|
207
|
+
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
|
208
|
+
<line x1="7" y1="7" x2="7.01" y2="7" />
|
|
209
|
+
</>
|
|
210
|
+
),
|
|
211
|
+
// Share of a whole — quotas, budgets, utilisation.
|
|
212
|
+
'pie-chart': (
|
|
213
|
+
<>
|
|
214
|
+
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
|
|
215
|
+
<path d="M22 12A10 10 0 0 0 12 2v10z" />
|
|
216
|
+
</>
|
|
217
|
+
),
|
|
162
218
|
// ---- people / access / admin ----
|
|
163
219
|
users: (
|
|
164
220
|
<>
|
|
@@ -2,31 +2,34 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Vertical side navigation.
|
|
5
|
+
'Vertical side navigation. `Section` is a labelled block that is always open — the default for primary navigation. `Group` is the same block behind a chevron (a disclosure) — reach for it only for a rare/advanced region, an unbounded data-driven list, or a rail long enough that flat stops reading as an overview; mixing the two in one rail is the healthy shape. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required.',
|
|
6
6
|
whenToUse: [
|
|
7
|
-
'App-level sidebar navigation',
|
|
8
|
-
'Settings navigation with
|
|
9
|
-
'
|
|
7
|
+
'App-level sidebar navigation — flat `Section`s, every destination visible',
|
|
8
|
+
'Settings navigation with labelled regions',
|
|
9
|
+
'`Group` for a rare or advanced region tucked below the flat sections',
|
|
10
|
+
'`Group` when the entries are data-driven and unbounded (one per namespace, project, team)',
|
|
10
11
|
],
|
|
11
12
|
whenNotToUse: [
|
|
13
|
+
'Collapsing the everyday destinations — a nav advertises where you can go; hiding the common ones behind a chevron costs a click each and removes them from scanning. Use `Section`.',
|
|
14
|
+
'Arbitrary-depth data browsing (file tree, resource drill-down) — that needs role="tree" with roving tabindex and typeahead, not a deeper SideNav',
|
|
12
15
|
'In-page content switching — use Tabs',
|
|
13
16
|
'Top-level horizontal nav — use Inline with LinkButton',
|
|
14
17
|
],
|
|
15
18
|
anatomy: {
|
|
16
19
|
required: ['Root', 'Item'],
|
|
17
|
-
optional: ['
|
|
20
|
+
optional: ['Section', 'Group'],
|
|
18
21
|
},
|
|
19
22
|
relatedTo: [
|
|
20
23
|
{component: 'Tabs', relationship: 'Tabs switch content in-place; SideNav navigates pages'},
|
|
21
24
|
{component: 'PageShell', relationship: 'Often placed alongside PageShell'},
|
|
22
25
|
],
|
|
23
26
|
example: `<SideNav.Root defaultValue="identities">
|
|
24
|
-
{/*
|
|
27
|
+
{/* Default: always-open sections. Nothing is a click away from being seen. */}
|
|
25
28
|
<SideNav.Section label="People & access">
|
|
26
29
|
<SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>Identities</SideNav.Item>
|
|
27
30
|
<SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>Grants</SideNav.Item>
|
|
28
31
|
</SideNav.Section>
|
|
29
|
-
{/*
|
|
32
|
+
{/* Disclosure, earned: rarely visited, so it starts collapsed. */}
|
|
30
33
|
<SideNav.Group label="Advanced" defaultExpanded={false}>
|
|
31
34
|
<SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>Plugins</SideNav.Item>
|
|
32
35
|
</SideNav.Group>
|
|
@@ -14,18 +14,21 @@ const meta: Meta<typeof SideNav.Root> = {
|
|
|
14
14
|
export default meta
|
|
15
15
|
type Story = StoryObj<typeof SideNav.Root>
|
|
16
16
|
|
|
17
|
+
// The default shape for primary navigation: labelled `Section`s, all open, no
|
|
18
|
+
// chevrons. Every destination is visible and one click away — a nav's job is to
|
|
19
|
+
// advertise where you can go, and a collapsed group does the opposite.
|
|
17
20
|
export const Default: Story = {
|
|
18
21
|
render: (args) => (
|
|
19
22
|
<SideNav.Root {...args} defaultValue="dashboard">
|
|
20
|
-
<SideNav.
|
|
23
|
+
<SideNav.Section label="Overview">
|
|
21
24
|
<SideNav.Item value="dashboard">Dashboard</SideNav.Item>
|
|
22
25
|
<SideNav.Item value="analytics">Analytics</SideNav.Item>
|
|
23
|
-
</SideNav.
|
|
24
|
-
<SideNav.
|
|
26
|
+
</SideNav.Section>
|
|
27
|
+
<SideNav.Section label="Settings">
|
|
25
28
|
<SideNav.Item value="profile">Profile</SideNav.Item>
|
|
26
29
|
<SideNav.Item value="security">Security</SideNav.Item>
|
|
27
30
|
<SideNav.Item value="notifications">Notifications</SideNav.Item>
|
|
28
|
-
</SideNav.
|
|
31
|
+
</SideNav.Section>
|
|
29
32
|
</SideNav.Root>
|
|
30
33
|
),
|
|
31
34
|
play: async ({canvas}) => {
|
|
@@ -34,6 +37,62 @@ export const Default: Story = {
|
|
|
34
37
|
|
|
35
38
|
const activeItem = canvas.getByRole('button', {name: 'Dashboard'})
|
|
36
39
|
await expect(activeItem).toHaveAttribute('aria-current', 'page')
|
|
40
|
+
|
|
41
|
+
// Nothing is hidden: no section is a disclosure, every item is reachable.
|
|
42
|
+
await expect(canvas.queryByRole('button', {name: 'Settings'})).toBeNull()
|
|
43
|
+
await expect(canvas.getByRole('button', {name: 'Notifications'})).toBeInTheDocument()
|
|
44
|
+
|
|
45
|
+
// Each section names its own group so a screen reader hears "Settings,
|
|
46
|
+
// group" rather than one undifferentiated run of buttons.
|
|
47
|
+
const groups = canvas.getAllByRole('group')
|
|
48
|
+
await expect(groups).toHaveLength(2)
|
|
49
|
+
await expect(groups[0]).toHaveAccessibleName('Overview')
|
|
50
|
+
await expect(groups[1]).toHaveAccessibleName('Settings')
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The healthy mix: flat sections carry the journey, and ONE collapsed Group
|
|
55
|
+
// holds the region you visit rarely. Disclosure is for the seldom-used — never
|
|
56
|
+
// for the everyday.
|
|
57
|
+
export const SectionsWithOneDisclosedGroup: Story = {
|
|
58
|
+
render: (args) => (
|
|
59
|
+
<SideNav.Root {...args} defaultValue="identities">
|
|
60
|
+
<SideNav.Section label="People & access">
|
|
61
|
+
<SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
|
|
62
|
+
Identities
|
|
63
|
+
</SideNav.Item>
|
|
64
|
+
<SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
|
|
65
|
+
Grants
|
|
66
|
+
</SideNav.Item>
|
|
67
|
+
</SideNav.Section>
|
|
68
|
+
<SideNav.Section label="Audit">
|
|
69
|
+
<SideNav.Item value="audit" icon={<Icon name="file-text" size={18} />}>
|
|
70
|
+
Audit log
|
|
71
|
+
</SideNav.Item>
|
|
72
|
+
</SideNav.Section>
|
|
73
|
+
<SideNav.Group label="Advanced">
|
|
74
|
+
<SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>
|
|
75
|
+
Plugins
|
|
76
|
+
</SideNav.Item>
|
|
77
|
+
</SideNav.Group>
|
|
78
|
+
</SideNav.Root>
|
|
79
|
+
),
|
|
80
|
+
play: async ({canvas, userEvent}) => {
|
|
81
|
+
// Everyday destinations: visible without interaction.
|
|
82
|
+
await expect(canvas.getByRole('button', {name: 'Identities'})).toBeInTheDocument()
|
|
83
|
+
await expect(canvas.getByRole('button', {name: 'Audit log'})).toBeInTheDocument()
|
|
84
|
+
|
|
85
|
+
// The rare one is disclosed, and says so.
|
|
86
|
+
const advanced = canvas.getByRole('button', {name: 'Advanced'})
|
|
87
|
+
await expect(advanced).toHaveAttribute('aria-expanded', 'false')
|
|
88
|
+
await expect(advanced).not.toHaveAttribute('aria-controls')
|
|
89
|
+
await expect(canvas.queryByRole('button', {name: 'Plugins'})).toBeNull()
|
|
90
|
+
|
|
91
|
+
await userEvent.click(advanced)
|
|
92
|
+
await expect(advanced).toHaveAttribute('aria-expanded', 'true')
|
|
93
|
+
const panelId = advanced.getAttribute('aria-controls')
|
|
94
|
+
await expect(panelId).toBeTruthy()
|
|
95
|
+
await expect(canvas.getByRole('button', {name: 'Plugins'})).toBeInTheDocument()
|
|
37
96
|
},
|
|
38
97
|
}
|
|
39
98
|
|
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import {type ReactNode, useState, useCallback, useRef, useEffect} from 'react'
|
|
1
|
+
import {type ReactNode, useState, useCallback, useRef, useEffect, useId} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
5
5
|
import {SideNavContext, useSideNav} from './SideNavContext'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* SideNav has two ways to chunk a rail, and they are NOT interchangeable.
|
|
9
|
+
*
|
|
10
|
+
* `Section` — a labelled block that is always open. This is the DEFAULT for
|
|
11
|
+
* primary navigation. A nav's job is to advertise where you can go; an
|
|
12
|
+
* always-open list keeps the whole information architecture scannable and puts
|
|
13
|
+
* every destination one click away. The uppercase label does the chunking work
|
|
14
|
+
* on its own — you get the grouping benefit without hiding anything.
|
|
15
|
+
*
|
|
16
|
+
* `Group` — the same block behind a chevron (a disclosure). Collapsing costs
|
|
17
|
+
* every destination inside it an extra click and hides it from scanning, so it
|
|
18
|
+
* has to buy something back. It does when the region is:
|
|
19
|
+
* • rare or advanced ("Advanced", "Danger zone", "Legacy") — disclose the
|
|
20
|
+
* seldom-used, never the everyday;
|
|
21
|
+
* • unbounded / data-driven (one entry per namespace, project, team) — you
|
|
22
|
+
* cannot author-flatten a list whose length you don't control;
|
|
23
|
+
* • one of many in a rail long enough (>~30 leaves) that a flat list stops
|
|
24
|
+
* reading as an overview.
|
|
25
|
+
* The healthy shape is a mix: flat Sections for the journey, one collapsed
|
|
26
|
+
* Group at the bottom.
|
|
27
|
+
*
|
|
28
|
+
* Neither is a tree. Arbitrary-depth *data* browsing (a file tree, a
|
|
29
|
+
* namespace → resource drill-down) needs `role="tree"` with roving tabindex,
|
|
30
|
+
* typeahead and aria-level — a different component, not a deeper SideNav.
|
|
31
|
+
*/
|
|
32
|
+
|
|
7
33
|
// --- Root ---
|
|
8
34
|
|
|
9
35
|
interface RootProps {
|
|
@@ -93,6 +119,8 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
|
|
|
93
119
|
const {expandedGroups, toggleGroup, expandGroup, activeValue} = useSideNav()
|
|
94
120
|
const isExpanded = expandedGroups.has(key)
|
|
95
121
|
const groupRef = useRef<HTMLDivElement>(null)
|
|
122
|
+
const triggerId = useId()
|
|
123
|
+
const panelId = useId()
|
|
96
124
|
|
|
97
125
|
// Auto-expand if this group contains the active item. Uses expandGroup (not
|
|
98
126
|
// toggleGroup) so it never races the defaultExpanded effect into a closed
|
|
@@ -125,9 +153,11 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
|
|
|
125
153
|
<html.div ref={groupRef} style={styles.group}>
|
|
126
154
|
<html.button
|
|
127
155
|
type="button"
|
|
156
|
+
id={triggerId}
|
|
128
157
|
onClick={() => toggleGroup(key)}
|
|
129
158
|
style={[styles.groupTrigger, hasActiveChild && styles.groupTriggerActive]}
|
|
130
159
|
aria-expanded={isExpanded}
|
|
160
|
+
aria-controls={isExpanded ? panelId : undefined}
|
|
131
161
|
>
|
|
132
162
|
<html.span style={[styles.chevron, isExpanded && styles.chevronOpen]}>
|
|
133
163
|
<svg
|
|
@@ -145,7 +175,11 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
|
|
|
145
175
|
</html.span>
|
|
146
176
|
{label}
|
|
147
177
|
</html.button>
|
|
148
|
-
{isExpanded
|
|
178
|
+
{isExpanded ? (
|
|
179
|
+
<html.div id={panelId} role="group" aria-labelledby={triggerId} style={styles.items}>
|
|
180
|
+
{children}
|
|
181
|
+
</html.div>
|
|
182
|
+
) : null}
|
|
149
183
|
</html.div>
|
|
150
184
|
)
|
|
151
185
|
}
|
|
@@ -159,15 +193,24 @@ interface SectionProps {
|
|
|
159
193
|
|
|
160
194
|
/**
|
|
161
195
|
* A static, non-collapsible grouping: an always-visible uppercase header with
|
|
162
|
-
* its items below. Unlike `Group` there is no chevron, toggle, or hidden state
|
|
163
|
-
*
|
|
164
|
-
*
|
|
196
|
+
* its items below. Unlike `Group` there is no chevron, toggle, or hidden state.
|
|
197
|
+
*
|
|
198
|
+
* Reach for this first — see the note at the top of this file for when a
|
|
199
|
+
* collapsible `Group` is the better call. The label is exposed as the group's
|
|
200
|
+
* accessible name, so a screen reader announces "Networking, group" on the way
|
|
201
|
+
* into its items instead of dropping the user into an undifferentiated run of
|
|
202
|
+
* thirty buttons.
|
|
165
203
|
*/
|
|
166
204
|
function Section({children, label}: SectionProps) {
|
|
205
|
+
const labelId = useId()
|
|
167
206
|
return (
|
|
168
207
|
<html.div style={styles.section}>
|
|
169
|
-
<html.div style={styles.sectionLabel}>
|
|
170
|
-
|
|
208
|
+
<html.div id={labelId} style={styles.sectionLabel}>
|
|
209
|
+
{label}
|
|
210
|
+
</html.div>
|
|
211
|
+
<html.div role="group" aria-labelledby={labelId} style={styles.items}>
|
|
212
|
+
{children}
|
|
213
|
+
</html.div>
|
|
171
214
|
</html.div>
|
|
172
215
|
)
|
|
173
216
|
}
|
|
@@ -51,6 +51,13 @@ export const styles = css.create({
|
|
|
51
51
|
chevronOpen: {
|
|
52
52
|
transform: 'rotate(90deg)',
|
|
53
53
|
},
|
|
54
|
+
// The `role="group"` wrapper around a Section's / an expanded Group's items.
|
|
55
|
+
// Purely structural: it exists to carry the accessible name, so it must not
|
|
56
|
+
// change how the items stack.
|
|
57
|
+
items: {
|
|
58
|
+
display: 'flex',
|
|
59
|
+
flexDirection: 'column',
|
|
60
|
+
},
|
|
54
61
|
// Static (non-collapsible) section — see SideNav.Section.
|
|
55
62
|
section: {
|
|
56
63
|
display: 'flex',
|