@duro-app/ui 0.22.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 (53) hide show
  1. package/dist/components/Alert/Alert.meta.d.ts.map +1 -1
  2. package/dist/components/Callout/Callout.meta.d.ts.map +1 -1
  3. package/dist/components/Card/Card.meta.d.ts.map +1 -1
  4. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts +22 -0
  5. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -0
  6. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts +3 -0
  7. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -0
  8. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts +9 -0
  9. package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts.map +1 -0
  10. package/dist/components/CheckboxGroup/styles.css.d.ts +12 -0
  11. package/dist/components/CheckboxGroup/styles.css.d.ts.map +1 -0
  12. package/dist/components/EmptyState/EmptyState.meta.d.ts.map +1 -1
  13. package/dist/components/InputGroup/InputGroup.meta.d.ts.map +1 -1
  14. package/dist/components/RadioGroup/RadioGroup.d.ts +22 -0
  15. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  16. package/dist/components/RadioGroup/RadioGroup.meta.d.ts +3 -0
  17. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -0
  18. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +9 -0
  19. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -0
  20. package/dist/components/RadioGroup/styles.css.d.ts +60 -0
  21. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -0
  22. package/dist/components/StatusIcon/StatusIcon.meta.d.ts.map +1 -1
  23. package/dist/components/Table/Table.d.ts +4 -3
  24. package/dist/components/Table/Table.d.ts.map +1 -1
  25. package/dist/components/Table/styles.css.d.ts +1 -1
  26. package/dist/components/ThemeProvider/ThemeProvider.meta.d.ts.map +1 -1
  27. package/dist/index.css +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1252 -1034
  31. package/dist/index.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/components/Alert/Alert.meta.ts +4 -1
  34. package/src/components/Callout/Callout.meta.ts +4 -1
  35. package/src/components/Card/Card.meta.ts +4 -1
  36. package/src/components/Checkbox/styles.css.ts +1 -1
  37. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +25 -0
  38. package/src/components/CheckboxGroup/CheckboxGroup.stories.tsx +88 -0
  39. package/src/components/CheckboxGroup/CheckboxGroup.tsx +105 -0
  40. package/src/components/CheckboxGroup/styles.css.ts +13 -0
  41. package/src/components/EmptyState/EmptyState.meta.ts +1 -4
  42. package/src/components/Icon/Icon.meta.ts +5 -5
  43. package/src/components/InputGroup/InputGroup.meta.ts +4 -1
  44. package/src/components/RadioGroup/RadioGroup.meta.ts +25 -0
  45. package/src/components/RadioGroup/RadioGroup.stories.tsx +83 -0
  46. package/src/components/RadioGroup/RadioGroup.tsx +113 -0
  47. package/src/components/RadioGroup/styles.css.ts +66 -0
  48. package/src/components/StatusIcon/StatusIcon.meta.ts +1 -4
  49. package/src/components/Table/Table.stories.tsx +9 -9
  50. package/src/components/Table/Table.tsx +31 -15
  51. package/src/components/Table/styles.css.ts +2 -2
  52. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -3
  53. package/src/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.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.22.0"
55
+ "@duro-app/tokens": "^0.23.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@babel/preset-typescript": "^7.28.0",
@@ -14,7 +14,10 @@ export const meta: ComponentMeta = {
14
14
  'Form field errors — use Field.Error',
15
15
  ],
16
16
  relatedTo: [
17
- {component: 'Callout', relationship: 'Callout for larger block-level messages; Alert for compact inline'},
17
+ {
18
+ component: 'Callout',
19
+ relationship: 'Callout for larger block-level messages; Alert for compact inline',
20
+ },
18
21
  ],
19
22
  example: `<Stack gap="sm">
20
23
  <Alert variant="success">Settings saved successfully.</Alert>
@@ -14,7 +14,10 @@ export const meta: ComponentMeta = {
14
14
  'Toast notifications — use a toast system',
15
15
  ],
16
16
  relatedTo: [
17
- {component: 'Alert', relationship: 'Alert for compact inline; Callout for block-level prominent'},
17
+ {
18
+ component: 'Alert',
19
+ relationship: 'Alert for compact inline; Callout for block-level prominent',
20
+ },
18
21
  ],
19
22
  example: `<Callout variant="warning">
20
23
  Your subscription expires in 3 days. Renew now to avoid service interruption.
@@ -14,7 +14,10 @@ export const meta: ComponentMeta = {
14
14
  'Simple spacing — use Stack or Inline',
15
15
  ],
16
16
  relatedTo: [
17
- {component: 'Panel', relationship: 'Panel for structural Header/Body/Footer; Card for visual container'},
17
+ {
18
+ component: 'Panel',
19
+ relationship: 'Panel for structural Header/Body/Footer; Card for visual container',
20
+ },
18
21
  ],
19
22
  example: `<Card variant="elevated" size="md">
20
23
  <Stack gap="md">
@@ -22,7 +22,7 @@ export const styles = css.create({
22
22
  height: 18,
23
23
  borderWidth: 1,
24
24
  borderStyle: 'solid',
25
- borderRadius: radii.sm,
25
+ borderRadius: radii.xs,
26
26
  display: 'inline-flex',
27
27
  alignItems: 'center',
28
28
  justifyContent: 'center',
@@ -0,0 +1,25 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Checkbox group for multi-select from a list of options. Wraps the existing Checkbox component with shared state management.',
6
+ whenToUse: [
7
+ 'Selecting multiple options from a small set',
8
+ 'Permission or feature toggles where several can be active',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Single selection — use RadioGroup instead',
12
+ 'Binary on/off — use a single Checkbox or Switch',
13
+ 'Many options (10+) — consider a filterable list or Select with multi-select',
14
+ ],
15
+ relatedTo: [
16
+ {component: 'RadioGroup', relationship: 'RadioGroup for single-select; CheckboxGroup for multi-select'},
17
+ {component: 'Checkbox', relationship: 'CheckboxGroup.Item wraps Checkbox internally'},
18
+ {component: 'ToggleGroup', relationship: 'ToggleGroup for toolbar-style multi-select'},
19
+ ],
20
+ example: `<CheckboxGroup.Root defaultValue={['email']} onValueChange={(v) => console.log(v)}>
21
+ <CheckboxGroup.Item value="email">Email</CheckboxGroup.Item>
22
+ <CheckboxGroup.Item value="sms">SMS</CheckboxGroup.Item>
23
+ <CheckboxGroup.Item value="push">Push notification</CheckboxGroup.Item>
24
+ </CheckboxGroup.Root>`,
25
+ }
@@ -0,0 +1,88 @@
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 {CheckboxGroup} from './CheckboxGroup'
6
+
7
+ const meta: Meta = {
8
+ title: 'Components/CheckboxGroup',
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
+ <CheckboxGroup.Root defaultValue={['email']}>
21
+ <CheckboxGroup.Item value="email">Email notifications</CheckboxGroup.Item>
22
+ <CheckboxGroup.Item value="sms">SMS notifications</CheckboxGroup.Item>
23
+ <CheckboxGroup.Item value="push">Push notifications</CheckboxGroup.Item>
24
+ </CheckboxGroup.Root>
25
+ ),
26
+ play: async ({canvas}) => {
27
+ const checkboxes = canvas.getAllByRole('checkbox')
28
+ await expect(checkboxes.length).toBe(3)
29
+ await expect(checkboxes[0]).toBeChecked()
30
+ await expect(checkboxes[1]).not.toBeChecked()
31
+ await expect(checkboxes[2]).not.toBeChecked()
32
+ },
33
+ }
34
+
35
+ export const Horizontal: Story = {
36
+ render: () => (
37
+ <CheckboxGroup.Root defaultValue={['react']} orientation="horizontal">
38
+ <CheckboxGroup.Item value="react">React</CheckboxGroup.Item>
39
+ <CheckboxGroup.Item value="vue">Vue</CheckboxGroup.Item>
40
+ <CheckboxGroup.Item value="angular">Angular</CheckboxGroup.Item>
41
+ </CheckboxGroup.Root>
42
+ ),
43
+ play: async ({canvas}) => {
44
+ const group = canvas.getByRole('group')
45
+ await expect(group).toHaveAttribute('aria-orientation', 'horizontal')
46
+ },
47
+ }
48
+
49
+ export const Disabled: Story = {
50
+ render: () => (
51
+ <CheckboxGroup.Root defaultValue={['a']} disabled>
52
+ <CheckboxGroup.Item value="a">Option A</CheckboxGroup.Item>
53
+ <CheckboxGroup.Item value="b">Option B</CheckboxGroup.Item>
54
+ </CheckboxGroup.Root>
55
+ ),
56
+ play: async ({canvas}) => {
57
+ const checkboxes = canvas.getAllByRole('checkbox')
58
+ await expect(checkboxes[0]).toBeDisabled()
59
+ await expect(checkboxes[1]).toBeDisabled()
60
+ },
61
+ }
62
+
63
+ export const Controlled: Story = {
64
+ render: () => {
65
+ const [value, setValue] = useState(['read'])
66
+ return (
67
+ <html.div>
68
+ <CheckboxGroup.Root value={value} onValueChange={setValue}>
69
+ <CheckboxGroup.Item value="read">Read</CheckboxGroup.Item>
70
+ <CheckboxGroup.Item value="write">Write</CheckboxGroup.Item>
71
+ <CheckboxGroup.Item value="execute">Execute</CheckboxGroup.Item>
72
+ </CheckboxGroup.Root>
73
+ </html.div>
74
+ )
75
+ },
76
+ play: async ({canvas, userEvent}) => {
77
+ const checkboxes = canvas.getAllByRole('checkbox')
78
+ await expect(checkboxes[0]).toBeChecked()
79
+
80
+ await userEvent.click(checkboxes[1])
81
+ await expect(checkboxes[0]).toBeChecked()
82
+ await expect(checkboxes[1]).toBeChecked()
83
+
84
+ await userEvent.click(checkboxes[0])
85
+ await expect(checkboxes[0]).not.toBeChecked()
86
+ await expect(checkboxes[1]).toBeChecked()
87
+ },
88
+ }
@@ -0,0 +1,105 @@
1
+ import {type ReactNode, createContext, useContext, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {Checkbox} from '../Checkbox/Checkbox'
5
+ import {styles} from './styles.css'
6
+
7
+ // --- Context ---
8
+
9
+ interface CheckboxGroupContextValue {
10
+ value: string[]
11
+ onToggle: (itemValue: string, checked: boolean) => void
12
+ disabled: boolean
13
+ }
14
+
15
+ const CheckboxGroupContext = createContext<CheckboxGroupContextValue | null>(null)
16
+
17
+ function useCheckboxGroup() {
18
+ const ctx = useContext(CheckboxGroupContext)
19
+ if (!ctx) {
20
+ throw new Error('CheckboxGroup.Item must be used within CheckboxGroup.Root')
21
+ }
22
+ return ctx
23
+ }
24
+
25
+ // --- Root ---
26
+
27
+ interface RootProps {
28
+ value?: string[]
29
+ defaultValue?: string[]
30
+ onValueChange?: (value: string[]) => void
31
+ orientation?: 'horizontal' | 'vertical'
32
+ disabled?: boolean
33
+ children: ReactNode
34
+ }
35
+
36
+ function Root({
37
+ value: controlledValue,
38
+ defaultValue = [],
39
+ onValueChange,
40
+ orientation = 'vertical',
41
+ disabled = false,
42
+ children,
43
+ }: RootProps) {
44
+ const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
45
+
46
+ const onToggle = useCallback(
47
+ (itemValue: string, checked: boolean) => {
48
+ if (disabled) return
49
+ const next = checked
50
+ ? [...value, itemValue]
51
+ : value.filter((v) => v !== itemValue)
52
+ setValue(next)
53
+ },
54
+ [disabled, value, setValue],
55
+ )
56
+
57
+ return (
58
+ <CheckboxGroupContext.Provider value={{value, onToggle, disabled}}>
59
+ <html.div
60
+ role="group"
61
+ aria-orientation={orientation}
62
+ style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
63
+ >
64
+ {children}
65
+ </html.div>
66
+ </CheckboxGroupContext.Provider>
67
+ )
68
+ }
69
+
70
+ // --- Item ---
71
+
72
+ interface ItemProps {
73
+ value: string
74
+ disabled?: boolean
75
+ children: ReactNode
76
+ }
77
+
78
+ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
79
+ const {value: groupValue, onToggle, disabled: groupDisabled} = useCheckboxGroup()
80
+ const isChecked = groupValue.includes(value)
81
+ const isDisabled = groupDisabled || itemDisabled
82
+
83
+ const handleChange = useCallback(
84
+ (e: React.ChangeEvent<HTMLInputElement>) => {
85
+ onToggle(value, e.target.checked)
86
+ },
87
+ [value, onToggle],
88
+ )
89
+
90
+ return (
91
+ <Checkbox
92
+ value={value}
93
+ checked={isChecked}
94
+ disabled={isDisabled}
95
+ onChange={handleChange}
96
+ >
97
+ {children}
98
+ </Checkbox>
99
+ )
100
+ }
101
+
102
+ export const CheckboxGroup = {
103
+ Root,
104
+ Item,
105
+ }
@@ -0,0 +1,13 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+
4
+ export const styles = css.create({
5
+ root: {
6
+ display: 'flex',
7
+ flexDirection: 'column',
8
+ gap: spacing.sm,
9
+ },
10
+ rootHorizontal: {
11
+ flexDirection: 'row',
12
+ },
13
+ })
@@ -8,10 +8,7 @@ export const meta: ComponentMeta = {
8
8
  'Search results with no matches',
9
9
  'First-time use before any data exists',
10
10
  ],
11
- whenNotToUse: [
12
- 'Loading states — use Spinner',
13
- 'Error states — use Alert or Callout',
14
- ],
11
+ whenNotToUse: ['Loading states — use Spinner', 'Error states — use Alert or Callout'],
15
12
  example: `<Card>
16
13
  <EmptyState
17
14
  icon={<Icon name="info-circle" size={48} />}
@@ -8,12 +8,12 @@ export const meta: ComponentMeta = {
8
8
  'Status indicators alongside text',
9
9
  'Navigation or action indicators',
10
10
  ],
11
- whenNotToUse: [
12
- 'Decorative images — use img elements',
13
- 'Complex illustrations — use custom SVGs',
14
- ],
11
+ whenNotToUse: ['Decorative images — use img elements', 'Complex illustrations — use custom SVGs'],
15
12
  relatedTo: [
16
- {component: 'StatusIcon', relationship: 'StatusIcon wraps Icon with a colored background circle'},
13
+ {
14
+ component: 'StatusIcon',
15
+ relationship: 'StatusIcon wraps Icon with a colored background circle',
16
+ },
17
17
  ],
18
18
  example: `<Inline gap="sm" align="center">
19
19
  <Icon name="check-circle" size={20} />
@@ -18,7 +18,10 @@ export const meta: ComponentMeta = {
18
18
  },
19
19
  relatedTo: [
20
20
  {component: 'Input', relationship: 'InputGroup enhances Input with addons'},
21
- {component: 'Field', relationship: 'Field provides label/error; InputGroup provides visual addons'},
21
+ {
22
+ component: 'Field',
23
+ relationship: 'Field provides label/error; InputGroup provides visual addons',
24
+ },
22
25
  ],
23
26
  example: `<InputGroup.Root>
24
27
  <InputGroup.Addon>$</InputGroup.Addon>
@@ -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
+ }
@@ -0,0 +1,113 @@
1
+ import {type ReactNode, createContext, useContext, useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {styles} from './styles.css'
5
+
6
+ // --- Context ---
7
+
8
+ interface RadioGroupContextValue {
9
+ value: string
10
+ onSelect: (value: string) => void
11
+ disabled: boolean
12
+ }
13
+
14
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
15
+
16
+ function useRadioGroup() {
17
+ const ctx = useContext(RadioGroupContext)
18
+ if (!ctx) {
19
+ throw new Error('RadioGroup.Item must be used within RadioGroup.Root')
20
+ }
21
+ return ctx
22
+ }
23
+
24
+ // --- Root ---
25
+
26
+ interface RootProps {
27
+ value?: string
28
+ defaultValue?: string
29
+ onValueChange?: (value: string) => void
30
+ orientation?: 'horizontal' | 'vertical'
31
+ disabled?: boolean
32
+ children: ReactNode
33
+ }
34
+
35
+ function Root({
36
+ value: controlledValue,
37
+ defaultValue = '',
38
+ onValueChange,
39
+ orientation = 'vertical',
40
+ disabled = false,
41
+ children,
42
+ }: RootProps) {
43
+ const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
44
+
45
+ const onSelect = useCallback(
46
+ (itemValue: string) => {
47
+ if (!disabled) {
48
+ setValue(itemValue)
49
+ }
50
+ },
51
+ [disabled, setValue],
52
+ )
53
+
54
+ return (
55
+ <RadioGroupContext.Provider value={{value, onSelect, disabled}}>
56
+ <html.div
57
+ role="radiogroup"
58
+ aria-orientation={orientation}
59
+ style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
60
+ >
61
+ {children}
62
+ </html.div>
63
+ </RadioGroupContext.Provider>
64
+ )
65
+ }
66
+
67
+ // --- Item ---
68
+
69
+ interface ItemProps {
70
+ value: string
71
+ disabled?: boolean
72
+ children: ReactNode
73
+ }
74
+
75
+ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
76
+ const {value: groupValue, onSelect, disabled: groupDisabled} = useRadioGroup()
77
+ const isChecked = groupValue === value
78
+ const isDisabled = groupDisabled || itemDisabled
79
+
80
+ const handleClick = () => {
81
+ if (!isDisabled) {
82
+ onSelect(value)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <html.label
88
+ style={[styles.item, isDisabled && styles.itemDisabled]}
89
+ onClick={handleClick}
90
+ >
91
+ <html.input
92
+ type="radio"
93
+ value={value}
94
+ checked={isChecked}
95
+ disabled={isDisabled}
96
+ onChange={() => onSelect(value)}
97
+ style={styles.input}
98
+ />
99
+ <html.span
100
+ style={[styles.circle, isChecked ? styles.circleChecked : styles.circleUnchecked]}
101
+ aria-hidden
102
+ >
103
+ {isChecked && <html.span style={styles.dot} />}
104
+ </html.span>
105
+ {children && <html.span>{children}</html.span>}
106
+ </html.label>
107
+ )
108
+ }
109
+
110
+ export const RadioGroup = {
111
+ Root,
112
+ Item,
113
+ }
@@ -0,0 +1,66 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing, radii} 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
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ gap: spacing.sm,
11
+ },
12
+ rootHorizontal: {
13
+ flexDirection: 'row',
14
+ },
15
+ item: {
16
+ display: 'inline-flex',
17
+ alignItems: 'center',
18
+ gap: spacing.sm,
19
+ cursor: 'pointer',
20
+ fontSize: typography.fontSizeSm,
21
+ color: colors.text,
22
+ lineHeight: typography.lineHeight,
23
+ },
24
+ itemDisabled: {
25
+ opacity: 0.5,
26
+ cursor: 'not-allowed',
27
+ },
28
+ circle: {
29
+ width: 18,
30
+ height: 18,
31
+ borderWidth: 1,
32
+ borderStyle: 'solid',
33
+ borderRadius: radii.full,
34
+ display: 'inline-flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ flexShrink: 0,
38
+ transitionProperty: 'background-color, border-color',
39
+ transitionDuration: '150ms',
40
+ transitionTimingFunction: 'ease',
41
+ },
42
+ circleUnchecked: {
43
+ backgroundColor: colors.bg,
44
+ borderColor: {
45
+ default: colors.border,
46
+ ':hover': colors.textMuted,
47
+ },
48
+ },
49
+ circleChecked: {
50
+ backgroundColor: colors.bg,
51
+ borderColor: colors.accent,
52
+ },
53
+ dot: {
54
+ width: 8,
55
+ height: 8,
56
+ borderRadius: radii.full,
57
+ backgroundColor: colors.accent,
58
+ },
59
+ input: {
60
+ position: 'absolute',
61
+ width: 1,
62
+ height: 1,
63
+ opacity: 0,
64
+ overflow: 'hidden',
65
+ },
66
+ })
@@ -8,10 +8,7 @@ export const meta: ComponentMeta = {
8
8
  'List item status icons with colored backgrounds',
9
9
  'Feature or capability indicators',
10
10
  ],
11
- whenNotToUse: [
12
- 'Inline icons without background — use Icon',
13
- 'Status text labels — use Badge',
14
- ],
11
+ whenNotToUse: ['Inline icons without background — use Icon', 'Status text labels — use Badge'],
15
12
  relatedTo: [
16
13
  {component: 'Icon', relationship: 'StatusIcon wraps Icon with background; Icon is bare SVG'},
17
14
  {component: 'Badge', relationship: 'Badge for text status; StatusIcon for icon status'},