@duro-app/ui 3.2.1 → 3.3.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/CheckboxGroup/CheckboxGroup.d.ts +9 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/Field/Field.d.ts +10 -1
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/Field.meta.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts +3 -0
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/Field/FieldContext.d.ts +19 -0
- package/dist/components/Field/FieldContext.d.ts.map +1 -1
- package/dist/components/Fieldset/Fieldset.d.ts +1 -0
- package/dist/components/Fieldset/Fieldset.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts +9 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts +9 -1
- package/dist/components/ToggleGroup/ToggleGroup.d.ts.map +1 -1
- package/dist/index.js +1646 -1593
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +12 -0
- package/src/components/Field/Field.meta.ts +27 -0
- package/src/components/Field/Field.stories.tsx +93 -0
- package/src/components/Field/Field.tsx +51 -7
- package/src/components/Field/FieldContext.ts +25 -0
- package/src/components/Fieldset/Fieldset.tsx +26 -9
- package/src/components/RadioGroup/RadioGroup.tsx +12 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@tanstack/react-virtual": "^3.14.6",
|
|
67
|
-
"@duro-app/tokens": "^3.
|
|
67
|
+
"@duro-app/tokens": "^3.3.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
|
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import {Checkbox} from '../Checkbox/Checkbox'
|
|
5
5
|
import {styles} from './styles.css'
|
|
6
|
+
import {useFieldGroupLabelling} from '../Field/FieldContext'
|
|
6
7
|
|
|
7
8
|
// --- Context ---
|
|
8
9
|
|
|
@@ -30,6 +31,11 @@ interface RootProps {
|
|
|
30
31
|
onValueChange?: (value: string[]) => void
|
|
31
32
|
orientation?: 'horizontal' | 'vertical'
|
|
32
33
|
disabled?: boolean
|
|
34
|
+
/** Accessible name when the group is not inside a Field.Root (inside one,
|
|
35
|
+
* the Field.Label names it). */
|
|
36
|
+
'aria-label'?: string
|
|
37
|
+
/** id of the element that names the group, when not inside a Field.Root. */
|
|
38
|
+
'aria-labelledby'?: string
|
|
33
39
|
children: ReactNode
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -40,7 +46,9 @@ function Root({
|
|
|
40
46
|
orientation = 'vertical',
|
|
41
47
|
disabled = false,
|
|
42
48
|
children,
|
|
49
|
+
...labelling
|
|
43
50
|
}: RootProps) {
|
|
51
|
+
const a11y = useFieldGroupLabelling(labelling)
|
|
44
52
|
const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
|
|
45
53
|
|
|
46
54
|
const onToggle = useCallback(
|
|
@@ -57,6 +65,7 @@ function Root({
|
|
|
57
65
|
<html.div
|
|
58
66
|
role="group"
|
|
59
67
|
aria-orientation={orientation}
|
|
68
|
+
{...a11y}
|
|
60
69
|
style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
|
|
61
70
|
>
|
|
62
71
|
{children}
|
|
@@ -92,6 +101,9 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
|
|
|
92
101
|
)
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
// inside a Field.Root, the label names this group (see Field's `group`)
|
|
105
|
+
Root.isFieldGroup = true as const
|
|
106
|
+
|
|
95
107
|
export const CheckboxGroup = {
|
|
96
108
|
Root,
|
|
97
109
|
Item,
|
|
@@ -6,6 +6,7 @@ export const meta: ComponentMeta = {
|
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Any form input that needs a label and/or error message',
|
|
8
8
|
'Standalone labeled input (without Form) with manual error display',
|
|
9
|
+
'A labelled GROUP of controls (ToggleGroup, CheckboxGroup, RadioGroup): the label names the group via aria-labelledby — detected for a direct child, `group` for a nested one',
|
|
9
10
|
],
|
|
10
11
|
whenNotToUse: ['Bare input without label — use Input directly (rare)'],
|
|
11
12
|
anatomy: {
|
|
@@ -21,6 +22,21 @@ export const meta: ComponentMeta = {
|
|
|
21
22
|
{component: 'Input', kind: 'composition', relationship: 'Place Input inside Field.Root'},
|
|
22
23
|
{component: 'Textarea', kind: 'composition', relationship: 'Place Textarea inside Field.Root'},
|
|
23
24
|
{component: 'Select', kind: 'composition', relationship: 'Place Select inside Field.Root'},
|
|
25
|
+
{
|
|
26
|
+
component: 'ToggleGroup',
|
|
27
|
+
kind: 'composition',
|
|
28
|
+
relationship: 'Inside Field.Root the Field.Label names the group',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
component: 'CheckboxGroup',
|
|
32
|
+
kind: 'composition',
|
|
33
|
+
relationship: 'Inside Field.Root the Field.Label names the group',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
component: 'RadioGroup',
|
|
37
|
+
kind: 'composition',
|
|
38
|
+
relationship: 'Inside Field.Root the Field.Label names the group',
|
|
39
|
+
},
|
|
24
40
|
],
|
|
25
41
|
example: `// Inside Form (auto-binds validation)
|
|
26
42
|
<Field.Root name="email">
|
|
@@ -30,6 +46,17 @@ export const meta: ComponentMeta = {
|
|
|
30
46
|
<Field.Error />
|
|
31
47
|
</Field.Root>
|
|
32
48
|
|
|
49
|
+
// A group of controls — the label names the group (aria-labelledby).
|
|
50
|
+
// A direct ToggleGroup / CheckboxGroup / RadioGroup child is detected;
|
|
51
|
+
// pass \`group\` when the group is nested (e.g. inside a Stack).
|
|
52
|
+
<Field.Root>
|
|
53
|
+
<Field.Label>Type</Field.Label>
|
|
54
|
+
<ToggleGroup value={[type]} onValueChange={setType}>
|
|
55
|
+
<Toggle value="app">Application</Toggle>
|
|
56
|
+
<Toggle value="platform">Platform</Toggle>
|
|
57
|
+
</ToggleGroup>
|
|
58
|
+
</Field.Root>
|
|
59
|
+
|
|
33
60
|
// Standalone (manual error)
|
|
34
61
|
<Field.Root invalid>
|
|
35
62
|
<Field.Label>Email</Field.Label>
|
|
@@ -3,6 +3,13 @@ import {expect} from 'storybook/test'
|
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {Field} from './Field'
|
|
5
5
|
import {Input} from '../Input/Input'
|
|
6
|
+
import {ToggleGroup} from '../ToggleGroup/ToggleGroup'
|
|
7
|
+
import {Toggle} from '../Toggle/Toggle'
|
|
8
|
+
import {CheckboxGroup} from '../CheckboxGroup/CheckboxGroup'
|
|
9
|
+
import {RadioGroup} from '../RadioGroup/RadioGroup'
|
|
10
|
+
import {Checkbox} from '../Checkbox/Checkbox'
|
|
11
|
+
import {Stack} from '../Stack/Stack'
|
|
12
|
+
import {Fieldset} from '../Fieldset/Fieldset'
|
|
6
13
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
7
14
|
|
|
8
15
|
const meta: Meta = {
|
|
@@ -159,3 +166,89 @@ export const Disabled: Story = {
|
|
|
159
166
|
await expect(input).toBeDisabled()
|
|
160
167
|
},
|
|
161
168
|
}
|
|
169
|
+
|
|
170
|
+
export const GroupControls: Story = {
|
|
171
|
+
name: 'Group controls named by the label',
|
|
172
|
+
render: () => (
|
|
173
|
+
<html.div style={stackStyles.stack}>
|
|
174
|
+
{/* a direct ToggleGroup / CheckboxGroup / RadioGroup child is detected */}
|
|
175
|
+
<Field.Root>
|
|
176
|
+
<Field.Label>Type</Field.Label>
|
|
177
|
+
<ToggleGroup defaultValue={['app']}>
|
|
178
|
+
<Toggle value="app">Application</Toggle>
|
|
179
|
+
<Toggle value="platform">Platform</Toggle>
|
|
180
|
+
</ToggleGroup>
|
|
181
|
+
<Field.Description>What the record describes.</Field.Description>
|
|
182
|
+
</Field.Root>
|
|
183
|
+
<Field.Root>
|
|
184
|
+
<Field.Label>Channels</Field.Label>
|
|
185
|
+
<CheckboxGroup.Root defaultValue={['mail']}>
|
|
186
|
+
<CheckboxGroup.Item value="mail">Mail</CheckboxGroup.Item>
|
|
187
|
+
<CheckboxGroup.Item value="sms">SMS</CheckboxGroup.Item>
|
|
188
|
+
</CheckboxGroup.Root>
|
|
189
|
+
</Field.Root>
|
|
190
|
+
<Field.Root>
|
|
191
|
+
<Field.Label>Plan</Field.Label>
|
|
192
|
+
<RadioGroup.Root defaultValue="free">
|
|
193
|
+
<RadioGroup.Item value="free">Free</RadioGroup.Item>
|
|
194
|
+
<RadioGroup.Item value="pro">Pro</RadioGroup.Item>
|
|
195
|
+
</RadioGroup.Root>
|
|
196
|
+
</Field.Root>
|
|
197
|
+
{/* a group that is not a direct child (here inside a Stack) says so */}
|
|
198
|
+
<Field.Root group>
|
|
199
|
+
<Field.Label>Technologies</Field.Label>
|
|
200
|
+
<Stack gap="sm">
|
|
201
|
+
<CheckboxGroup.Root orientation="horizontal">
|
|
202
|
+
<CheckboxGroup.Item value="pg">PostgreSQL</CheckboxGroup.Item>
|
|
203
|
+
<CheckboxGroup.Item value="kafka">Kafka</CheckboxGroup.Item>
|
|
204
|
+
</CheckboxGroup.Root>
|
|
205
|
+
</Stack>
|
|
206
|
+
</Field.Root>
|
|
207
|
+
</html.div>
|
|
208
|
+
),
|
|
209
|
+
play: async ({canvas}) => {
|
|
210
|
+
// each group control carries the Field.Label's text as its accessible name
|
|
211
|
+
await expect(canvas.getByRole('toolbar', {name: 'Type'})).toBeInTheDocument()
|
|
212
|
+
await expect(canvas.getByRole('group', {name: 'Channels'})).toBeInTheDocument()
|
|
213
|
+
await expect(canvas.getByRole('radiogroup', {name: 'Plan'})).toBeInTheDocument()
|
|
214
|
+
await expect(canvas.getByRole('group', {name: 'Technologies'})).toBeInTheDocument()
|
|
215
|
+
// the description reaches the group too
|
|
216
|
+
await expect(canvas.getByRole('toolbar', {name: 'Type'})).toHaveAccessibleDescription(
|
|
217
|
+
'What the record describes.',
|
|
218
|
+
)
|
|
219
|
+
// a group's label is not a <label for> pointing at nothing
|
|
220
|
+
for (const text of ['Type', 'Channels', 'Plan', 'Technologies']) {
|
|
221
|
+
const label = canvas.getByText(text)
|
|
222
|
+
await expect(label.tagName).not.toBe('LABEL')
|
|
223
|
+
await expect(label.id).not.toBe('')
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const SingleControlStillLabelled: Story = {
|
|
229
|
+
name: 'A single input keeps its <label for>',
|
|
230
|
+
render: () => (
|
|
231
|
+
<Field.Root>
|
|
232
|
+
<Field.Label>Name</Field.Label>
|
|
233
|
+
<Input placeholder="Name" />
|
|
234
|
+
</Field.Root>
|
|
235
|
+
),
|
|
236
|
+
play: async ({canvas}) => {
|
|
237
|
+
const label = canvas.getByText('Name')
|
|
238
|
+
await expect(label.tagName).toBe('LABEL')
|
|
239
|
+
await expect(canvas.getByRole('textbox', {name: 'Name'})).toBeInTheDocument()
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const FieldsetLegendNamesTheGroup: Story = {
|
|
244
|
+
name: 'Fieldset legend names its group',
|
|
245
|
+
render: () => (
|
|
246
|
+
<Fieldset.Root>
|
|
247
|
+
<Fieldset.Legend>Notifications</Fieldset.Legend>
|
|
248
|
+
<Checkbox value="a">Weekly digest</Checkbox>
|
|
249
|
+
</Fieldset.Root>
|
|
250
|
+
),
|
|
251
|
+
play: async ({canvas}) => {
|
|
252
|
+
await expect(canvas.getByRole('group', {name: 'Notifications'})).toBeInTheDocument()
|
|
253
|
+
},
|
|
254
|
+
}
|
|
@@ -9,6 +9,11 @@ import {styles} from './styles.css'
|
|
|
9
9
|
// --- Root ---
|
|
10
10
|
interface RootProps {
|
|
11
11
|
name?: string
|
|
12
|
+
/** The control is a group of controls rather than one input — the label
|
|
13
|
+
* then names the group (`aria-labelledby`) instead of pointing `for` at an
|
|
14
|
+
* input. Detected automatically when a direct child is a ToggleGroup,
|
|
15
|
+
* CheckboxGroup.Root or RadioGroup.Root; set it for any other group. */
|
|
16
|
+
group?: boolean
|
|
12
17
|
invalid?: boolean
|
|
13
18
|
required?: boolean
|
|
14
19
|
disabled?: boolean
|
|
@@ -16,18 +21,31 @@ interface RootProps {
|
|
|
16
21
|
children: ReactNode
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
/** A group control marks itself so a Field can tell it holds a group. */
|
|
25
|
+
export type FieldGroupComponent = {isFieldGroup?: true}
|
|
26
|
+
|
|
27
|
+
function holdsGroup(children: ReactNode): boolean {
|
|
28
|
+
let found = false
|
|
29
|
+
Children.forEach(children, (child) => {
|
|
30
|
+
if (isValidElement(child) && (child.type as FieldGroupComponent).isFieldGroup) found = true
|
|
31
|
+
})
|
|
32
|
+
return found
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Root({name, group, ...props}: RootProps) {
|
|
20
36
|
const formCtx = useFormContext()
|
|
37
|
+
const isGroup = group ?? holdsGroup(props.children)
|
|
21
38
|
if (name && formCtx) {
|
|
22
|
-
return <ControlledRoot name={name} formCtx={formCtx} {...props} />
|
|
39
|
+
return <ControlledRoot name={name} formCtx={formCtx} group={isGroup} {...props} />
|
|
23
40
|
}
|
|
24
|
-
return <StaticRoot {...props} />
|
|
41
|
+
return <StaticRoot group={isGroup} {...props} />
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
// Always calls useController — no conditional hooks
|
|
28
45
|
function ControlledRoot({
|
|
29
46
|
name,
|
|
30
47
|
formCtx,
|
|
48
|
+
group,
|
|
31
49
|
invalid: invalidProp = false,
|
|
32
50
|
required,
|
|
33
51
|
disabled,
|
|
@@ -36,6 +54,7 @@ function ControlledRoot({
|
|
|
36
54
|
}: {
|
|
37
55
|
name: string
|
|
38
56
|
formCtx: NonNullable<ReturnType<typeof useFormContext>>
|
|
57
|
+
group: boolean
|
|
39
58
|
invalid?: boolean
|
|
40
59
|
required?: boolean
|
|
41
60
|
disabled?: boolean
|
|
@@ -54,6 +73,8 @@ function ControlledRoot({
|
|
|
54
73
|
const ctx = useMemo(
|
|
55
74
|
() => ({
|
|
56
75
|
controlId: `${id}-control`,
|
|
76
|
+
labelId: `${id}-label`,
|
|
77
|
+
group,
|
|
57
78
|
descriptionId: `${id}-description`,
|
|
58
79
|
errorId: `${id}-error`,
|
|
59
80
|
invalid,
|
|
@@ -72,6 +93,7 @@ function ControlledRoot({
|
|
|
72
93
|
}),
|
|
73
94
|
[
|
|
74
95
|
id,
|
|
96
|
+
group,
|
|
75
97
|
invalid,
|
|
76
98
|
required,
|
|
77
99
|
effectiveDisabled,
|
|
@@ -95,12 +117,14 @@ function ControlledRoot({
|
|
|
95
117
|
|
|
96
118
|
// Current behavior, no RHF dependency
|
|
97
119
|
function StaticRoot({
|
|
120
|
+
group,
|
|
98
121
|
invalid = false,
|
|
99
122
|
required,
|
|
100
123
|
disabled,
|
|
101
124
|
labelPosition = 'top',
|
|
102
125
|
children,
|
|
103
126
|
}: {
|
|
127
|
+
group: boolean
|
|
104
128
|
invalid?: boolean
|
|
105
129
|
required?: boolean
|
|
106
130
|
disabled?: boolean
|
|
@@ -111,6 +135,8 @@ function StaticRoot({
|
|
|
111
135
|
const ctx = useMemo(
|
|
112
136
|
() => ({
|
|
113
137
|
controlId: `${id}-control`,
|
|
138
|
+
labelId: `${id}-label`,
|
|
139
|
+
group,
|
|
114
140
|
descriptionId: `${id}-description`,
|
|
115
141
|
errorId: `${id}-error`,
|
|
116
142
|
invalid,
|
|
@@ -118,7 +144,7 @@ function StaticRoot({
|
|
|
118
144
|
disabled,
|
|
119
145
|
labelPosition,
|
|
120
146
|
}),
|
|
121
|
-
[id, invalid, required, disabled, labelPosition],
|
|
147
|
+
[id, group, invalid, required, disabled, labelPosition],
|
|
122
148
|
)
|
|
123
149
|
|
|
124
150
|
return (
|
|
@@ -170,9 +196,8 @@ function Label({children}: LabelProps) {
|
|
|
170
196
|
const ctx = useFieldContext()
|
|
171
197
|
const isSide = ctx?.labelPosition === 'side'
|
|
172
198
|
const indicator = ctx?.necessityIndicator
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<html.label for={ctx?.controlId} style={[styles.label, isSide && styles.labelSide]}>
|
|
199
|
+
const content = (
|
|
200
|
+
<>
|
|
176
201
|
{children}
|
|
177
202
|
{indicator === 'icon' && ctx?.required && (
|
|
178
203
|
<html.span style={styles.necessityIcon} aria-hidden={true}>
|
|
@@ -184,6 +209,25 @@ function Label({children}: LabelProps) {
|
|
|
184
209
|
{ctx?.required ? ' (required)' : ' (optional)'}
|
|
185
210
|
</html.span>
|
|
186
211
|
)}
|
|
212
|
+
</>
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
// A group is not a labelable element: `for` would point at nothing. The
|
|
216
|
+
// group names itself from this id instead (aria-labelledby).
|
|
217
|
+
if (ctx?.group) {
|
|
218
|
+
return (
|
|
219
|
+
<html.span id={ctx.labelId} style={[styles.label, isSide && styles.labelSide]}>
|
|
220
|
+
{content}
|
|
221
|
+
</html.span>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
return (
|
|
225
|
+
<html.label
|
|
226
|
+
id={ctx?.labelId}
|
|
227
|
+
for={ctx?.controlId}
|
|
228
|
+
style={[styles.label, isSide && styles.labelSide]}
|
|
229
|
+
>
|
|
230
|
+
{content}
|
|
187
231
|
</html.label>
|
|
188
232
|
)
|
|
189
233
|
}
|
|
@@ -3,6 +3,12 @@ import type {LabelPosition, NecessityIndicator} from '../Form/FormContext'
|
|
|
3
3
|
|
|
4
4
|
interface FieldContextValue {
|
|
5
5
|
controlId: string
|
|
6
|
+
/** id of the Field.Label — what a GROUP control points `aria-labelledby` at. */
|
|
7
|
+
labelId: string
|
|
8
|
+
/** The control is a group (ToggleGroup, CheckboxGroup, RadioGroup, a set of
|
|
9
|
+
* checkboxes): the label names the group instead of pointing `for` at one
|
|
10
|
+
* input, which a group is not. */
|
|
11
|
+
group?: boolean
|
|
6
12
|
descriptionId: string
|
|
7
13
|
errorId: string
|
|
8
14
|
invalid: boolean
|
|
@@ -27,3 +33,22 @@ export const FieldContext = createContext<FieldContextValue | null>(null)
|
|
|
27
33
|
export function useFieldContext() {
|
|
28
34
|
return useContext(FieldContext)
|
|
29
35
|
}
|
|
36
|
+
|
|
37
|
+
interface GroupLabelling {
|
|
38
|
+
'aria-label'?: string
|
|
39
|
+
'aria-labelledby'?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The accessible-name wiring for a group control: its own `aria-label` /
|
|
43
|
+
* `aria-labelledby` when given, else the enclosing Field's label and its
|
|
44
|
+
* description / error. Outside a Field with neither, the group is unnamed —
|
|
45
|
+
* pass one. */
|
|
46
|
+
export function useFieldGroupLabelling(own: GroupLabelling) {
|
|
47
|
+
const ctx = useFieldContext()
|
|
48
|
+
const describedBy = ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : ''
|
|
49
|
+
return {
|
|
50
|
+
'aria-label': own['aria-label'],
|
|
51
|
+
'aria-labelledby': own['aria-labelledby'] ?? (own['aria-label'] ? undefined : ctx?.labelId),
|
|
52
|
+
'aria-describedby': describedBy || undefined,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import {type ReactNode, Children, createContext, isValidElement, useContext, useId} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import type {SpacingToken} from '@duro-app/tokens/keys'
|
|
4
4
|
import {styles} from './styles.css'
|
|
@@ -23,15 +23,26 @@ interface RootProps {
|
|
|
23
23
|
children: ReactNode
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// the Legend's id, so the group can name itself after it
|
|
27
|
+
const LegendIdContext = createContext<string | undefined>(undefined)
|
|
28
|
+
|
|
26
29
|
function Root({disabled = false, gap = 'md', children}: RootProps) {
|
|
30
|
+
const legendId = useId()
|
|
31
|
+
let hasLegend = false
|
|
32
|
+
Children.forEach(children, (child) => {
|
|
33
|
+
if (isValidElement(child) && child.type === Legend) hasLegend = true
|
|
34
|
+
})
|
|
27
35
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
<LegendIdContext.Provider value={legendId}>
|
|
37
|
+
<html.div
|
|
38
|
+
role="group"
|
|
39
|
+
aria-labelledby={hasLegend ? legendId : undefined}
|
|
40
|
+
aria-disabled={disabled || undefined}
|
|
41
|
+
style={[styles.root, gapMap[gap], disabled && styles.disabled]}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</html.div>
|
|
45
|
+
</LegendIdContext.Provider>
|
|
35
46
|
)
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -40,8 +51,14 @@ interface LegendProps {
|
|
|
40
51
|
children: ReactNode
|
|
41
52
|
}
|
|
42
53
|
|
|
54
|
+
/** Names the group: the Root points `aria-labelledby` at it. */
|
|
43
55
|
function Legend({children}: LegendProps) {
|
|
44
|
-
|
|
56
|
+
const id = useContext(LegendIdContext)
|
|
57
|
+
return (
|
|
58
|
+
<html.span id={id} style={styles.legend}>
|
|
59
|
+
{children}
|
|
60
|
+
</html.span>
|
|
61
|
+
)
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
export const Fieldset = {
|
|
@@ -2,6 +2,7 @@ import {type ReactNode, createContext, useContext, useCallback} from 'react'
|
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import {styles} from './styles.css'
|
|
5
|
+
import {useFieldGroupLabelling} from '../Field/FieldContext'
|
|
5
6
|
|
|
6
7
|
// --- Context ---
|
|
7
8
|
|
|
@@ -29,6 +30,11 @@ interface RootProps {
|
|
|
29
30
|
onValueChange?: (value: string) => void
|
|
30
31
|
orientation?: 'horizontal' | 'vertical'
|
|
31
32
|
disabled?: boolean
|
|
33
|
+
/** Accessible name when the group is not inside a Field.Root (inside one,
|
|
34
|
+
* the Field.Label names it). */
|
|
35
|
+
'aria-label'?: string
|
|
36
|
+
/** id of the element that names the group, when not inside a Field.Root. */
|
|
37
|
+
'aria-labelledby'?: string
|
|
32
38
|
children: ReactNode
|
|
33
39
|
}
|
|
34
40
|
|
|
@@ -39,7 +45,9 @@ function Root({
|
|
|
39
45
|
orientation = 'vertical',
|
|
40
46
|
disabled = false,
|
|
41
47
|
children,
|
|
48
|
+
...labelling
|
|
42
49
|
}: RootProps) {
|
|
50
|
+
const a11y = useFieldGroupLabelling(labelling)
|
|
43
51
|
const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
|
|
44
52
|
|
|
45
53
|
const onSelect = useCallback(
|
|
@@ -56,6 +64,7 @@ function Root({
|
|
|
56
64
|
<html.div
|
|
57
65
|
role="radiogroup"
|
|
58
66
|
aria-orientation={orientation}
|
|
67
|
+
{...a11y}
|
|
59
68
|
style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
|
|
60
69
|
>
|
|
61
70
|
{children}
|
|
@@ -104,6 +113,9 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
|
|
|
104
113
|
)
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
// inside a Field.Root, the label names this group (see Field's `group`)
|
|
117
|
+
Root.isFieldGroup = true as const
|
|
118
|
+
|
|
107
119
|
export const RadioGroup = {
|
|
108
120
|
Root,
|
|
109
121
|
Item,
|
|
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
|
|
|
3
3
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
4
4
|
import type {ToggleSize} from '../Toggle/Toggle'
|
|
5
5
|
import {ToggleGroupContext, type Orientation} from './ToggleGroupContext'
|
|
6
|
+
import {useFieldGroupLabelling} from '../Field/FieldContext'
|
|
6
7
|
import {styles} from './styles.css'
|
|
7
8
|
|
|
8
9
|
interface ToggleGroupProps {
|
|
@@ -20,6 +21,11 @@ interface ToggleGroupProps {
|
|
|
20
21
|
orientation?: Orientation
|
|
21
22
|
/** Size applied to all child toggles. */
|
|
22
23
|
size?: ToggleSize
|
|
24
|
+
/** Accessible name when the group is not inside a Field.Root (inside one,
|
|
25
|
+
* the Field.Label names it). */
|
|
26
|
+
'aria-label'?: string
|
|
27
|
+
/** id of the element that names the group, when not inside a Field.Root. */
|
|
28
|
+
'aria-labelledby'?: string
|
|
23
29
|
children: ReactNode
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -32,7 +38,9 @@ export function ToggleGroup({
|
|
|
32
38
|
orientation = 'horizontal',
|
|
33
39
|
size = 'default',
|
|
34
40
|
children,
|
|
41
|
+
...labelling
|
|
35
42
|
}: ToggleGroupProps) {
|
|
43
|
+
const a11y = useFieldGroupLabelling(labelling)
|
|
36
44
|
const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
|
|
37
45
|
|
|
38
46
|
const toggle = useCallback(
|
|
@@ -54,6 +62,7 @@ export function ToggleGroup({
|
|
|
54
62
|
<html.div
|
|
55
63
|
role="toolbar"
|
|
56
64
|
aria-orientation={orientation}
|
|
65
|
+
{...a11y}
|
|
57
66
|
style={[styles.root, orientation === 'vertical' && styles.vertical]}
|
|
58
67
|
>
|
|
59
68
|
{children}
|
|
@@ -61,3 +70,6 @@ export function ToggleGroup({
|
|
|
61
70
|
</ToggleGroupContext.Provider>
|
|
62
71
|
)
|
|
63
72
|
}
|
|
73
|
+
|
|
74
|
+
// inside a Field.Root, the label names this group (see Field's `group`)
|
|
75
|
+
ToggleGroup.isFieldGroup = true as const
|