@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.
- package/dist/components/Alert/Alert.meta.d.ts.map +1 -1
- package/dist/components/Callout/Callout.meta.d.ts.map +1 -1
- package/dist/components/Card/Card.meta.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts +22 -0
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -0
- package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts +3 -0
- package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -0
- package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts +9 -0
- package/dist/components/CheckboxGroup/CheckboxGroup.stories.d.ts.map +1 -0
- package/dist/components/CheckboxGroup/styles.css.d.ts +12 -0
- package/dist/components/CheckboxGroup/styles.css.d.ts.map +1 -0
- package/dist/components/EmptyState/EmptyState.meta.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.meta.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts +22 -0
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/components/RadioGroup/RadioGroup.meta.d.ts +3 -0
- package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -0
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts +9 -0
- package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -0
- package/dist/components/RadioGroup/styles.css.d.ts +60 -0
- package/dist/components/RadioGroup/styles.css.d.ts.map +1 -0
- package/dist/components/StatusIcon/StatusIcon.meta.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts +4 -3
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +1 -1
- package/dist/components/ThemeProvider/ThemeProvider.meta.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1252 -1034
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Alert/Alert.meta.ts +4 -1
- package/src/components/Callout/Callout.meta.ts +4 -1
- package/src/components/Card/Card.meta.ts +4 -1
- package/src/components/Checkbox/styles.css.ts +1 -1
- package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +25 -0
- package/src/components/CheckboxGroup/CheckboxGroup.stories.tsx +88 -0
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +105 -0
- package/src/components/CheckboxGroup/styles.css.ts +13 -0
- package/src/components/EmptyState/EmptyState.meta.ts +1 -4
- package/src/components/Icon/Icon.meta.ts +5 -5
- package/src/components/InputGroup/InputGroup.meta.ts +4 -1
- package/src/components/RadioGroup/RadioGroup.meta.ts +25 -0
- package/src/components/RadioGroup/RadioGroup.stories.tsx +83 -0
- package/src/components/RadioGroup/RadioGroup.tsx +113 -0
- package/src/components/RadioGroup/styles.css.ts +66 -0
- package/src/components/StatusIcon/StatusIcon.meta.ts +1 -4
- package/src/components/Table/Table.stories.tsx +9 -9
- package/src/components/Table/Table.tsx +31 -15
- package/src/components/Table/styles.css.ts +2 -2
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -3
- 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.
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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">
|
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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'},
|