@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "3.2.1",
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.2.1"
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
- function Root({name, ...props}: RootProps) {
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
- return (
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 {ReactNode} from 'react'
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
- <html.div
29
- role="group"
30
- aria-disabled={disabled || undefined}
31
- style={[styles.root, gapMap[gap], disabled && styles.disabled]}
32
- >
33
- {children}
34
- </html.div>
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
- return <html.span style={styles.legend}>{children}</html.span>
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