@duro-app/ui 0.51.0 → 1.0.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 (114) hide show
  1. package/dist/{Table-xlMa_Kqe.js → Table-P-UHkEqJ.js} +333 -329
  2. package/dist/Table-P-UHkEqJ.js.map +1 -0
  3. package/dist/components/ActionBar/ActionBar.d.ts +6 -4
  4. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  5. package/dist/components/ButtonGroup/ButtonGroup.d.ts +3 -1
  6. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -1
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  8. package/dist/components/Cluster/Cluster.d.ts +2 -2
  9. package/dist/components/Cluster/Cluster.d.ts.map +1 -1
  10. package/dist/components/Cluster/Cluster.stories.d.ts.map +1 -1
  11. package/dist/components/ColorMode/ColorMode.d.ts +3 -2
  12. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  13. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -1
  14. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +1 -1
  15. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  16. package/dist/components/DetailPanel/DetailPanel.d.ts +3 -2
  17. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  18. package/dist/components/DetailPanel/styles.css.d.ts +15 -3
  19. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -1
  20. package/dist/components/Dialog/Dialog.d.ts +3 -2
  21. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/components/Dialog/styles.css.d.ts +16 -4
  23. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  24. package/dist/components/Drawer/Drawer.d.ts +3 -2
  25. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  26. package/dist/components/Drawer/styles.css.d.ts +16 -4
  27. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  28. package/dist/components/Fieldset/Fieldset.d.ts +2 -1
  29. package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
  30. package/dist/components/Grid/Grid.d.ts +2 -2
  31. package/dist/components/Grid/Grid.d.ts.map +1 -1
  32. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  33. package/dist/components/Icon/Icon.d.ts +6 -4
  34. package/dist/components/Icon/Icon.d.ts.map +1 -1
  35. package/dist/components/Icon/index.d.ts +1 -1
  36. package/dist/components/Icon/index.d.ts.map +1 -1
  37. package/dist/components/Inline/Inline.d.ts +2 -2
  38. package/dist/components/Inline/Inline.d.ts.map +1 -1
  39. package/dist/components/Inline/Inline.stories.d.ts.map +1 -1
  40. package/dist/components/SideNav/SideNav.d.ts +32 -3
  41. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  42. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  43. package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
  44. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  45. package/dist/components/SideNav/styles.css.d.ts +4 -0
  46. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  47. package/dist/components/Stack/Stack.d.ts +2 -1
  48. package/dist/components/Stack/Stack.d.ts.map +1 -1
  49. package/dist/components/Stack/Stack.stories.d.ts.map +1 -1
  50. package/dist/components/StatusIcon/StatusIcon.d.ts +2 -2
  51. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  52. package/dist/components/Table/Table.d.ts +1 -7
  53. package/dist/components/Table/Table.d.ts.map +1 -1
  54. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  55. package/dist/components/Tabs/TabsContext.d.ts +2 -1
  56. package/dist/components/Tabs/TabsContext.d.ts.map +1 -1
  57. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts +2 -1
  58. package/dist/components/ToggleGroup/ToggleGroupContext.d.ts.map +1 -1
  59. package/dist/components/VirtualTable/VirtualTable.d.ts +2 -1
  60. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  61. package/dist/hooks/useContainerQuery.d.ts +3 -2
  62. package/dist/hooks/useContainerQuery.d.ts.map +1 -1
  63. package/dist/index.css +1 -1
  64. package/dist/index.js +2120 -1983
  65. package/dist/index.js.map +1 -1
  66. package/dist/shared/types.d.ts +2 -0
  67. package/dist/shared/types.d.ts.map +1 -0
  68. package/dist/table.js +137 -139
  69. package/dist/table.js.map +1 -1
  70. package/package.json +3 -2
  71. package/src/components/ActionBar/ActionBar.tsx +10 -5
  72. package/src/components/Alert/Alert.tsx +2 -2
  73. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +3 -2
  74. package/src/components/ButtonGroup/ButtonGroup.tsx +5 -2
  75. package/src/components/Callout/Callout.tsx +2 -2
  76. package/src/components/Cluster/Cluster.stories.tsx +2 -1
  77. package/src/components/Cluster/Cluster.tsx +3 -3
  78. package/src/components/ColorMode/ColorMode.tsx +4 -4
  79. package/src/components/Combobox/Combobox.stories.tsx +7 -3
  80. package/src/components/ConfirmDialog/ConfirmDialog.tsx +1 -3
  81. package/src/components/DetailPanel/DetailPanel.tsx +23 -6
  82. package/src/components/DetailPanel/styles.css.ts +14 -7
  83. package/src/components/Dialog/Dialog.tsx +26 -5
  84. package/src/components/Dialog/styles.css.ts +12 -4
  85. package/src/components/Drawer/Drawer.tsx +17 -4
  86. package/src/components/Drawer/styles.css.ts +12 -4
  87. package/src/components/EmptyState/EmptyState.meta.ts +1 -1
  88. package/src/components/EmptyState/EmptyState.stories.tsx +2 -2
  89. package/src/components/Fieldset/Fieldset.tsx +3 -2
  90. package/src/components/Grid/Grid.stories.tsx +2 -1
  91. package/src/components/Grid/Grid.tsx +3 -3
  92. package/src/components/Icon/Icon.meta.ts +3 -3
  93. package/src/components/Icon/Icon.tsx +63 -2
  94. package/src/components/Icon/index.ts +1 -1
  95. package/src/components/Inline/Inline.stories.tsx +2 -1
  96. package/src/components/Inline/Inline.tsx +3 -3
  97. package/src/components/SideNav/SideNav.meta.ts +13 -10
  98. package/src/components/SideNav/SideNav.stories.tsx +67 -8
  99. package/src/components/SideNav/SideNav.tsx +50 -7
  100. package/src/components/SideNav/styles.css.ts +7 -0
  101. package/src/components/Stack/Stack.stories.tsx +3 -2
  102. package/src/components/Stack/Stack.tsx +3 -2
  103. package/src/components/StatusIcon/StatusIcon.stories.tsx +1 -1
  104. package/src/components/StatusIcon/StatusIcon.tsx +3 -3
  105. package/src/components/Table/Table.stories.tsx +29 -32
  106. package/src/components/Table/Table.tsx +0 -13
  107. package/src/components/Tabs/TabsContext.ts +2 -1
  108. package/src/components/Toast/ToastProvider.tsx +2 -2
  109. package/src/components/Toggle/Toggle.meta.ts +1 -1
  110. package/src/components/ToggleGroup/ToggleGroupContext.ts +2 -1
  111. package/src/components/VirtualTable/VirtualTable.tsx +6 -5
  112. package/src/hooks/useContainerQuery.ts +10 -3
  113. package/src/shared/types.ts +3 -0
  114. package/dist/Table-xlMa_Kqe.js.map +0 -1
@@ -2,33 +2,36 @@ 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
- <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>Identities</SideNav.Item>
27
- <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>Grants</SideNav.Item>
29
+ <SideNav.Item value="identities" icon={<Icon name="users" size="md" />}>Identities</SideNav.Item>
30
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>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
- <SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>Plugins</SideNav.Item>
34
+ <SideNav.Item value="plugins" icon={<Icon name="plug" size="md" />}>Plugins</SideNav.Item>
32
35
  </SideNav.Group>
33
36
  </SideNav.Root>`,
34
37
  }
@@ -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="md" />}>
62
+ Identities
63
+ </SideNav.Item>
64
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>
65
+ Grants
66
+ </SideNav.Item>
67
+ </SideNav.Section>
68
+ <SideNav.Section label="Audit">
69
+ <SideNav.Item value="audit" icon={<Icon name="file-text" size="md" />}>
70
+ Audit log
71
+ </SideNav.Item>
72
+ </SideNav.Section>
73
+ <SideNav.Group label="Advanced">
74
+ <SideNav.Item value="plugins" icon={<Icon name="plug" size="md" />}>
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
 
@@ -106,18 +165,18 @@ export const FlatWithIcons: Story = {
106
165
  render: (args) => (
107
166
  <SideNav.Root {...args} defaultValue="grants">
108
167
  <SideNav.Section label="People & access">
109
- <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
168
+ <SideNav.Item value="identities" icon={<Icon name="users" size="md" />}>
110
169
  Identities
111
170
  </SideNav.Item>
112
- <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
171
+ <SideNav.Item value="grants" icon={<Icon name="key" size="md" />}>
113
172
  Grants
114
173
  </SideNav.Item>
115
174
  </SideNav.Section>
116
175
  <SideNav.Section label="Requests & invites">
117
- <SideNav.Item value="invitations" icon={<Icon name="mail" size={18} />}>
176
+ <SideNav.Item value="invitations" icon={<Icon name="mail" size="md" />}>
118
177
  Invitations
119
178
  </SideNav.Item>
120
- <SideNav.Item value="invites" icon={<Icon name="user-plus" size={18} />}>
179
+ <SideNav.Item value="invites" icon={<Icon name="user-plus" size="md" />}>
121
180
  User Invites
122
181
  </SideNav.Item>
123
182
  </SideNav.Section>
@@ -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',
@@ -3,6 +3,7 @@ import {css, html} from 'react-strict-dom'
3
3
  import {Stack} from './Stack'
4
4
  import {colors} from '@duro-app/tokens/tokens/colors.css'
5
5
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
6
+ import {SPACING_KEYS} from '@duro-app/tokens/keys'
6
7
 
7
8
  const meta: Meta<typeof Stack> = {
8
9
  title: 'Layout/Stack',
@@ -10,7 +11,7 @@ const meta: Meta<typeof Stack> = {
10
11
  argTypes: {
11
12
  gap: {
12
13
  control: 'select',
13
- options: ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'],
14
+ options: [...SPACING_KEYS],
14
15
  },
15
16
  align: {
16
17
  control: 'select',
@@ -87,7 +88,7 @@ export const CenterAligned: Story = {
87
88
 
88
89
  export const AllGaps: Story = {
89
90
  render: () => {
90
- const gaps = ['xs', 'sm', 'ms', 'md', 'lg', 'xl', 'xxl', 'xxxl'] as const
91
+ const gaps = SPACING_KEYS
91
92
  return (
92
93
  <Stack gap="xl">
93
94
  {gaps.map((g) => (
@@ -1,8 +1,9 @@
1
1
  import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
+ import type {SpacingToken} from '@duro-app/tokens/keys'
3
4
  import {styles} from './styles.css'
4
5
 
5
- export type SpacingKey = 'xs' | 'sm' | 'ms' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'
6
+ export type SpacingKey = SpacingToken
6
7
 
7
8
  interface StackProps {
8
9
  gap?: SpacingKey
@@ -19,7 +20,7 @@ const gapMap = {
19
20
  xl: styles.gapXl,
20
21
  xxl: styles.gapXxl,
21
22
  xxxl: styles.gapXxxl,
22
- } as const
23
+ } as const satisfies Record<SpacingToken, unknown>
23
24
 
24
25
  const alignMap = {
25
26
  start: styles.alignStart,
@@ -43,7 +43,7 @@ export const CheckDone: Story = {
43
43
  }
44
44
 
45
45
  export const CustomSize: Story = {
46
- args: {name: 'check-circle', variant: 'success', size: 24},
46
+ args: {name: 'check-circle', variant: 'success', size: 'lg'},
47
47
  }
48
48
 
49
49
  const gridStyles = css.create({
@@ -1,6 +1,6 @@
1
1
  import {html} from 'react-strict-dom'
2
2
  import {Icon} from '../Icon'
3
- import type {IconName} from '../Icon'
3
+ import type {IconName, IconSize} from '../Icon'
4
4
  import {styles} from './styles.css'
5
5
 
6
6
  export type StatusIconName = Extract<
@@ -11,11 +11,11 @@ export type StatusIconVariant = 'error' | 'success' | 'warning' | 'info' | 'mute
11
11
 
12
12
  interface StatusIconProps {
13
13
  name: StatusIconName
14
- size?: number
14
+ size?: IconSize
15
15
  variant?: StatusIconVariant
16
16
  }
17
17
 
18
- export function StatusIcon({name, size = 48, variant = 'muted'}: StatusIconProps) {
18
+ export function StatusIcon({name, size = 'xxl', variant = 'muted'}: StatusIconProps) {
19
19
  return (
20
20
  <html.div style={[styles.root, styles[variant]]}>
21
21
  <Icon name={name} size={size} />
@@ -920,39 +920,36 @@ export const Responsive: Story = {
920
920
  ]
921
921
  return (
922
922
  <html.div style={responsiveStyles.resizable}>
923
- <Table.Container>
924
- <Table.SortChip options={sortable} value={sort} onChange={setSort} />
925
- <Table.Root>
926
- <Table.Header>
927
- <Table.Row>
928
- <Table.HeaderCell label="Principal">Principal</Table.HeaderCell>
929
- <Table.HeaderCell label="Role">Role</Table.HeaderCell>
930
- <Table.HeaderCell label="Resource">Resource</Table.HeaderCell>
931
- <Table.HeaderCell label="Expires">Expires</Table.HeaderCell>
932
- <Table.HeaderCell label="Granted by">Granted by</Table.HeaderCell>
933
- <Table.HeaderCell label="Actions" isActions>
934
- Actions
935
- </Table.HeaderCell>
923
+ <Table.Root
924
+ sortChip={<Table.SortChip options={sortable} value={sort} onChange={setSort} />}
925
+ >
926
+ <Table.Header>
927
+ <Table.Row>
928
+ <Table.HeaderCell label="Principal">Principal</Table.HeaderCell>
929
+ <Table.HeaderCell label="Role">Role</Table.HeaderCell>
930
+ <Table.HeaderCell label="Resource">Resource</Table.HeaderCell>
931
+ <Table.HeaderCell label="Expires">Expires</Table.HeaderCell>
932
+ <Table.HeaderCell label="Granted by">Granted by</Table.HeaderCell>
933
+ <Table.HeaderCell label="Actions">Actions</Table.HeaderCell>
934
+ </Table.Row>
935
+ </Table.Header>
936
+ <Table.Body>
937
+ {grants.map((g) => (
938
+ <Table.Row key={g.principal}>
939
+ <Table.Cell>{g.principal}</Table.Cell>
940
+ <Table.Cell>{g.role}</Table.Cell>
941
+ <Table.Cell>{g.resource}</Table.Cell>
942
+ <Table.Cell>{g.expires}</Table.Cell>
943
+ <Table.Cell>{g.granted_by}</Table.Cell>
944
+ <Table.Cell isActions>
945
+ <Button size="small" variant="secondary">
946
+ Revoke
947
+ </Button>
948
+ </Table.Cell>
936
949
  </Table.Row>
937
- </Table.Header>
938
- <Table.Body>
939
- {grants.map((g) => (
940
- <Table.Row key={g.principal}>
941
- <Table.Cell>{g.principal}</Table.Cell>
942
- <Table.Cell>{g.role}</Table.Cell>
943
- <Table.Cell>{g.resource}</Table.Cell>
944
- <Table.Cell>{g.expires}</Table.Cell>
945
- <Table.Cell>{g.granted_by}</Table.Cell>
946
- <Table.Cell isActions>
947
- <Button size="small" variant="secondary">
948
- Revoke
949
- </Button>
950
- </Table.Cell>
951
- </Table.Row>
952
- ))}
953
- </Table.Body>
954
- </Table.Root>
955
- </Table.Container>
950
+ ))}
951
+ </Table.Body>
952
+ </Table.Root>
956
953
  </html.div>
957
954
  )
958
955
  },
@@ -445,7 +445,6 @@ export function HeaderCell({
445
445
  width: _width,
446
446
  compactWidth: _compactWidth,
447
447
  label: _label,
448
- isActions,
449
448
  'aria-label': ariaLabel,
450
449
  }: {
451
450
  children?: ReactNode
@@ -465,20 +464,8 @@ export function HeaderCell({
465
464
  * text content is used as the label automatically. Required when children
466
465
  * contain JSX (icon + text, sort indicator, etc.). */
467
466
  label?: string
468
- /**
469
- * @deprecated Has no effect on HeaderCell. Pass `isActions` on the
470
- * matching `Table.Cell` instead — that's where the stack-mode footer
471
- * layout actually applies.
472
- */
473
- isActions?: boolean
474
467
  'aria-label'?: string
475
468
  }) {
476
- if (isActions === true) {
477
- devWarnOnce(
478
- 'headerCell-isActions',
479
- 'HeaderCell `isActions` prop has no effect and is deprecated. Pass `isActions` on the matching Table.Cell instead.',
480
- )
481
- }
482
469
  const {size, variant} = useTable()
483
470
  const {index, total} = useContext(CellIndexContext)
484
471
  const isLast = variant === 'bordered' && index === total - 1
@@ -1,6 +1,7 @@
1
1
  import {createContext, useContext} from 'react'
2
+ import type {Orientation} from '../../shared/types'
2
3
 
3
- export type Orientation = 'horizontal' | 'vertical'
4
+ export type {Orientation}
4
5
 
5
6
  export interface TabsContextValue {
6
7
  activeValue: string | null
@@ -123,7 +123,7 @@ function ToastItem({entry, onDismiss}: {entry: ToastEntry; onDismiss: (id: strin
123
123
  style={[styles.toast, styles[variant]]}
124
124
  >
125
125
  <html.div style={[styles.iconWrap, variantIconStyle[variant]]}>
126
- <Icon name={variantIcon[variant]} size={18} />
126
+ <Icon name={variantIcon[variant]} size="md" />
127
127
  </html.div>
128
128
  <html.div style={styles.content}>
129
129
  <html.div style={styles.message}>{message}</html.div>
@@ -146,7 +146,7 @@ function ToastItem({entry, onDismiss}: {entry: ToastEntry; onDismiss: (id: strin
146
146
  style={styles.closeBtn}
147
147
  onClick={() => onDismiss(id)}
148
148
  >
149
- <Icon name="x-circle" size={16} />
149
+ <Icon name="x-circle" size="sm" />
150
150
  </html.button>
151
151
  </html.div>
152
152
  )
@@ -19,7 +19,7 @@ export const meta: ComponentMeta = {
19
19
  ],
20
20
  example: `// Standalone
21
21
  <Toggle pressed={isBold} onPressedChange={setIsBold} aria-label="Bold">
22
- <Icon name="key" size={16} />
22
+ <Icon name="key" size="sm" />
23
23
  </Toggle>
24
24
 
25
25
  // In a group
@@ -1,7 +1,8 @@
1
1
  import {createContext, useContext} from 'react'
2
2
  import type {ToggleSize} from '../Toggle/Toggle'
3
+ import type {Orientation} from '../../shared/types'
3
4
 
4
- export type Orientation = 'horizontal' | 'vertical'
5
+ export type {Orientation}
5
6
 
6
7
  export interface ToggleGroupContextValue {
7
8
  value: string[]
@@ -11,7 +11,7 @@ import {
11
11
  type Row,
12
12
  } from '@tanstack/react-table'
13
13
  import {useVirtualizer} from '@tanstack/react-virtual'
14
- import {breakpointsPx} from '@duro-app/tokens/tokens/breakpoints.css'
14
+ import {breakpointsPx, type Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
15
15
  import {styles} from './styles.css'
16
16
 
17
17
  // Measure + commit before paint on the client; no-op-safe on the server.
@@ -67,7 +67,7 @@ interface VirtualTableProps<TData> {
67
67
  * line. Cards disable windowing (variable heights), so keep phone lists
68
68
  * reasonably short. Set `responsive={false}` to disable entirely.
69
69
  */
70
- stackBelow?: number
70
+ stackBelow?: Breakpoint | number
71
71
  /** Opt out of the card-up-on-narrow behavior. Default true. */
72
72
  responsive?: boolean
73
73
  emptyLabel?: ReactNode
@@ -98,7 +98,7 @@ export function VirtualTable<TData>({
98
98
  estimateRowHeight = 44,
99
99
  maxHeight = '70vh',
100
100
  virtualizeThreshold = 150,
101
- stackBelow = breakpointsPx.sm,
101
+ stackBelow = 'sm',
102
102
  responsive = true,
103
103
  emptyLabel,
104
104
  }: VirtualTableProps<TData>) {
@@ -125,6 +125,7 @@ export function VirtualTable<TData>({
125
125
  // so `virtualize` is forced off while stacked.
126
126
  const wrapRef = useRef<HTMLDivElement>(null)
127
127
  const [stacked, setStacked] = useState(false)
128
+ const stackBelowPx = typeof stackBelow === 'number' ? stackBelow : breakpointsPx[stackBelow]
128
129
  useIsoLayoutEffect(() => {
129
130
  if (!responsive) {
130
131
  setStacked(false)
@@ -133,7 +134,7 @@ export function VirtualTable<TData>({
133
134
  const el = wrapRef.current
134
135
  if (!el || typeof ResizeObserver === 'undefined') return
135
136
  const apply = (width: number) => {
136
- if (width > 0) setStacked(width < stackBelow)
137
+ if (width > 0) setStacked(width < stackBelowPx)
137
138
  }
138
139
  apply(el.getBoundingClientRect().width)
139
140
  const ro = new ResizeObserver((entries) => {
@@ -144,7 +145,7 @@ export function VirtualTable<TData>({
144
145
  })
145
146
  ro.observe(el)
146
147
  return () => ro.disconnect()
147
- }, [responsive, stackBelow])
148
+ }, [responsive, stackBelowPx])
148
149
 
149
150
  const virtualize = rows.length > virtualizeThreshold && !stacked
150
151
 
@@ -1,10 +1,15 @@
1
1
  import {useRef, useState, useEffect} from 'react'
2
+ import {breakpointsPx, type Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
2
3
 
3
4
  export type ContainerSize = 'compact' | 'default' | 'spacious'
4
5
 
5
6
  interface UseContainerQueryOptions {
6
- compactBelow?: number
7
- spaciousAbove?: number
7
+ compactBelow?: Breakpoint | number
8
+ spaciousAbove?: Breakpoint | number
9
+ }
10
+
11
+ function toPx(value: Breakpoint | number): number {
12
+ return typeof value === 'number' ? value : breakpointsPx[value]
8
13
  }
9
14
 
10
15
  export function useContainerQuery<T extends HTMLElement = HTMLElement>(
@@ -13,7 +18,9 @@ export function useContainerQuery<T extends HTMLElement = HTMLElement>(
13
18
  ref: React.RefObject<T | null>
14
19
  size: ContainerSize
15
20
  } {
16
- const {compactBelow = 480, spaciousAbove = 768} = options
21
+ const {compactBelow: compactOpt = 'xs', spaciousAbove: spaciousOpt = 'md'} = options
22
+ const compactBelow = toPx(compactOpt)
23
+ const spaciousAbove = toPx(spaciousOpt)
17
24
  const ref = useRef<T | null>(null)
18
25
  const [size, setSize] = useState<ContainerSize>('default')
19
26
 
@@ -0,0 +1,3 @@
1
+ // Cross-component prop types that don't belong to any single component.
2
+
3
+ export type Orientation = 'horizontal' | 'vertical'