@duro-app/ui 0.40.0 → 0.42.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 (33) hide show
  1. package/dist/components/Icon/Icon.d.ts +1 -1
  2. package/dist/components/Icon/Icon.d.ts.map +1 -1
  3. package/dist/components/SideNav/SideNav.d.ts +16 -1
  4. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  5. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  6. package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
  7. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  8. package/dist/components/SideNav/styles.css.d.ts +48 -0
  9. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  10. package/dist/components/VirtualTable/VirtualTable.d.ts +57 -0
  11. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -0
  12. package/dist/components/VirtualTable/VirtualTable.meta.d.ts +3 -0
  13. package/dist/components/VirtualTable/VirtualTable.meta.d.ts.map +1 -0
  14. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +14 -0
  15. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -0
  16. package/dist/components/VirtualTable/styles.css.d.ts +128 -0
  17. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -0
  18. package/dist/index.css +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2286 -1936
  22. package/dist/index.js.map +1 -1
  23. package/package.json +3 -2
  24. package/src/components/Icon/Icon.tsx +46 -0
  25. package/src/components/SideNav/SideNav.meta.ts +17 -11
  26. package/src/components/SideNav/SideNav.stories.tsx +47 -3
  27. package/src/components/SideNav/SideNav.tsx +34 -2
  28. package/src/components/SideNav/styles.css.ts +51 -1
  29. package/src/components/VirtualTable/VirtualTable.meta.ts +43 -0
  30. package/src/components/VirtualTable/VirtualTable.stories.tsx +89 -0
  31. package/src/components/VirtualTable/VirtualTable.tsx +275 -0
  32. package/src/components/VirtualTable/styles.css.ts +135 -0
  33. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,7 +54,8 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@duro-app/tokens": "^0.40.0"
57
+ "@tanstack/react-virtual": "^3.14.6",
58
+ "@duro-app/tokens": "^0.42.0"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@babel/preset-typescript": "^7.28.0",
@@ -21,6 +21,12 @@ export type IconName =
21
21
  | 'git-branch'
22
22
  | 'menu'
23
23
  | 'pin'
24
+ // People / access / admin glyphs
25
+ | 'users'
26
+ | 'user-plus'
27
+ | 'mail'
28
+ | 'file-text'
29
+ | 'plug'
24
30
  // Color-mode glyphs
25
31
  | 'sun'
26
32
  | 'moon'
@@ -153,6 +159,46 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
153
159
  <line x1="3" y1="18" x2="21" y2="18" />
154
160
  </>
155
161
  ),
162
+ // ---- people / access / admin ----
163
+ users: (
164
+ <>
165
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
166
+ <circle cx="9" cy="7" r="4" />
167
+ <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
168
+ <path d="M16 3.13a4 4 0 0 1 0 7.75" />
169
+ </>
170
+ ),
171
+ 'user-plus': (
172
+ <>
173
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
174
+ <circle cx="8.5" cy="7" r="4" />
175
+ <line x1="20" y1="8" x2="20" y2="14" />
176
+ <line x1="23" y1="11" x2="17" y2="11" />
177
+ </>
178
+ ),
179
+ mail: (
180
+ <>
181
+ <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
182
+ <polyline points="22,6 12,13 2,6" />
183
+ </>
184
+ ),
185
+ 'file-text': (
186
+ <>
187
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
188
+ <polyline points="14 2 14 8 20 8" />
189
+ <line x1="16" y1="13" x2="8" y2="13" />
190
+ <line x1="16" y1="17" x2="8" y2="17" />
191
+ <polyline points="10 9 9 9 8 9" />
192
+ </>
193
+ ),
194
+ plug: (
195
+ <>
196
+ <path d="M12 22v-5" />
197
+ <path d="M9 8V2" />
198
+ <path d="M15 8V2" />
199
+ <path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
200
+ </>
201
+ ),
156
202
  pin: (
157
203
  <>
158
204
  <line x1="12" y1="17" x2="12" y2="22" />
@@ -2,27 +2,33 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Vertical side navigation with collapsible groups. Supports controlled and uncontrolled active item. Compound component — Root is required.',
6
- whenToUse: ['App-level sidebar navigation', 'Settings navigation with grouped sections'],
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.',
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',
10
+ ],
7
11
  whenNotToUse: [
8
12
  'In-page content switching — use Tabs',
9
13
  'Top-level horizontal nav — use Inline with LinkButton',
10
14
  ],
11
15
  anatomy: {
12
- required: ['Root', 'Group', 'Item'],
16
+ required: ['Root', 'Item'],
17
+ optional: ['Group', 'Section'],
13
18
  },
14
19
  relatedTo: [
15
20
  {component: 'Tabs', relationship: 'Tabs switch content in-place; SideNav navigates pages'},
16
21
  {component: 'PageShell', relationship: 'Often placed alongside PageShell'},
17
22
  ],
18
- example: `<SideNav.Root defaultValue="dashboard">
19
- <SideNav.Group label="Main">
20
- <SideNav.Item value="dashboard">Dashboard</SideNav.Item>
21
- <SideNav.Item value="analytics">Analytics</SideNav.Item>
22
- </SideNav.Group>
23
- <SideNav.Group label="Settings" defaultExpanded={false}>
24
- <SideNav.Item value="profile">Profile</SideNav.Item>
25
- <SideNav.Item value="billing">Billing</SideNav.Item>
23
+ example: `<SideNav.Root defaultValue="identities">
24
+ {/* Static, always-open sections with icons (flat menu) */}
25
+ <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>
28
+ </SideNav.Section>
29
+ {/* Or collapsible groups (accordion) */}
30
+ <SideNav.Group label="Advanced" defaultExpanded={false}>
31
+ <SideNav.Item value="plugins" icon={<Icon name="plug" size={18} />}>Plugins</SideNav.Item>
26
32
  </SideNav.Group>
27
33
  </SideNav.Root>`,
28
34
  }
@@ -1,6 +1,7 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn} from 'storybook/test'
3
3
  import {SideNav} from './SideNav'
4
+ import {Icon} from '../Icon/Icon'
4
5
 
5
6
  const meta: Meta<typeof SideNav.Root> = {
6
7
  title: 'Components/SideNav',
@@ -31,7 +32,7 @@ export const Default: Story = {
31
32
  const nav = canvas.getByRole('navigation')
32
33
  await expect(nav).toBeInTheDocument()
33
34
 
34
- const activeItem = canvas.getByText('Dashboard')
35
+ const activeItem = canvas.getByRole('button', {name: 'Dashboard'})
35
36
  await expect(activeItem).toHaveAttribute('aria-current', 'page')
36
37
  },
37
38
  }
@@ -74,7 +75,7 @@ export const DefaultExpandedWithActiveItem: Story = {
74
75
  play: async ({canvas}) => {
75
76
  const trigger = canvas.getByText('Settings')
76
77
  await expect(trigger).toHaveAttribute('aria-expanded', 'true')
77
- const active = canvas.getByText('Security')
78
+ const active = canvas.getByRole('button', {name: 'Security'})
78
79
  await expect(active).toBeInTheDocument()
79
80
  await expect(active).toHaveAttribute('aria-current', 'page')
80
81
  },
@@ -91,9 +92,52 @@ export const Interactive: Story = {
91
92
  </SideNav.Root>
92
93
  ),
93
94
  play: async ({canvas, userEvent, args}) => {
94
- const aboutItem = canvas.getByText('About')
95
+ const aboutItem = canvas.getByRole('button', {name: 'About'})
95
96
  await userEvent.click(aboutItem)
96
97
  await expect(args.onValueChange).toHaveBeenCalledWith('about')
97
98
  await expect(aboutItem).toHaveAttribute('aria-current', 'page')
98
99
  },
99
100
  }
101
+
102
+ // Flat menu: static (non-collapsible) Section headers + per-item icons + the
103
+ // active left-marker. This is the AppChrome-style layout — no chevrons, every
104
+ // section always open.
105
+ export const FlatWithIcons: Story = {
106
+ render: (args) => (
107
+ <SideNav.Root {...args} defaultValue="grants">
108
+ <SideNav.Section label="People & access">
109
+ <SideNav.Item value="identities" icon={<Icon name="users" size={18} />}>
110
+ Identities
111
+ </SideNav.Item>
112
+ <SideNav.Item value="grants" icon={<Icon name="key" size={18} />}>
113
+ Grants
114
+ </SideNav.Item>
115
+ </SideNav.Section>
116
+ <SideNav.Section label="Requests & invites">
117
+ <SideNav.Item value="invitations" icon={<Icon name="mail" size={18} />}>
118
+ Invitations
119
+ </SideNav.Item>
120
+ <SideNav.Item value="invites" icon={<Icon name="user-plus" size={18} />}>
121
+ User Invites
122
+ </SideNav.Item>
123
+ </SideNav.Section>
124
+ </SideNav.Root>
125
+ ),
126
+ play: async ({canvas, userEvent, args}) => {
127
+ // Static section headers render as plain text (no toggle button).
128
+ await expect(canvas.getByText('People & access')).toBeInTheDocument()
129
+ await expect(canvas.queryByRole('button', {name: 'People & access'})).toBeNull()
130
+
131
+ // All items are visible immediately (nothing is collapsed).
132
+ const grants = canvas.getByRole('button', {name: 'Grants'})
133
+ await expect(grants).toHaveAttribute('aria-current', 'page')
134
+ await expect(canvas.getByRole('button', {name: 'Identities'})).toBeInTheDocument()
135
+ await expect(canvas.getByRole('button', {name: 'User Invites'})).toBeInTheDocument()
136
+
137
+ // Selecting another item moves the active state.
138
+ const identities = canvas.getByRole('button', {name: 'Identities'})
139
+ await userEvent.click(identities)
140
+ await expect(args.onValueChange).toHaveBeenCalledWith('identities')
141
+ await expect(identities).toHaveAttribute('aria-current', 'page')
142
+ },
143
+ }
@@ -150,14 +150,39 @@ function Group({children, label, groupKey, defaultExpanded}: GroupProps) {
150
150
  )
151
151
  }
152
152
 
153
+ // --- Section ---
154
+
155
+ interface SectionProps {
156
+ children: ReactNode
157
+ label: string
158
+ }
159
+
160
+ /**
161
+ * 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).
165
+ */
166
+ function Section({children, label}: SectionProps) {
167
+ return (
168
+ <html.div style={styles.section}>
169
+ <html.div style={styles.sectionLabel}>{label}</html.div>
170
+ {children}
171
+ </html.div>
172
+ )
173
+ }
174
+
153
175
  // --- Item ---
154
176
 
155
177
  interface ItemProps {
156
178
  value: string
157
179
  children: ReactNode
180
+ /** Optional leading glyph (e.g. a design-system `<Icon />`). Inherits the
181
+ * item's text color, so it turns accent when the item is active. */
182
+ icon?: ReactNode
158
183
  }
159
184
 
160
- function Item({value, children}: ItemProps) {
185
+ function Item({value, children, icon}: ItemProps) {
161
186
  const {activeValue, onSelect, registerItem} = useSideNav()
162
187
  const isActive = activeValue === value
163
188
 
@@ -173,7 +198,13 @@ function Item({value, children}: ItemProps) {
173
198
  style={[styles.item, isActive && styles.itemActive]}
174
199
  aria-current={isActive ? 'page' : undefined}
175
200
  >
176
- {children}
201
+ <html.span aria-hidden style={[styles.marker, isActive && styles.markerActive]} />
202
+ {icon ? (
203
+ <html.span aria-hidden style={styles.itemIcon}>
204
+ {icon}
205
+ </html.span>
206
+ ) : null}
207
+ <html.span style={styles.itemLabel}>{children}</html.span>
177
208
  </html.button>
178
209
  )
179
210
  }
@@ -181,5 +212,6 @@ function Item({value, children}: ItemProps) {
181
212
  export const SideNav = {
182
213
  Root,
183
214
  Group,
215
+ Section,
184
216
  Item,
185
217
  }
@@ -51,12 +51,31 @@ export const styles = css.create({
51
51
  chevronOpen: {
52
52
  transform: 'rotate(90deg)',
53
53
  },
54
+ // Static (non-collapsible) section — see SideNav.Section.
55
+ section: {
56
+ display: 'flex',
57
+ flexDirection: 'column',
58
+ },
59
+ sectionLabel: {
60
+ paddingTop: spacing.md,
61
+ paddingBottom: spacing.xs,
62
+ paddingLeft: spacing.md,
63
+ paddingRight: spacing.md,
64
+ fontFamily: typography.fontFamily,
65
+ fontSize: typography.fontSizeXs,
66
+ fontWeight: typography.fontWeightSemibold,
67
+ textTransform: 'uppercase' as const,
68
+ letterSpacing: '0.06em',
69
+ color: colors.textMuted,
70
+ },
54
71
  item: {
72
+ position: 'relative',
55
73
  display: 'flex',
56
74
  alignItems: 'center',
75
+ gap: spacing.sm,
57
76
  paddingTop: '6px',
58
77
  paddingBottom: '6px',
59
- paddingLeft: spacing.lg,
78
+ paddingLeft: spacing.md,
60
79
  paddingRight: spacing.md,
61
80
  fontFamily: typography.fontFamily,
62
81
  fontSize: typography.fontSizeSm,
@@ -92,5 +111,36 @@ export const styles = css.create({
92
111
  itemActive: {
93
112
  color: colors.accent,
94
113
  fontWeight: typography.fontWeightMedium,
114
+ backgroundColor: colors.bgCardHover,
115
+ },
116
+ // Active indicator — a bar at the rail's left edge that grows in on select.
117
+ marker: {
118
+ position: 'absolute',
119
+ left: 0,
120
+ top: '50%',
121
+ width: '3px',
122
+ height: 0,
123
+ transform: 'translateY(-50%)',
124
+ borderTopRightRadius: radii.full,
125
+ borderBottomRightRadius: radii.full,
126
+ backgroundColor: colors.accent,
127
+ transitionProperty: 'height',
128
+ transitionDuration: duration.fast,
129
+ transitionTimingFunction: easing.standard,
130
+ },
131
+ markerActive: {
132
+ height: '18px',
133
+ },
134
+ itemIcon: {
135
+ display: 'inline-flex',
136
+ alignItems: 'center',
137
+ justifyContent: 'center',
138
+ flexShrink: 0,
139
+ width: '18px',
140
+ height: '18px',
141
+ },
142
+ itemLabel: {
143
+ flexGrow: 1,
144
+ minWidth: 0,
95
145
  },
96
146
  })
@@ -0,0 +1,43 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Sortable data table that windows its rows above a threshold (default 150) with @tanstack/react-virtual, shows a floating position indicator, and reports the visible page so the caller can mirror it in the URL. Below the threshold it renders every row in one scroll (no pagination). URL-decoupled: pass initialPage + onVisiblePageChange. Web-only.',
6
+ whenToUse: [
7
+ 'Long lists/tables (hundreds to tens of thousands of rows) held client-side',
8
+ 'When you want a scroll position reflected in the URL + a "rows X–Y of Z" indicator',
9
+ 'Admin tables that may grow — small today renders all rows, large windows automatically',
10
+ ],
11
+ whenNotToUse: [
12
+ 'Small static tables where semantic markup matters — use Table / Table.FromTanstack',
13
+ 'Server-paginated data (this windows an already-loaded array, it does not fetch on scroll)',
14
+ ],
15
+ anatomy: {
16
+ required: ['data', 'columns'],
17
+ optional: [
18
+ 'sorting',
19
+ 'onSortingChange',
20
+ 'initialPage',
21
+ 'onVisiblePageChange',
22
+ 'rangeLabel',
23
+ 'onRowClick',
24
+ ],
25
+ },
26
+ relatedTo: [
27
+ {
28
+ component: 'Table',
29
+ relationship: 'Table is semantic + paginated; VirtualTable windows large lists',
30
+ },
31
+ ],
32
+ example: `<VirtualTable
33
+ data={rows}
34
+ columns={columns}
35
+ sorting={sorting}
36
+ onSortingChange={setSorting}
37
+ initialPage={Number(params.get('page')) || 1}
38
+ onVisiblePageChange={({page}) =>
39
+ setParams((p) => { const n = new URLSearchParams(p); page > 1 ? n.set('page', String(page)) : n.delete('page'); return n }, {replace: true, preventScrollReset: true})}
40
+ rangeLabel={({from, to, total}) => t('table.range', {from, to, total})}
41
+ onRowClick={(row) => navigate(\`/things/\${row.id}\`)}
42
+ />`,
43
+ }
@@ -0,0 +1,89 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, waitFor} from 'storybook/test'
3
+ import {createColumnHelper} from '@tanstack/react-table'
4
+ import {VirtualTable} from './VirtualTable'
5
+
6
+ interface Item {
7
+ id: string
8
+ name: string
9
+ value: number
10
+ }
11
+
12
+ const col = createColumnHelper<Item>()
13
+ const columns = [
14
+ col.accessor('name', {header: 'Name', enableSorting: true}),
15
+ col.accessor('value', {header: 'Value', enableSorting: true}),
16
+ ]
17
+
18
+ const makeData = (n: number): Item[] =>
19
+ Array.from({length: n}, (_, i) => ({id: `r${i}`, name: `Row ${i}`, value: i}))
20
+
21
+ const meta: Meta<typeof VirtualTable<Item>> = {
22
+ title: 'Components/VirtualTable',
23
+ component: VirtualTable,
24
+ args: {columns, getRowId: (r: Item) => r.id},
25
+ }
26
+ export default meta
27
+ type Story = StoryObj<typeof VirtualTable<Item>>
28
+
29
+ // Below the threshold: every row rendered, one scroll, no windowing/indicator.
30
+ export const SmallList: Story = {
31
+ args: {data: makeData(10)},
32
+ play: async ({canvas}) => {
33
+ // 1 header row + 10 body rows.
34
+ await waitFor(() => expect(canvas.getAllByRole('row')).toHaveLength(11))
35
+ await expect(canvas.getByText('Row 0')).toBeInTheDocument()
36
+ await expect(canvas.getByText('Row 9')).toBeInTheDocument()
37
+ // No floating position indicator at this size.
38
+ await expect(canvas.queryByText(/\/ 10$/)).toBeNull()
39
+ },
40
+ }
41
+
42
+ // Above the threshold: only a window of rows hits the DOM + the indicator shows.
43
+ export const Virtualized: Story = {
44
+ args: {
45
+ data: makeData(500),
46
+ maxHeight: 300,
47
+ rangeLabel: ({from, to, total}) => `${from}-${to} of ${total}`,
48
+ onVisiblePageChange: fn(),
49
+ },
50
+ play: async ({canvas}) => {
51
+ // Windowed: far fewer than 500 body rows are in the DOM.
52
+ await waitFor(() => {
53
+ const rows = canvas.getAllByRole('row')
54
+ expect(rows.length).toBeGreaterThan(1) // header + some
55
+ expect(rows.length).toBeLessThan(80) // NOT 501 — proof of windowing
56
+ })
57
+ // The floating position indicator is present, starting at row 1.
58
+ await expect(canvas.getByText(/^1-\d+ of 500$/)).toBeInTheDocument()
59
+ },
60
+ }
61
+
62
+ // Scrolling reports a new page (for URL sync) once past a page boundary.
63
+ export const ScrollReportsPage: Story = {
64
+ args: {
65
+ data: makeData(500),
66
+ maxHeight: 300,
67
+ pageSize: 50,
68
+ rangeLabel: ({page, pages}) => `page ${page}/${pages}`,
69
+ onVisiblePageChange: fn(),
70
+ },
71
+ play: async ({canvas, args}) => {
72
+ const scroller = canvas.getByRole('table')
73
+ await waitFor(() => expect(canvas.getByText('page 1/10')).toBeInTheDocument())
74
+ // Wait past the 300ms mount suppress-write window (which stops the restore
75
+ // scroll from clobbering the URL) before scrolling for real.
76
+ await new Promise((r) => setTimeout(r, 400))
77
+ // Scroll well past the first 50-row page (row height ~44 → page ≈ 2200px).
78
+ scroller.scrollTo({top: 6000})
79
+ scroller.dispatchEvent(new Event('scroll'))
80
+ // The indicator reflects the new page (proves curPage recomputed on scroll)…
81
+ await waitFor(() => expect(canvas.queryByText('page 1/10')).toBeNull(), {timeout: 3000})
82
+ await expect(canvas.getByText(/^page \d+\/10$/).textContent).not.toBe('page 1/10')
83
+ // …and the visible-page callback fired so a caller could sync the URL.
84
+ await waitFor(() => expect(args.onVisiblePageChange).toHaveBeenCalled(), {timeout: 4000})
85
+ const calls = (args.onVisiblePageChange as ReturnType<typeof fn>).mock.calls
86
+ const maxPage = Math.max(...calls.map((c) => (c[0] as {page: number}).page))
87
+ await expect(maxPage).toBeGreaterThan(1)
88
+ },
89
+ }