@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.
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -1
- package/dist/components/DetailPanel/styles.css.d.ts +4 -0
- package/dist/components/DetailPanel/styles.css.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/List/List.d.ts +46 -0
- package/dist/components/List/List.d.ts.map +1 -0
- package/dist/components/List/List.meta.d.ts +3 -0
- package/dist/components/List/List.meta.d.ts.map +1 -0
- package/dist/components/List/List.stories.d.ts +9 -0
- package/dist/components/List/List.stories.d.ts.map +1 -0
- package/dist/components/List/styles.css.d.ts +77 -0
- package/dist/components/List/styles.css.d.ts.map +1 -0
- package/dist/components/Panel/Panel.d.ts.map +1 -1
- package/dist/components/Panel/Panel.meta.d.ts.map +1 -1
- package/dist/components/Panel/Panel.stories.d.ts.map +1 -1
- package/dist/components/Panel/styles.css.d.ts +4 -0
- package/dist/components/Panel/styles.css.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Tabs/Tabs.stories.d.ts.map +1 -1
- package/dist/components/Tooltip/Tooltip.stories.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1384 -1217
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.stories.tsx +10 -0
- package/src/components/DetailPanel/DetailPanel.meta.ts +4 -1
- package/src/components/DetailPanel/DetailPanel.stories.tsx +54 -27
- package/src/components/DetailPanel/DetailPanel.tsx +3 -16
- package/src/components/DetailPanel/styles.css.ts +5 -0
- package/src/components/Dialog/Dialog.meta.ts +8 -2
- package/src/components/Drawer/Drawer.meta.ts +4 -1
- package/src/components/Field/Field.stories.tsx +6 -0
- package/src/components/List/List.meta.ts +39 -0
- package/src/components/List/List.stories.tsx +200 -0
- package/src/components/List/List.tsx +118 -0
- package/src/components/List/styles.css.ts +99 -0
- package/src/components/Panel/Panel.meta.ts +12 -3
- package/src/components/Panel/Panel.stories.tsx +17 -10
- package/src/components/Panel/Panel.tsx +2 -10
- package/src/components/Panel/styles.css.ts +5 -0
- package/src/components/Table/Table.stories.tsx +16 -0
- package/src/components/Tabs/Tabs.stories.tsx +7 -0
- package/src/components/Tooltip/Tooltip.stories.tsx +15 -0
- 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.
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
|
|
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}
|
|
141
|
-
|
|
142
|
-
|
|
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}
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
<
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 & 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
|
-
|
|
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
|
-
{
|
|
24
|
-
|
|
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
|
-
{
|
|
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>
|
|
@@ -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
|
+
}
|