@duro-app/ui 0.21.0 → 0.22.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 (51) hide show
  1. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  2. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  3. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  4. package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -1
  5. package/dist/components/DetailPanel/styles.css.d.ts +4 -0
  6. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -1
  7. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
  8. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  9. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  10. package/dist/components/List/List.d.ts +46 -0
  11. package/dist/components/List/List.d.ts.map +1 -0
  12. package/dist/components/List/List.meta.d.ts +3 -0
  13. package/dist/components/List/List.meta.d.ts.map +1 -0
  14. package/dist/components/List/List.stories.d.ts +9 -0
  15. package/dist/components/List/List.stories.d.ts.map +1 -0
  16. package/dist/components/List/styles.css.d.ts +77 -0
  17. package/dist/components/List/styles.css.d.ts.map +1 -0
  18. package/dist/components/Panel/Panel.d.ts.map +1 -1
  19. package/dist/components/Panel/Panel.meta.d.ts.map +1 -1
  20. package/dist/components/Panel/Panel.stories.d.ts.map +1 -1
  21. package/dist/components/Panel/styles.css.d.ts +4 -0
  22. package/dist/components/Panel/styles.css.d.ts.map +1 -1
  23. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  24. package/dist/components/Tabs/Tabs.stories.d.ts.map +1 -1
  25. package/dist/components/Tooltip/Tooltip.stories.d.ts.map +1 -1
  26. package/dist/index.css +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1384 -1217
  30. package/dist/index.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/components/ActionBar/ActionBar.stories.tsx +10 -0
  33. package/src/components/DetailPanel/DetailPanel.meta.ts +4 -1
  34. package/src/components/DetailPanel/DetailPanel.stories.tsx +54 -27
  35. package/src/components/DetailPanel/DetailPanel.tsx +3 -16
  36. package/src/components/DetailPanel/styles.css.ts +5 -0
  37. package/src/components/Dialog/Dialog.meta.ts +8 -2
  38. package/src/components/Drawer/Drawer.meta.ts +4 -1
  39. package/src/components/Field/Field.stories.tsx +6 -0
  40. package/src/components/List/List.meta.ts +39 -0
  41. package/src/components/List/List.stories.tsx +200 -0
  42. package/src/components/List/List.tsx +118 -0
  43. package/src/components/List/styles.css.ts +99 -0
  44. package/src/components/Panel/Panel.meta.ts +12 -3
  45. package/src/components/Panel/Panel.stories.tsx +17 -10
  46. package/src/components/Panel/Panel.tsx +2 -10
  47. package/src/components/Panel/styles.css.ts +5 -0
  48. package/src/components/Table/Table.stories.tsx +16 -0
  49. package/src/components/Tabs/Tabs.stories.tsx +7 -0
  50. package/src/components/Tooltip/Tooltip.stories.tsx +15 -0
  51. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  }
53
53
  },
54
54
  "dependencies": {
55
- "@duro-app/tokens": "^0.21.0"
55
+ "@duro-app/tokens": "^0.22.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@babel/preset-typescript": "^7.28.0",
@@ -13,6 +13,16 @@ interface ActionBarStoryArgs {
13
13
 
14
14
  const meta: Meta<ActionBarStoryArgs> = {
15
15
  title: 'Components/ActionBar',
16
+ argTypes: {
17
+ selectedItemCount: {
18
+ control: 'number',
19
+ description: 'Number of selected items displayed in the action bar',
20
+ },
21
+ isEmphasized: {
22
+ control: 'boolean',
23
+ description: 'Whether the action bar uses the emphasized (inverse) style',
24
+ },
25
+ },
16
26
  }
17
27
 
18
28
  export default meta
@@ -18,7 +18,10 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Title', 'Footer', 'Close'],
19
19
  },
20
20
  relatedTo: [
21
- {component: 'Drawer', relationship: 'Drawer is modal with backdrop; DetailPanel is non-modal and in-flow'},
21
+ {
22
+ component: 'Drawer',
23
+ relationship: 'Drawer is modal with backdrop; DetailPanel is non-modal and in-flow',
24
+ },
22
25
  {component: 'Panel', relationship: 'DetailPanel uses Panel structure internally'},
23
26
  {component: 'Table', relationship: 'Common pattern: Table + DetailPanel for master-detail'},
24
27
  ],
@@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {useState} from 'react'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {DetailPanel} from './DetailPanel'
5
+ import {Panel} from '../Panel/Panel'
5
6
  import {Button} from '../Button/Button'
6
7
  import {Text} from '../Text/Text'
7
8
  import {Heading} from '../Heading/Heading'
@@ -56,9 +57,8 @@ function DefaultDemo() {
56
57
  </DetailPanel.Header>
57
58
  <DetailPanel.Body>
58
59
  <Text>
59
- This is a non-modal detail panel. The content behind it
60
- remains interactive — you can still click, scroll, and
61
- interact with the main area.
60
+ This is a non-modal detail panel. The content behind it remains interactive — you can
61
+ still click, scroll, and interact with the main area.
62
62
  </Text>
63
63
  </DetailPanel.Body>
64
64
  <DetailPanel.Footer>
@@ -137,9 +137,15 @@ function TableSelectionDemo() {
137
137
  <Heading level={3}>Users</Heading>
138
138
  <html.div>
139
139
  <html.div style={tableStyles.headerRow}>
140
- <html.span style={tableStyles.cellText}><Text variant="label">Name</Text></html.span>
141
- <html.span style={tableStyles.cellText}><Text variant="label">Role</Text></html.span>
142
- <html.span style={tableStyles.cellText}><Text variant="label">Status</Text></html.span>
140
+ <html.span style={tableStyles.cellText}>
141
+ <Text variant="label">Name</Text>
142
+ </html.span>
143
+ <html.span style={tableStyles.cellText}>
144
+ <Text variant="label">Role</Text>
145
+ </html.span>
146
+ <html.span style={tableStyles.cellText}>
147
+ <Text variant="label">Status</Text>
148
+ </html.span>
143
149
  </html.div>
144
150
  {users.map((user) => (
145
151
  <html.div
@@ -147,9 +153,15 @@ function TableSelectionDemo() {
147
153
  style={[tableStyles.row, selectedId === user.id && tableStyles.rowSelected]}
148
154
  onClick={() => setSelectedId(selectedId === user.id ? null : user.id)}
149
155
  >
150
- <html.span style={tableStyles.cellText}><Text>{user.name}</Text></html.span>
151
- <html.span style={tableStyles.cellText}><Text>{user.role}</Text></html.span>
152
- <html.span style={tableStyles.cellText}><Text>{user.status}</Text></html.span>
156
+ <html.span style={tableStyles.cellText}>
157
+ <Text>{user.name}</Text>
158
+ </html.span>
159
+ <html.span style={tableStyles.cellText}>
160
+ <Text>{user.role}</Text>
161
+ </html.span>
162
+ <html.span style={tableStyles.cellText}>
163
+ <Text>{user.status}</Text>
164
+ </html.span>
153
165
  </html.div>
154
166
  ))}
155
167
  </html.div>
@@ -170,22 +182,37 @@ function TableSelectionDemo() {
170
182
  <DetailPanel.Body>
171
183
  {selectedUser && (
172
184
  <Stack gap="md">
173
- <Stack gap="xs">
174
- <Text variant="label">Email</Text>
175
- <Text variant="bodySm">{selectedUser.email}</Text>
176
- </Stack>
177
- <Stack gap="xs">
178
- <Text variant="label">Role</Text>
179
- <Text variant="bodySm">{selectedUser.role}</Text>
180
- </Stack>
181
- <Stack gap="xs">
182
- <Text variant="label">Status</Text>
183
- <Inline gap="sm">
184
- <Badge variant={selectedUser.status === 'Active' ? 'success' : 'default'}>
185
- {selectedUser.status}
186
- </Badge>
187
- </Inline>
188
- </Stack>
185
+ <Panel.Root>
186
+ <Panel.Header>
187
+ <Text variant="label">Contact</Text>
188
+ </Panel.Header>
189
+ <Panel.Body>
190
+ <Stack gap="xs">
191
+ <Text variant="caption">Email</Text>
192
+ <Text variant="bodySm">{selectedUser.email}</Text>
193
+ </Stack>
194
+ </Panel.Body>
195
+ </Panel.Root>
196
+
197
+ <Panel.Root>
198
+ <Panel.Header>
199
+ <Text variant="label">Role &amp; Status</Text>
200
+ </Panel.Header>
201
+ <Panel.Body>
202
+ <Stack gap="sm">
203
+ <Inline gap="sm" align="center">
204
+ <Text variant="caption">Role</Text>
205
+ <Text variant="bodySm">{selectedUser.role}</Text>
206
+ </Inline>
207
+ <Inline gap="sm" align="center">
208
+ <Text variant="caption">Status</Text>
209
+ <Badge variant={selectedUser.status === 'Active' ? 'success' : 'default'}>
210
+ {selectedUser.status}
211
+ </Badge>
212
+ </Inline>
213
+ </Stack>
214
+ </Panel.Body>
215
+ </Panel.Root>
189
216
  </Stack>
190
217
  )}
191
218
  </DetailPanel.Body>
@@ -225,8 +252,8 @@ function MediumSizeDemo() {
225
252
  </DetailPanel.Header>
226
253
  <DetailPanel.Body>
227
254
  <Text>
228
- This panel is 480px wide, providing more room for complex
229
- content like forms or detailed information.
255
+ This panel is 480px wide, providing more room for complex content like forms or
256
+ detailed information.
230
257
  </Text>
231
258
  </DetailPanel.Body>
232
259
  </DetailPanel.Content>
@@ -194,12 +194,7 @@ function Content({children, size = 'sm', label}: ContentProps) {
194
194
  if (!present) return null
195
195
 
196
196
  return (
197
- <html.div
198
- style={[
199
- styles.wrapper,
200
- closing ? wrapperCloseMap[size] : wrapperOpenMap[size],
201
- ]}
202
- >
197
+ <html.div style={[styles.wrapper, closing ? wrapperCloseMap[size] : wrapperOpenMap[size]]}>
203
198
  <html.div
204
199
  ref={panelRef}
205
200
  role="complementary"
@@ -250,11 +245,7 @@ interface BodyProps {
250
245
  }
251
246
 
252
247
  function Body({children, padded = true}: BodyProps) {
253
- return (
254
- <html.div style={[styles.body, padded && styles.bodyPadded]}>
255
- {children}
256
- </html.div>
257
- )
248
+ return <html.div style={[styles.body, padded && styles.bodyPadded]}>{children}</html.div>
258
249
  }
259
250
 
260
251
  // --- Footer ---
@@ -282,11 +273,7 @@ function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
282
273
  }
283
274
 
284
275
  return (
285
- <html.button
286
- onClick={requestClose}
287
- aria-label={ariaLabel}
288
- style={styles.closeButton}
289
- >
276
+ <html.button onClick={requestClose} aria-label={ariaLabel} style={styles.closeButton}>
290
277
  <svg width={16} height={16} viewBox="0 0 16 16" fill="none">
291
278
  <path
292
279
  d="M4 4l8 8M12 4l-8 8"
@@ -137,6 +137,11 @@ export const styles = css.create({
137
137
  body: {
138
138
  flex: 1,
139
139
  overflowY: 'auto',
140
+ // Minimal inset prevents child borders from touching panel structural borders
141
+ paddingTop: spacing.sm,
142
+ paddingBottom: spacing.sm,
143
+ paddingLeft: spacing.sm,
144
+ paddingRight: spacing.sm,
140
145
  },
141
146
  bodyPadded: {
142
147
  paddingTop: spacing.lg,
@@ -20,8 +20,14 @@ export const meta: ComponentMeta = {
20
20
  optional: ['Trigger', 'Header', 'Title', 'Description', 'Body', 'Footer', 'Close'],
21
21
  },
22
22
  relatedTo: [
23
- {component: 'Drawer', relationship: 'Drawer slides from an edge; Dialog is centered with scale animation'},
24
- {component: 'DetailPanel', relationship: 'DetailPanel is non-modal and in-flow; Dialog is modal with backdrop'},
23
+ {
24
+ component: 'Drawer',
25
+ relationship: 'Drawer slides from an edge; Dialog is centered with scale animation',
26
+ },
27
+ {
28
+ component: 'DetailPanel',
29
+ relationship: 'DetailPanel is non-modal and in-flow; Dialog is modal with backdrop',
30
+ },
25
31
  ],
26
32
  example: `<Dialog.Root open={open} onOpenChange={setOpen}>
27
33
  <Dialog.Trigger>
@@ -21,7 +21,10 @@ export const meta: ComponentMeta = {
21
21
  },
22
22
  relatedTo: [
23
23
  {component: 'Dialog', relationship: 'Dialog is centered modal; Drawer slides from an edge'},
24
- {component: 'DetailPanel', relationship: 'DetailPanel is non-modal and in-flow; Drawer is modal with backdrop'},
24
+ {
25
+ component: 'DetailPanel',
26
+ relationship: 'DetailPanel is non-modal and in-flow; Drawer is modal with backdrop',
27
+ },
25
28
  ],
26
29
  example: `<Drawer.Root anchor="right" open={open} onOpenChange={setOpen}>
27
30
  <Drawer.Trigger>
@@ -6,6 +6,12 @@ import {Input} from '../Input/Input'
6
6
 
7
7
  const meta: Meta = {
8
8
  title: 'Components/Field',
9
+ argTypes: {
10
+ invalid: {
11
+ control: 'boolean',
12
+ description: 'Whether the field is in an invalid/error state',
13
+ },
14
+ },
9
15
  }
10
16
 
11
17
  export default meta
@@ -0,0 +1,39 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Vertical list of interactive items. Ideal for narrow spaces (detail panels, sidebars) where Table would truncate. Supports selection, descriptions, and action slots.',
6
+ whenToUse: [
7
+ 'Displaying items in a narrow container (< 480px)',
8
+ 'Detail panels, drawers, and sidebars',
9
+ 'Lists needing selection (single or multiple) with compact layout',
10
+ ],
11
+ whenNotToUse: [
12
+ 'Tabular data with many columns — use Table',
13
+ 'Navigation menus — use SideNav or Menu',
14
+ 'Simple text lists without interaction — use Stack with Text',
15
+ ],
16
+ anatomy: {
17
+ required: ['Root', 'Item', 'Content', 'Text'],
18
+ optional: ['Description', 'Actions', 'Empty'],
19
+ },
20
+ relatedTo: [
21
+ {component: 'Table', relationship: 'Table for wide multi-column data; List for narrow compact items'},
22
+ {component: 'DetailPanel', relationship: 'List fits naturally inside DetailPanel.Body'},
23
+ {component: 'Menu', relationship: 'Menu for action menus; List for data display with selection'},
24
+ ],
25
+ example: `<List.Root selectionMode="multiple" aria-label="Certificates">
26
+ <List.Item selected onClick={toggle}>
27
+ <Checkbox checked />
28
+ <List.Content>
29
+ <List.Text>web-server-prod-2024.pem</List.Text>
30
+ <List.Description>Issued: Jan 15, 2024 · Expires: Jan 15, 2025</List.Description>
31
+ </List.Content>
32
+ <List.Actions>
33
+ <Badge variant="success">Active</Badge>
34
+ <Button variant="danger" size="small">Revoke</Button>
35
+ </List.Actions>
36
+ </List.Item>
37
+ <List.Empty>No certificates found.</List.Empty>
38
+ </List.Root>`,
39
+ }
@@ -0,0 +1,200 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {useState} from 'react'
3
+ import {expect} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {List} from './List'
6
+ import {Badge} from '../Badge/Badge'
7
+ import {Button} from '../Button/Button'
8
+ import {Checkbox} from '../Checkbox/Checkbox'
9
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
10
+
11
+ const meta: Meta = {
12
+ title: 'Components/List',
13
+ argTypes: {
14
+ selectionMode: {
15
+ control: 'select',
16
+ options: ['none', 'single', 'multiple'],
17
+ },
18
+ },
19
+ }
20
+
21
+ export default meta
22
+ type Story = StoryObj
23
+
24
+ // --- Default ---
25
+
26
+ export const Default: Story = {
27
+ render: () => (
28
+ <List.Root aria-label="Services">
29
+ <List.Item>
30
+ <List.Content>
31
+ <List.Text>Traefik</List.Text>
32
+ <List.Description>Reverse proxy · Port 443</List.Description>
33
+ </List.Content>
34
+ <List.Actions>
35
+ <Badge variant="success" size="sm">Running</Badge>
36
+ </List.Actions>
37
+ </List.Item>
38
+ <List.Item>
39
+ <List.Content>
40
+ <List.Text>Nginx</List.Text>
41
+ <List.Description>Web server · Port 80</List.Description>
42
+ </List.Content>
43
+ <List.Actions>
44
+ <Badge variant="success" size="sm">Running</Badge>
45
+ </List.Actions>
46
+ </List.Item>
47
+ <List.Item>
48
+ <List.Content>
49
+ <List.Text>Pi-hole</List.Text>
50
+ <List.Description>DNS filter · Port 53</List.Description>
51
+ </List.Content>
52
+ <List.Actions>
53
+ <Badge variant="default" size="sm">Stopped</Badge>
54
+ </List.Actions>
55
+ </List.Item>
56
+ </List.Root>
57
+ ),
58
+ play: async ({canvas}) => {
59
+ const list = canvas.getByRole('list')
60
+ await expect(list).toBeInTheDocument()
61
+ await expect(canvas.getAllByRole('listitem').length).toBe(3)
62
+ },
63
+ }
64
+
65
+ // --- With Selection ---
66
+
67
+ const narrowContainer = css.create({
68
+ box: {
69
+ maxWidth: 360,
70
+ borderWidth: 1,
71
+ borderStyle: 'solid',
72
+ borderColor: colors.border,
73
+ borderRadius: 8,
74
+ overflow: 'hidden',
75
+ },
76
+ })
77
+
78
+ function SelectionDemo() {
79
+ const certs = [
80
+ {id: 'c1', serial: 'aa:bb:cc:dd:01', issued: 'Mar 21, 2026', expires: 'Jun 19, 2026', status: 'active' as const},
81
+ {id: 'c2', serial: 'aa:bb:cc:dd:02', issued: 'Mar 21, 2026', expires: 'Jun 19, 2026', status: 'active' as const},
82
+ {id: 'c3', serial: 'ee:ff:00:11:03', issued: 'Jan 10, 2025', expires: 'Jan 10, 2026', status: 'expired' as const},
83
+ ]
84
+
85
+ const [selected, setSelected] = useState<Set<string>>(new Set())
86
+
87
+ const toggle = (id: string) => {
88
+ setSelected((prev) => {
89
+ const next = new Set(prev)
90
+ if (next.has(id)) next.delete(id)
91
+ else next.add(id)
92
+ return next
93
+ })
94
+ }
95
+
96
+ return (
97
+ <html.div style={narrowContainer.box}>
98
+ <List.Root selectionMode="multiple" aria-label="Certificates">
99
+ {certs.map((cert) => (
100
+ <List.Item
101
+ key={cert.id}
102
+ selected={selected.has(cert.id)}
103
+ onClick={() => cert.status === 'active' && toggle(cert.id)}
104
+ disabled={cert.status !== 'active'}
105
+ >
106
+ {cert.status === 'active' && (
107
+ <Checkbox
108
+ checked={selected.has(cert.id)}
109
+ onChange={() => toggle(cert.id)}
110
+ aria-label={cert.serial}
111
+ />
112
+ )}
113
+ <List.Content>
114
+ <List.Text>{cert.serial}</List.Text>
115
+ <List.Description>
116
+ Issued: {cert.issued} · Expires: {cert.expires}
117
+ </List.Description>
118
+ </List.Content>
119
+ <List.Actions>
120
+ <Badge
121
+ variant={cert.status === 'active' ? 'success' : 'default'}
122
+ size="sm"
123
+ >
124
+ {cert.status}
125
+ </Badge>
126
+ {cert.status === 'active' && (
127
+ <Button variant="danger" size="small">
128
+ Revoke
129
+ </Button>
130
+ )}
131
+ </List.Actions>
132
+ </List.Item>
133
+ ))}
134
+ </List.Root>
135
+ </html.div>
136
+ )
137
+ }
138
+
139
+ export const WithSelection: Story = {
140
+ render: () => <SelectionDemo />,
141
+ play: async ({canvas}) => {
142
+ const checkboxes = canvas.getAllByRole('checkbox')
143
+ await expect(checkboxes.length).toBe(2)
144
+ },
145
+ }
146
+
147
+ // --- Empty State ---
148
+
149
+ export const EmptyState: Story = {
150
+ render: () => (
151
+ <html.div style={narrowContainer.box}>
152
+ <List.Root aria-label="Empty list">
153
+ <List.Empty>No items found.</List.Empty>
154
+ </List.Root>
155
+ </html.div>
156
+ ),
157
+ }
158
+
159
+ // --- In Detail Panel context (360px) ---
160
+
161
+ function DetailPanelDemo() {
162
+ const users = [
163
+ {id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin', status: 'Active'},
164
+ {id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'Editor', status: 'Active'},
165
+ {id: '3', name: 'Carol Davis', email: 'carol@example.com', role: 'Viewer', status: 'Inactive'},
166
+ ]
167
+
168
+ const [selectedId, setSelectedId] = useState<string | null>(null)
169
+
170
+ return (
171
+ <html.div style={narrowContainer.box}>
172
+ <List.Root selectionMode="single" aria-label="Users">
173
+ {users.map((user) => (
174
+ <List.Item
175
+ key={user.id}
176
+ selected={selectedId === user.id}
177
+ onClick={() => setSelectedId(selectedId === user.id ? null : user.id)}
178
+ >
179
+ <List.Content>
180
+ <List.Text>{user.name}</List.Text>
181
+ <List.Description>{user.email} · {user.role}</List.Description>
182
+ </List.Content>
183
+ <List.Actions>
184
+ <Badge
185
+ variant={user.status === 'Active' ? 'success' : 'default'}
186
+ size="sm"
187
+ >
188
+ {user.status}
189
+ </Badge>
190
+ </List.Actions>
191
+ </List.Item>
192
+ ))}
193
+ </List.Root>
194
+ </html.div>
195
+ )
196
+ }
197
+
198
+ export const SingleSelection: Story = {
199
+ render: () => <DetailPanelDemo />,
200
+ }
@@ -0,0 +1,118 @@
1
+ import {type ReactNode, createContext, useContext} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {styles} from './styles.css'
4
+
5
+ // --- Types ---
6
+
7
+ export type ListSelectionMode = 'none' | 'single' | 'multiple'
8
+
9
+ // --- Context ---
10
+
11
+ interface ListContextValue {
12
+ selectionMode: ListSelectionMode
13
+ }
14
+
15
+ const ListContext = createContext<ListContextValue>({selectionMode: 'none'})
16
+
17
+ function useList() {
18
+ return useContext(ListContext)
19
+ }
20
+
21
+ // --- Root ---
22
+
23
+ interface RootProps {
24
+ children: ReactNode
25
+ /** Selection mode: 'none' (default), 'single', or 'multiple' */
26
+ selectionMode?: ListSelectionMode
27
+ /** Accessible label for the list */
28
+ 'aria-label'?: string
29
+ }
30
+
31
+ function Root({children, selectionMode = 'none', 'aria-label': ariaLabel}: RootProps) {
32
+ return (
33
+ <ListContext.Provider value={{selectionMode}}>
34
+ <html.div role="list" aria-label={ariaLabel} style={styles.root}>
35
+ {children}
36
+ </html.div>
37
+ </ListContext.Provider>
38
+ )
39
+ }
40
+
41
+ // --- Item ---
42
+
43
+ interface ItemProps {
44
+ children: ReactNode
45
+ /** Whether this item is selected */
46
+ selected?: boolean
47
+ /** Whether this item is disabled */
48
+ disabled?: boolean
49
+ /** Click handler for the item */
50
+ onClick?: () => void
51
+ }
52
+
53
+ function Item({children, selected, disabled, onClick}: ItemProps) {
54
+ const {selectionMode} = useList()
55
+ const isSelectable = selectionMode !== 'none'
56
+
57
+ return (
58
+ <html.div
59
+ role="listitem"
60
+ aria-selected={isSelectable ? selected : undefined}
61
+ aria-disabled={disabled || undefined}
62
+ onClick={disabled ? undefined : onClick}
63
+ style={[
64
+ styles.item,
65
+ selected && styles.itemSelected,
66
+ disabled && styles.itemDisabled,
67
+ ]}
68
+ >
69
+ {children}
70
+ </html.div>
71
+ )
72
+ }
73
+
74
+ // --- Content: wraps primary text + description ---
75
+
76
+ function Content({children}: {children: ReactNode}) {
77
+ return <html.div style={styles.content}>{children}</html.div>
78
+ }
79
+
80
+ // --- Text: primary label ---
81
+
82
+ function ItemText({children}: {children: ReactNode}) {
83
+ return <html.p style={styles.text}>{children}</html.p>
84
+ }
85
+
86
+ // --- Description: secondary text ---
87
+
88
+ function Description({children}: {children: ReactNode}) {
89
+ return <html.p style={styles.description}>{children}</html.p>
90
+ }
91
+
92
+ // --- Actions: slot for buttons, badges, etc. ---
93
+
94
+ function Actions({children}: {children: ReactNode}) {
95
+ return <html.div style={styles.actions}>{children}</html.div>
96
+ }
97
+
98
+ // --- Empty: placeholder when list has no items ---
99
+
100
+ function Empty({children}: {children: ReactNode}) {
101
+ return (
102
+ <html.div role="listitem" style={styles.empty}>
103
+ {children}
104
+ </html.div>
105
+ )
106
+ }
107
+
108
+ // --- Export ---
109
+
110
+ export const List = {
111
+ Root,
112
+ Item,
113
+ Content,
114
+ Text: ItemText,
115
+ Description,
116
+ Actions,
117
+ Empty,
118
+ }