@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.
Files changed (29) hide show
  1. package/dist/components/ActionBar/ActionBar.d.ts +6 -0
  2. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  3. package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
  4. package/dist/components/ActionBar/ActionBar.stories.d.ts +2 -0
  5. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  6. package/dist/components/ActionBar/styles.css.d.ts +3 -0
  7. package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
  8. package/dist/components/Icon/Icon.d.ts +1 -1
  9. package/dist/components/Icon/Icon.d.ts.map +1 -1
  10. package/dist/components/SideNav/SideNav.d.ts +32 -3
  11. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  12. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  13. package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
  14. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  15. package/dist/components/SideNav/styles.css.d.ts +4 -0
  16. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  17. package/dist/index.css +1 -1
  18. package/dist/index.js +1123 -1069
  19. package/dist/index.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/components/ActionBar/ActionBar.meta.ts +2 -1
  22. package/src/components/ActionBar/ActionBar.stories.tsx +35 -0
  23. package/src/components/ActionBar/ActionBar.tsx +12 -1
  24. package/src/components/ActionBar/styles.css.ts +3 -0
  25. package/src/components/Icon/Icon.tsx +56 -0
  26. package/src/components/SideNav/SideNav.meta.ts +10 -7
  27. package/src/components/SideNav/SideNav.stories.tsx +63 -4
  28. package/src/components/SideNav/SideNav.tsx +50 -7
  29. 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.50.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.50.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={[styles.overlay, isEmphasized && styles.overlayEmphasized]}
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}
@@ -28,6 +28,9 @@ export const styles = css.create({
28
28
  zIndex: 50,
29
29
  maxWidth: 'calc(100vw - 48px)',
30
30
  },
31
+ overlayOffset: (bottom: number) => ({
32
+ bottom,
33
+ }),
31
34
  overlayEmphasized: {
32
35
  backgroundColor: colors.accent,
33
36
  borderColor: colors.accent,
@@ -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. Use collapsible `Group`s (chevron accordion) or static `Section`s (always-open headers) — or a mix. `Item`s take an optional `icon` and show an active left-marker. Supports controlled and uncontrolled active item. Compound component — Root is required.',
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 grouped sections',
9
- 'Flat menu with labelled, always-open regions — use Section instead of Group',
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: ['Group', 'Section'],
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
- {/* Static, always-open sections with icons (flat menu) */}
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
- {/* Or collapsible groups (accordion) */}
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.Group label="Overview" defaultExpanded>
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.Group>
24
- <SideNav.Group label="Settings">
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.Group>
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 && children}
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
- * — use it when every section should stay open at a glance (a flat menu with
164
- * labelled regions rather than an accordion).
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}>{label}</html.div>
170
- {children}
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',