@duro-app/ui 0.21.0 → 0.23.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 (96) hide show
  1. package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
  2. package/dist/components/Alert/Alert.meta.d.ts.map +1 -1
  3. package/dist/components/Callout/Callout.meta.d.ts.map +1 -1
  4. package/dist/components/Card/Card.meta.d.ts.map +1 -1
  5. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts +22 -0
  6. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -0
  7. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts +3 -0
  8. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -0
  9. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts +9 -0
  10. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts.map +1 -0
  11. package/dist/components/CheckboxGroup/styles.css.d.ts +12 -0
  12. package/dist/components/CheckboxGroup/styles.css.d.ts.map +1 -0
  13. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  14. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  15. package/dist/components/DetailPanel/DetailPanel.stories.d.ts.map +1 -1
  16. package/dist/components/DetailPanel/styles.css.d.ts +4 -0
  17. package/dist/components/DetailPanel/styles.css.d.ts.map +1 -1
  18. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
  19. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  20. package/dist/components/EmptyState/EmptyState.meta.d.ts.map +1 -1
  21. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  22. package/dist/components/InputGroup/InputGroup.meta.d.ts.map +1 -1
  23. package/dist/components/List/List.d.ts +46 -0
  24. package/dist/components/List/List.d.ts.map +1 -0
  25. package/dist/components/List/List.meta.d.ts +3 -0
  26. package/dist/components/List/List.meta.d.ts.map +1 -0
  27. package/dist/components/List/List.stories.d.ts +9 -0
  28. package/dist/components/List/List.stories.d.ts.map +1 -0
  29. package/dist/components/List/styles.css.d.ts +77 -0
  30. package/dist/components/List/styles.css.d.ts.map +1 -0
  31. package/dist/components/Panel/Panel.d.ts.map +1 -1
  32. package/dist/components/Panel/Panel.meta.d.ts.map +1 -1
  33. package/dist/components/Panel/Panel.stories.d.ts.map +1 -1
  34. package/dist/components/Panel/styles.css.d.ts +4 -0
  35. package/dist/components/Panel/styles.css.d.ts.map +1 -1
  36. package/dist/components/RadioGroup/RadioGroup.d.ts +22 -0
  37. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  38. package/dist/components/RadioGroup/RadioGroup.meta.d.ts +3 -0
  39. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -0
  40. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +9 -0
  41. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -0
  42. package/dist/components/RadioGroup/styles.css.d.ts +60 -0
  43. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -0
  44. package/dist/components/StatusIcon/StatusIcon.meta.d.ts.map +1 -1
  45. package/dist/components/Table/Table.d.ts +4 -3
  46. package/dist/components/Table/Table.d.ts.map +1 -1
  47. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  48. package/dist/components/Table/styles.css.d.ts +1 -1
  49. package/dist/components/Tabs/Tabs.stories.d.ts.map +1 -1
  50. package/dist/components/ThemeProvider/ThemeProvider.meta.d.ts.map +1 -1
  51. package/dist/components/Tooltip/Tooltip.stories.d.ts.map +1 -1
  52. package/dist/index.css +1 -1
  53. package/dist/index.d.ts +3 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +1771 -1386
  56. package/dist/index.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/components/ActionBar/ActionBar.stories.tsx +10 -0
  59. package/src/components/Alert/Alert.meta.ts +4 -1
  60. package/src/components/Callout/Callout.meta.ts +4 -1
  61. package/src/components/Card/Card.meta.ts +4 -1
  62. package/src/components/Checkbox/styles.css.ts +1 -1
  63. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +25 -0
  64. package/src/components/CheckboxGroup/CheckboxGroup.stories.tsx +88 -0
  65. package/src/components/CheckboxGroup/CheckboxGroup.tsx +105 -0
  66. package/src/components/CheckboxGroup/styles.css.ts +13 -0
  67. package/src/components/DetailPanel/DetailPanel.meta.ts +4 -1
  68. package/src/components/DetailPanel/DetailPanel.stories.tsx +54 -27
  69. package/src/components/DetailPanel/DetailPanel.tsx +3 -16
  70. package/src/components/DetailPanel/styles.css.ts +5 -0
  71. package/src/components/Dialog/Dialog.meta.ts +8 -2
  72. package/src/components/Drawer/Drawer.meta.ts +4 -1
  73. package/src/components/EmptyState/EmptyState.meta.ts +1 -4
  74. package/src/components/Field/Field.stories.tsx +6 -0
  75. package/src/components/Icon/Icon.meta.ts +5 -5
  76. package/src/components/InputGroup/InputGroup.meta.ts +4 -1
  77. package/src/components/List/List.meta.ts +39 -0
  78. package/src/components/List/List.stories.tsx +200 -0
  79. package/src/components/List/List.tsx +118 -0
  80. package/src/components/List/styles.css.ts +99 -0
  81. package/src/components/Panel/Panel.meta.ts +12 -3
  82. package/src/components/Panel/Panel.stories.tsx +17 -10
  83. package/src/components/Panel/Panel.tsx +2 -10
  84. package/src/components/Panel/styles.css.ts +5 -0
  85. package/src/components/RadioGroup/RadioGroup.meta.ts +25 -0
  86. package/src/components/RadioGroup/RadioGroup.stories.tsx +83 -0
  87. package/src/components/RadioGroup/RadioGroup.tsx +113 -0
  88. package/src/components/RadioGroup/styles.css.ts +66 -0
  89. package/src/components/StatusIcon/StatusIcon.meta.ts +1 -4
  90. package/src/components/Table/Table.stories.tsx +25 -9
  91. package/src/components/Table/Table.tsx +31 -15
  92. package/src/components/Table/styles.css.ts +2 -2
  93. package/src/components/Tabs/Tabs.stories.tsx +7 -0
  94. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -3
  95. package/src/components/Tooltip/Tooltip.stories.tsx +15 -0
  96. package/src/index.ts +3 -0
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ export const styles = css.create({
7
+ // --- Root ---
8
+ root: {
9
+ display: 'flex',
10
+ flexDirection: 'column',
11
+ margin: 0,
12
+ padding: 0,
13
+ },
14
+
15
+ // --- Item ---
16
+ item: {
17
+ display: 'flex',
18
+ alignItems: 'center',
19
+ gap: spacing.sm,
20
+ paddingTop: spacing.sm,
21
+ paddingBottom: spacing.sm,
22
+ paddingLeft: spacing.ms,
23
+ paddingRight: spacing.ms,
24
+ borderBottomWidth: 1,
25
+ borderBottomStyle: 'solid',
26
+ borderBottomColor: colors.border,
27
+ backgroundColor: {
28
+ default: 'transparent',
29
+ ':hover': colors.bgCardHover,
30
+ },
31
+ transitionProperty: 'background-color',
32
+ transitionDuration: '120ms',
33
+ cursor: 'default',
34
+ },
35
+ itemSelected: {
36
+ backgroundColor: {
37
+ default: colors.bgCardHover,
38
+ ':hover': colors.bgCardHover,
39
+ },
40
+ },
41
+ itemDisabled: {
42
+ opacity: 0.5,
43
+ pointerEvents: 'none',
44
+ },
45
+ itemLast: {
46
+ borderBottomWidth: 0,
47
+ },
48
+
49
+ // --- Item content area (text + description) ---
50
+ content: {
51
+ flex: 1,
52
+ display: 'flex',
53
+ flexDirection: 'column',
54
+ gap: 2,
55
+ minWidth: 0,
56
+ },
57
+
58
+ // --- Primary text ---
59
+ text: {
60
+ fontSize: typography.fontSizeSm,
61
+ fontWeight: typography.fontWeightMedium,
62
+ lineHeight: typography.lineHeight,
63
+ color: colors.text,
64
+ margin: 0,
65
+ overflow: 'hidden',
66
+ textOverflow: 'ellipsis',
67
+ whiteSpace: 'nowrap',
68
+ },
69
+
70
+ // --- Description text ---
71
+ description: {
72
+ fontSize: typography.fontSizeXs,
73
+ lineHeight: typography.lineHeight,
74
+ color: colors.textMuted,
75
+ margin: 0,
76
+ overflow: 'hidden',
77
+ textOverflow: 'ellipsis',
78
+ whiteSpace: 'nowrap',
79
+ },
80
+
81
+ // --- Actions slot ---
82
+ actions: {
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ gap: spacing.xs,
86
+ flexShrink: 0,
87
+ },
88
+
89
+ // --- Empty state ---
90
+ empty: {
91
+ display: 'flex',
92
+ alignItems: 'center',
93
+ justifyContent: 'center',
94
+ paddingTop: spacing.xl,
95
+ paddingBottom: spacing.xl,
96
+ color: colors.textMuted,
97
+ fontSize: typography.fontSizeSm,
98
+ },
99
+ })
@@ -18,9 +18,18 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Root', 'Header', 'Body', 'Footer'],
19
19
  },
20
20
  relatedTo: [
21
- {component: 'Card', relationship: 'Card is for shallow, repeated items; Panel is for deep, organized content'},
22
- {component: 'DetailPanel', relationship: 'DetailPanel uses Panel internally for its structural layout'},
23
- {component: 'PageShell', relationship: 'PageShell is for page-level layout; Panel is for section-level'},
21
+ {
22
+ component: 'Card',
23
+ relationship: 'Card is for shallow, repeated items; Panel is for deep, organized content',
24
+ },
25
+ {
26
+ component: 'DetailPanel',
27
+ relationship: 'DetailPanel uses Panel internally for its structural layout',
28
+ },
29
+ {
30
+ component: 'PageShell',
31
+ relationship: 'PageShell is for page-level layout; Panel is for section-level',
32
+ },
24
33
  ],
25
34
  example: `<Panel.Root bordered>
26
35
  <Panel.Header>
@@ -10,6 +10,12 @@ import {Input} from '../Input/Input'
10
10
 
11
11
  const meta: Meta = {
12
12
  title: 'Components/Panel',
13
+ argTypes: {
14
+ bordered: {
15
+ control: 'boolean',
16
+ description: 'Whether the panel has an outer border for visual separation',
17
+ },
18
+ },
13
19
  }
14
20
 
15
21
  export default meta
@@ -25,9 +31,8 @@ export const Default: Story = {
25
31
  </Panel.Header>
26
32
  <Panel.Body>
27
33
  <Text>
28
- Panel is a structural primitive for grouping content with
29
- header, body, and footer slots. It fills the gap between Card
30
- and page-level layout.
34
+ Panel is a structural primitive for grouping content with header, body, and footer slots.
35
+ It fills the gap between Card and page-level layout.
31
36
  </Text>
32
37
  </Panel.Body>
33
38
  <Panel.Footer>
@@ -45,12 +50,14 @@ export const Bordered: Story = {
45
50
  <Panel.Root bordered>
46
51
  <Panel.Header>
47
52
  <Heading level={3}>Bordered Panel</Heading>
48
- <Button variant="secondary" size="small">Action</Button>
53
+ <Button variant="secondary" size="small">
54
+ Action
55
+ </Button>
49
56
  </Panel.Header>
50
57
  <Panel.Body>
51
58
  <Text>
52
- The bordered variant adds an outer border, useful when the panel
53
- needs visual separation from its surroundings.
59
+ The bordered variant adds an outer border, useful when the panel needs visual separation
60
+ from its surroundings.
54
61
  </Text>
55
62
  </Panel.Body>
56
63
  <Panel.Footer>
@@ -67,8 +74,8 @@ export const BodyOnly: Story = {
67
74
  <Panel.Root bordered>
68
75
  <Panel.Body>
69
76
  <Text>
70
- Panel sub-components can be used independently. This panel only
71
- has a Body — no Header or Footer.
77
+ Panel sub-components can be used independently. This panel only has a Body — no Header or
78
+ Footer.
72
79
  </Text>
73
80
  </Panel.Body>
74
81
  </Panel.Root>
@@ -85,8 +92,8 @@ export const UnpaddedBody: Story = {
85
92
  </Panel.Header>
86
93
  <Panel.Body padded={false}>
87
94
  <Text>
88
- Setting padded=false removes body padding, useful for content
89
- that needs to extend edge-to-edge (tables, images, etc).
95
+ Setting padded=false removes body padding, useful for content that needs to extend
96
+ edge-to-edge (tables, images, etc).
90
97
  </Text>
91
98
  </Panel.Body>
92
99
  </Panel.Root>
@@ -11,11 +11,7 @@ interface RootProps {
11
11
  }
12
12
 
13
13
  function Root({children, bordered = false}: RootProps) {
14
- return (
15
- <html.div style={[styles.root, bordered && styles.bordered]}>
16
- {children}
17
- </html.div>
18
- )
14
+ return <html.div style={[styles.root, bordered && styles.bordered]}>{children}</html.div>
19
15
  }
20
16
 
21
17
  // --- Header ---
@@ -33,11 +29,7 @@ interface BodyProps {
33
29
  }
34
30
 
35
31
  function Body({children, padded = true}: BodyProps) {
36
- return (
37
- <html.div style={[styles.body, padded && styles.bodyPadded]}>
38
- {children}
39
- </html.div>
40
- )
32
+ return <html.div style={[styles.body, padded && styles.bodyPadded]}>{children}</html.div>
41
33
  }
42
34
 
43
35
  // --- Footer ---
@@ -31,6 +31,11 @@ export const styles = css.create({
31
31
  body: {
32
32
  flex: 1,
33
33
  overflowY: 'auto',
34
+ // Minimal inset prevents child borders from touching panel structural borders
35
+ paddingTop: spacing.sm,
36
+ paddingBottom: spacing.sm,
37
+ paddingLeft: spacing.sm,
38
+ paddingRight: spacing.sm,
34
39
  },
35
40
  bodyPadded: {
36
41
  paddingTop: spacing.lg,
@@ -0,0 +1,25 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Radio button group for single-select from a list of options. Compound component with Root container and Item children.',
6
+ whenToUse: [
7
+ 'Selecting exactly one option from a small set (2-5 items)',
8
+ 'Options need to be visible at all times (not hidden in a dropdown)',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Many options (5+) — use Select for space efficiency',
12
+ 'Multi-select — use CheckboxGroup instead',
13
+ 'Binary on/off — use Switch',
14
+ ],
15
+ relatedTo: [
16
+ {component: 'CheckboxGroup', relationship: 'CheckboxGroup for multi-select; RadioGroup for single-select'},
17
+ {component: 'Select', relationship: 'Select for larger option sets or when space is limited'},
18
+ {component: 'ToggleGroup', relationship: 'ToggleGroup for toolbar-style selection'},
19
+ ],
20
+ example: `<RadioGroup.Root defaultValue="email" onValueChange={(v) => console.log(v)}>
21
+ <RadioGroup.Item value="email">Email</RadioGroup.Item>
22
+ <RadioGroup.Item value="sms">SMS</RadioGroup.Item>
23
+ <RadioGroup.Item value="push">Push notification</RadioGroup.Item>
24
+ </RadioGroup.Root>`,
25
+ }
@@ -0,0 +1,83 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, fn} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {RadioGroup} from './RadioGroup'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/RadioGroup',
9
+ argTypes: {
10
+ orientation: {control: 'select', options: ['horizontal', 'vertical']},
11
+ disabled: {control: 'boolean'},
12
+ },
13
+ }
14
+
15
+ export default meta
16
+ type Story = StoryObj
17
+
18
+ export const Default: Story = {
19
+ render: () => (
20
+ <RadioGroup.Root defaultValue="apple">
21
+ <RadioGroup.Item value="apple">Apple</RadioGroup.Item>
22
+ <RadioGroup.Item value="banana">Banana</RadioGroup.Item>
23
+ <RadioGroup.Item value="cherry">Cherry</RadioGroup.Item>
24
+ </RadioGroup.Root>
25
+ ),
26
+ play: async ({canvas}) => {
27
+ const radios = canvas.getAllByRole('radio')
28
+ await expect(radios.length).toBe(3)
29
+ await expect(radios[0]).toBeChecked()
30
+ await expect(radios[1]).not.toBeChecked()
31
+ },
32
+ }
33
+
34
+ export const Horizontal: Story = {
35
+ render: () => (
36
+ <RadioGroup.Root defaultValue="sm" orientation="horizontal">
37
+ <RadioGroup.Item value="sm">Small</RadioGroup.Item>
38
+ <RadioGroup.Item value="md">Medium</RadioGroup.Item>
39
+ <RadioGroup.Item value="lg">Large</RadioGroup.Item>
40
+ </RadioGroup.Root>
41
+ ),
42
+ play: async ({canvas}) => {
43
+ const group = canvas.getByRole('radiogroup')
44
+ await expect(group).toHaveAttribute('aria-orientation', 'horizontal')
45
+ },
46
+ }
47
+
48
+ export const Disabled: Story = {
49
+ render: () => (
50
+ <RadioGroup.Root defaultValue="on" disabled>
51
+ <RadioGroup.Item value="on">On</RadioGroup.Item>
52
+ <RadioGroup.Item value="off">Off</RadioGroup.Item>
53
+ </RadioGroup.Root>
54
+ ),
55
+ play: async ({canvas}) => {
56
+ const radios = canvas.getAllByRole('radio')
57
+ await expect(radios[0]).toBeDisabled()
58
+ await expect(radios[1]).toBeDisabled()
59
+ },
60
+ }
61
+
62
+ export const Controlled: Story = {
63
+ render: () => {
64
+ const [value, setValue] = useState('red')
65
+ return (
66
+ <html.div>
67
+ <RadioGroup.Root value={value} onValueChange={setValue}>
68
+ <RadioGroup.Item value="red">Red</RadioGroup.Item>
69
+ <RadioGroup.Item value="green">Green</RadioGroup.Item>
70
+ <RadioGroup.Item value="blue">Blue</RadioGroup.Item>
71
+ </RadioGroup.Root>
72
+ </html.div>
73
+ )
74
+ },
75
+ play: async ({canvas, userEvent}) => {
76
+ const radios = canvas.getAllByRole('radio')
77
+ await expect(radios[0]).toBeChecked()
78
+
79
+ await userEvent.click(radios[2])
80
+ await expect(radios[2]).toBeChecked()
81
+ await expect(radios[0]).not.toBeChecked()
82
+ },
83
+ }