@discourser/design-system 0.3.0 → 0.4.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.
@@ -0,0 +1,757 @@
1
+ # RadioGroup
2
+
3
+ **Purpose:** Provides mutually exclusive selection between multiple options, allowing users to choose exactly one item from a set of choices.
4
+
5
+ ## Import
6
+
7
+ ```typescript
8
+ import { RadioGroup } from '@discourser/design-system';
9
+ ```
10
+
11
+ ## Component Structure
12
+
13
+ RadioGroup is a compound component built on Ark UI, providing a complete solution for radio button groups with built-in accessibility and state management.
14
+
15
+ ### Anatomy
16
+
17
+ ```typescript
18
+ <RadioGroup.Root>
19
+ <RadioGroup.Label />
20
+ <RadioGroup.Item>
21
+ <RadioGroup.ItemControl>
22
+ <RadioGroup.Indicator />
23
+ </RadioGroup.ItemControl>
24
+ <RadioGroup.ItemText />
25
+ <RadioGroup.ItemHiddenInput />
26
+ </RadioGroup.Item>
27
+ </RadioGroup.Root>
28
+ ```
29
+
30
+ **Component Parts:**
31
+
32
+ - **Root**: Container that manages the radio group state and keyboard navigation
33
+ - **Label**: Optional label for the entire group
34
+ - **Item**: Individual radio option container
35
+ - **ItemControl**: Visual radio button (circle with inner dot when selected)
36
+ - **Indicator**: Visual indicator shown when radio is selected
37
+ - **ItemText**: Label text for the individual radio option
38
+ - **ItemHiddenInput**: Hidden native input for form integration and accessibility
39
+
40
+ ## Variants
41
+
42
+ | Variant | Visual Style | Usage | When to Use |
43
+ | ------- | ------------------------------------------------- | ---------------------- | ---------------------------------------------- |
44
+ | `solid` | Filled circle with color background when selected | Standard radio buttons | All use cases, default choice for radio groups |
45
+
46
+ **Note:** Currently only the `solid` variant is implemented, providing a clean, Material Design-inspired appearance.
47
+
48
+ ### Visual Characteristics
49
+
50
+ - **solid**: Gray border circle in default state, colored background with white inner dot when selected
51
+
52
+ ## Sizes
53
+
54
+ | Size | Control Size | Gap | Font Size | Usage |
55
+ | ---- | ------------ | -------- | --------- | --------------------------------------------------------- |
56
+ | `sm` | 18px (4.5) | 8px (2) | sm | Compact forms, dense layouts, space-constrained UI |
57
+ | `md` | 20px (5) | 12px (3) | md | Default, most use cases, standard forms |
58
+ | `lg` | 22px (5.5) | 12px (3) | lg | Touch-friendly interfaces, emphasis, mobile-first designs |
59
+
60
+ **Recommendation:** Use `md` for most cases. Use `lg` for mobile-first designs or when prioritizing touch accessibility.
61
+
62
+ ## Orientation
63
+
64
+ RadioGroup supports both horizontal and vertical layouts:
65
+
66
+ | Orientation | Layout Direction | When to Use |
67
+ | ------------ | --------------------- | ---------------------------------------------- |
68
+ | `vertical` | Stacked vertically | Default, most forms, multiple options (3+) |
69
+ | `horizontal` | Arranged horizontally | Simple choices (2-3 options), toolbar settings |
70
+
71
+ ## Props
72
+
73
+ ### Root Props
74
+
75
+ | Prop | Type | Default | Description |
76
+ | --------------- | -------------------------------------- | ------------ | --------------------------------------- |
77
+ | `variant` | `'solid'` | `'solid'` | Visual style variant |
78
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Control and text size |
79
+ | `orientation` | `'horizontal' \| 'vertical'` | `'vertical'` | Layout direction |
80
+ | `value` | `string` | - | Currently selected value (controlled) |
81
+ | `defaultValue` | `string` | - | Initially selected value (uncontrolled) |
82
+ | `disabled` | `boolean` | `false` | Disable entire radio group |
83
+ | `onValueChange` | `(details: { value: string }) => void` | - | Callback when selection changes |
84
+ | `name` | `string` | - | Form field name |
85
+
86
+ ### Item Props
87
+
88
+ | Prop | Type | Default | Description |
89
+ | ---------- | --------- | -------- | --------------------------------- |
90
+ | `value` | `string` | Required | Unique identifier for this option |
91
+ | `disabled` | `boolean` | `false` | Disable this specific option |
92
+ | `invalid` | `boolean` | `false` | Mark this option as invalid |
93
+
94
+ **Note:** RadioGroup.Root extends Ark UI's RadioGroupRootProps, supporting all native radio group attributes.
95
+
96
+ ## Examples
97
+
98
+ ### Basic Usage
99
+
100
+ ```typescript
101
+ // Uncontrolled (internal state management)
102
+ <RadioGroup.Root defaultValue="option1">
103
+ <RadioGroup.Label>Choose an option</RadioGroup.Label>
104
+ <RadioGroup.Item value="option1">
105
+ <RadioGroup.ItemControl>
106
+ <RadioGroup.Indicator />
107
+ </RadioGroup.ItemControl>
108
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
109
+ <RadioGroup.ItemHiddenInput />
110
+ </RadioGroup.Item>
111
+ <RadioGroup.Item value="option2">
112
+ <RadioGroup.ItemControl>
113
+ <RadioGroup.Indicator />
114
+ </RadioGroup.ItemControl>
115
+ <RadioGroup.ItemText>Option 2</RadioGroup.ItemText>
116
+ <RadioGroup.ItemHiddenInput />
117
+ </RadioGroup.Item>
118
+ <RadioGroup.Item value="option3">
119
+ <RadioGroup.ItemControl>
120
+ <RadioGroup.Indicator />
121
+ </RadioGroup.ItemControl>
122
+ <RadioGroup.ItemText>Option 3</RadioGroup.ItemText>
123
+ <RadioGroup.ItemHiddenInput />
124
+ </RadioGroup.Item>
125
+ </RadioGroup.Root>
126
+ ```
127
+
128
+ ### Controlled Usage
129
+
130
+ ```typescript
131
+ const [selectedValue, setSelectedValue] = useState('medium');
132
+
133
+ <RadioGroup.Root
134
+ value={selectedValue}
135
+ onValueChange={(details) => setSelectedValue(details.value)}
136
+ >
137
+ <RadioGroup.Label>Select size</RadioGroup.Label>
138
+ <RadioGroup.Item value="small">
139
+ <RadioGroup.ItemControl>
140
+ <RadioGroup.Indicator />
141
+ </RadioGroup.ItemControl>
142
+ <RadioGroup.ItemText>Small</RadioGroup.ItemText>
143
+ <RadioGroup.ItemHiddenInput />
144
+ </RadioGroup.Item>
145
+ <RadioGroup.Item value="medium">
146
+ <RadioGroup.ItemControl>
147
+ <RadioGroup.Indicator />
148
+ </RadioGroup.ItemControl>
149
+ <RadioGroup.ItemText>Medium</RadioGroup.ItemText>
150
+ <RadioGroup.ItemHiddenInput />
151
+ </RadioGroup.Item>
152
+ <RadioGroup.Item value="large">
153
+ <RadioGroup.ItemControl>
154
+ <RadioGroup.Indicator />
155
+ </RadioGroup.ItemControl>
156
+ <RadioGroup.ItemText>Large</RadioGroup.ItemText>
157
+ <RadioGroup.ItemHiddenInput />
158
+ </RadioGroup.Item>
159
+ </RadioGroup.Root>
160
+ ```
161
+
162
+ ### Different Sizes
163
+
164
+ ```typescript
165
+ // Small size (compact)
166
+ <RadioGroup.Root size="sm" defaultValue="option1">
167
+ <RadioGroup.Label>Small Radio Group</RadioGroup.Label>
168
+ <RadioGroup.Item value="option1">
169
+ <RadioGroup.ItemControl>
170
+ <RadioGroup.Indicator />
171
+ </RadioGroup.ItemControl>
172
+ <RadioGroup.ItemText>Compact option</RadioGroup.ItemText>
173
+ <RadioGroup.ItemHiddenInput />
174
+ </RadioGroup.Item>
175
+ </RadioGroup.Root>
176
+
177
+ // Large size (touch-friendly)
178
+ <RadioGroup.Root size="lg" defaultValue="option1">
179
+ <RadioGroup.Label>Large Radio Group</RadioGroup.Label>
180
+ <RadioGroup.Item value="option1">
181
+ <RadioGroup.ItemControl>
182
+ <RadioGroup.Indicator />
183
+ </RadioGroup.ItemControl>
184
+ <RadioGroup.ItemText>Touch-friendly option</RadioGroup.ItemText>
185
+ <RadioGroup.ItemHiddenInput />
186
+ </RadioGroup.Item>
187
+ </RadioGroup.Root>
188
+ ```
189
+
190
+ ### Horizontal Layout
191
+
192
+ ```typescript
193
+ <RadioGroup.Root orientation="horizontal" defaultValue="yes">
194
+ <RadioGroup.Label>Enable notifications?</RadioGroup.Label>
195
+ <RadioGroup.Item value="yes">
196
+ <RadioGroup.ItemControl>
197
+ <RadioGroup.Indicator />
198
+ </RadioGroup.ItemControl>
199
+ <RadioGroup.ItemText>Yes</RadioGroup.ItemText>
200
+ <RadioGroup.ItemHiddenInput />
201
+ </RadioGroup.Item>
202
+ <RadioGroup.Item value="no">
203
+ <RadioGroup.ItemControl>
204
+ <RadioGroup.Indicator />
205
+ </RadioGroup.ItemControl>
206
+ <RadioGroup.ItemText>No</RadioGroup.ItemText>
207
+ <RadioGroup.ItemHiddenInput />
208
+ </RadioGroup.Item>
209
+ </RadioGroup.Root>
210
+ ```
211
+
212
+ ### Disabled States
213
+
214
+ ```typescript
215
+ // Entire group disabled
216
+ <RadioGroup.Root disabled defaultValue="option1">
217
+ <RadioGroup.Label>Disabled Group</RadioGroup.Label>
218
+ <RadioGroup.Item value="option1">
219
+ <RadioGroup.ItemControl>
220
+ <RadioGroup.Indicator />
221
+ </RadioGroup.ItemControl>
222
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
223
+ <RadioGroup.ItemHiddenInput />
224
+ </RadioGroup.Item>
225
+ <RadioGroup.Item value="option2">
226
+ <RadioGroup.ItemControl>
227
+ <RadioGroup.Indicator />
228
+ </RadioGroup.ItemControl>
229
+ <RadioGroup.ItemText>Option 2</RadioGroup.ItemText>
230
+ <RadioGroup.ItemHiddenInput />
231
+ </RadioGroup.Item>
232
+ </RadioGroup.Root>
233
+
234
+ // Individual option disabled
235
+ <RadioGroup.Root defaultValue="option1">
236
+ <RadioGroup.Label>Partially Disabled</RadioGroup.Label>
237
+ <RadioGroup.Item value="option1">
238
+ <RadioGroup.ItemControl>
239
+ <RadioGroup.Indicator />
240
+ </RadioGroup.ItemControl>
241
+ <RadioGroup.ItemText>Available option</RadioGroup.ItemText>
242
+ <RadioGroup.ItemHiddenInput />
243
+ </RadioGroup.Item>
244
+ <RadioGroup.Item value="option2" disabled>
245
+ <RadioGroup.ItemControl>
246
+ <RadioGroup.Indicator />
247
+ </RadioGroup.ItemControl>
248
+ <RadioGroup.ItemText>Disabled option</RadioGroup.ItemText>
249
+ <RadioGroup.ItemHiddenInput />
250
+ </RadioGroup.Item>
251
+ </RadioGroup.Root>
252
+ ```
253
+
254
+ ### Form Integration
255
+
256
+ ```typescript
257
+ <form onSubmit={handleSubmit}>
258
+ <RadioGroup.Root name="deliveryMethod" defaultValue="standard">
259
+ <RadioGroup.Label>Delivery Method</RadioGroup.Label>
260
+ <RadioGroup.Item value="standard">
261
+ <RadioGroup.ItemControl>
262
+ <RadioGroup.Indicator />
263
+ </RadioGroup.ItemControl>
264
+ <RadioGroup.ItemText>Standard (5-7 days)</RadioGroup.ItemText>
265
+ <RadioGroup.ItemHiddenInput />
266
+ </RadioGroup.Item>
267
+ <RadioGroup.Item value="express">
268
+ <RadioGroup.ItemControl>
269
+ <RadioGroup.Indicator />
270
+ </RadioGroup.ItemControl>
271
+ <RadioGroup.ItemText>Express (2-3 days)</RadioGroup.ItemText>
272
+ <RadioGroup.ItemHiddenInput />
273
+ </RadioGroup.Item>
274
+ <RadioGroup.Item value="overnight">
275
+ <RadioGroup.ItemControl>
276
+ <RadioGroup.Indicator />
277
+ </RadioGroup.ItemControl>
278
+ <RadioGroup.ItemText>Overnight</RadioGroup.ItemText>
279
+ <RadioGroup.ItemHiddenInput />
280
+ </RadioGroup.Item>
281
+ </RadioGroup.Root>
282
+ <Button type="submit">Continue</Button>
283
+ </form>
284
+ ```
285
+
286
+ ## Common Patterns
287
+
288
+ ### Dynamic Options from Data
289
+
290
+ ```typescript
291
+ const deliveryOptions = [
292
+ { value: 'standard', label: 'Standard Shipping', description: '5-7 business days' },
293
+ { value: 'express', label: 'Express Shipping', description: '2-3 business days' },
294
+ { value: 'overnight', label: 'Overnight Shipping', description: 'Next business day' },
295
+ ];
296
+
297
+ <RadioGroup.Root defaultValue="standard">
298
+ <RadioGroup.Label>Choose delivery method</RadioGroup.Label>
299
+ {deliveryOptions.map((option) => (
300
+ <RadioGroup.Item key={option.value} value={option.value}>
301
+ <RadioGroup.ItemControl>
302
+ <RadioGroup.Indicator />
303
+ </RadioGroup.ItemControl>
304
+ <RadioGroup.ItemText>
305
+ <div>
306
+ <div>{option.label}</div>
307
+ <div className={css({ color: 'fg.subtle', fontSize: 'sm' })}>
308
+ {option.description}
309
+ </div>
310
+ </div>
311
+ </RadioGroup.ItemText>
312
+ <RadioGroup.ItemHiddenInput />
313
+ </RadioGroup.Item>
314
+ ))}
315
+ </RadioGroup.Root>
316
+ ```
317
+
318
+ ### With Validation
319
+
320
+ ```typescript
321
+ const [value, setValue] = useState('');
322
+ const [error, setError] = useState('');
323
+
324
+ const handleSubmit = () => {
325
+ if (!value) {
326
+ setError('Please select an option');
327
+ return;
328
+ }
329
+ setError('');
330
+ // Process form
331
+ };
332
+
333
+ <div>
334
+ <RadioGroup.Root
335
+ value={value}
336
+ onValueChange={(details) => {
337
+ setValue(details.value);
338
+ setError('');
339
+ }}
340
+ >
341
+ <RadioGroup.Label>Select your preference</RadioGroup.Label>
342
+ <RadioGroup.Item value="option1">
343
+ <RadioGroup.ItemControl>
344
+ <RadioGroup.Indicator />
345
+ </RadioGroup.ItemControl>
346
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
347
+ <RadioGroup.ItemHiddenInput />
348
+ </RadioGroup.Item>
349
+ <RadioGroup.Item value="option2">
350
+ <RadioGroup.ItemControl>
351
+ <RadioGroup.Indicator />
352
+ </RadioGroup.ItemControl>
353
+ <RadioGroup.ItemText>Option 2</RadioGroup.ItemText>
354
+ <RadioGroup.ItemHiddenInput />
355
+ </RadioGroup.Item>
356
+ </RadioGroup.Root>
357
+ {error && (
358
+ <div className={css({ color: 'error.fg', fontSize: 'sm', mt: '1' })}>
359
+ {error}
360
+ </div>
361
+ )}
362
+ </div>
363
+ ```
364
+
365
+ ### Settings Panel
366
+
367
+ ```typescript
368
+ <div className={css({ display: 'flex', flexDirection: 'column', gap: '6' })}>
369
+ <RadioGroup.Root defaultValue="light" name="theme">
370
+ <RadioGroup.Label>Theme Preference</RadioGroup.Label>
371
+ <RadioGroup.Item value="light">
372
+ <RadioGroup.ItemControl>
373
+ <RadioGroup.Indicator />
374
+ </RadioGroup.ItemControl>
375
+ <RadioGroup.ItemText>Light</RadioGroup.ItemText>
376
+ <RadioGroup.ItemHiddenInput />
377
+ </RadioGroup.Item>
378
+ <RadioGroup.Item value="dark">
379
+ <RadioGroup.ItemControl>
380
+ <RadioGroup.Indicator />
381
+ </RadioGroup.ItemControl>
382
+ <RadioGroup.ItemText>Dark</RadioGroup.ItemText>
383
+ <RadioGroup.ItemHiddenInput />
384
+ </RadioGroup.Item>
385
+ <RadioGroup.Item value="system">
386
+ <RadioGroup.ItemControl>
387
+ <RadioGroup.Indicator />
388
+ </RadioGroup.ItemControl>
389
+ <RadioGroup.ItemText>System</RadioGroup.ItemText>
390
+ <RadioGroup.ItemHiddenInput />
391
+ </RadioGroup.Item>
392
+ </RadioGroup.Root>
393
+
394
+ <RadioGroup.Root defaultValue="en" name="language">
395
+ <RadioGroup.Label>Language</RadioGroup.Label>
396
+ <RadioGroup.Item value="en">
397
+ <RadioGroup.ItemControl>
398
+ <RadioGroup.Indicator />
399
+ </RadioGroup.ItemControl>
400
+ <RadioGroup.ItemText>English</RadioGroup.ItemText>
401
+ <RadioGroup.ItemHiddenInput />
402
+ </RadioGroup.Item>
403
+ <RadioGroup.Item value="es">
404
+ <RadioGroup.ItemControl>
405
+ <RadioGroup.Indicator />
406
+ </RadioGroup.ItemControl>
407
+ <RadioGroup.ItemText>Español</RadioGroup.ItemText>
408
+ <RadioGroup.ItemHiddenInput />
409
+ </RadioGroup.Item>
410
+ <RadioGroup.Item value="fr">
411
+ <RadioGroup.ItemControl>
412
+ <RadioGroup.Indicator />
413
+ </RadioGroup.ItemControl>
414
+ <RadioGroup.ItemText>Français</RadioGroup.ItemText>
415
+ <RadioGroup.ItemHiddenInput />
416
+ </RadioGroup.Item>
417
+ </RadioGroup.Root>
418
+ </div>
419
+ ```
420
+
421
+ ## DO NOT
422
+
423
+ ```typescript
424
+ // ❌ Don't use for multiple selections (use Checkbox instead)
425
+ <RadioGroup.Root>
426
+ <RadioGroup.Label>Select all that apply</RadioGroup.Label>
427
+ {/* Radio groups are for single selection only */}
428
+ </RadioGroup.Root>
429
+
430
+ // ✅ Use Checkbox for multiple selections
431
+ <CheckboxGroup>
432
+ <Checkbox value="option1">Option 1</Checkbox>
433
+ <Checkbox value="option2">Option 2</Checkbox>
434
+ </CheckboxGroup>
435
+
436
+ // ❌ Don't use for many options (>7 items, use Select instead)
437
+ <RadioGroup.Root>
438
+ <RadioGroup.Item value="country1">Country 1</RadioGroup.Item>
439
+ <RadioGroup.Item value="country2">Country 2</RadioGroup.Item>
440
+ {/* ... 50+ more countries */}
441
+ </RadioGroup.Root>
442
+
443
+ // ✅ Use Select for many options
444
+ <Select.Root>
445
+ <Select.Trigger>
446
+ <Select.ValueText placeholder="Select country" />
447
+ </Select.Trigger>
448
+ <Select.Content>
449
+ {countries.map((country) => (
450
+ <Select.Item key={country.value} item={country.value}>
451
+ <Select.ItemText>{country.label}</Select.ItemText>
452
+ </Select.Item>
453
+ ))}
454
+ </Select.Content>
455
+ </Select.Root>
456
+
457
+ // ❌ Don't omit RadioGroup.ItemHiddenInput (breaks form submission)
458
+ <RadioGroup.Item value="option1">
459
+ <RadioGroup.ItemControl>
460
+ <RadioGroup.Indicator />
461
+ </RadioGroup.ItemControl>
462
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
463
+ {/* Missing ItemHiddenInput */}
464
+ </RadioGroup.Item>
465
+
466
+ // ✅ Always include ItemHiddenInput
467
+ <RadioGroup.Item value="option1">
468
+ <RadioGroup.ItemControl>
469
+ <RadioGroup.Indicator />
470
+ </RadioGroup.ItemControl>
471
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
472
+ <RadioGroup.ItemHiddenInput />
473
+ </RadioGroup.Item>
474
+
475
+ // ❌ Don't use for binary choices that are actions (use Switch instead)
476
+ <RadioGroup.Root>
477
+ <RadioGroup.Item value="enabled">Enable notifications</RadioGroup.Item>
478
+ <RadioGroup.Item value="disabled">Disable notifications</RadioGroup.Item>
479
+ </RadioGroup.Root>
480
+
481
+ // ✅ Use Switch for on/off toggles
482
+ <Switch.Root>
483
+ <Switch.Label>Enable notifications</Switch.Label>
484
+ <Switch.Control>
485
+ <Switch.Thumb />
486
+ </Switch.Control>
487
+ </Switch.Root>
488
+
489
+ // ❌ Don't create radio groups without a label
490
+ <RadioGroup.Root>
491
+ {/* No label - unclear what user is choosing */}
492
+ <RadioGroup.Item value="option1">...</RadioGroup.Item>
493
+ </RadioGroup.Root>
494
+
495
+ // ✅ Always provide a clear group label
496
+ <RadioGroup.Root>
497
+ <RadioGroup.Label>Select your preference</RadioGroup.Label>
498
+ <RadioGroup.Item value="option1">...</RadioGroup.Item>
499
+ </RadioGroup.Root>
500
+ ```
501
+
502
+ ## Accessibility
503
+
504
+ The RadioGroup component follows WCAG 2.1 Level AA standards:
505
+
506
+ - **Keyboard Navigation**:
507
+ - `Tab` moves focus to the selected radio or first radio if none selected
508
+ - Arrow keys (↑/↓ for vertical, ←/→ for horizontal) navigate between options
509
+ - `Space` selects the focused radio
510
+ - **Focus Management**: Clear focus indicator on ItemControl with 2px outline
511
+ - **Screen Readers**:
512
+ - Group labeled with `role="radiogroup"` and `aria-labelledby`
513
+ - Each radio has `role="radio"` and `aria-checked` state
514
+ - Hidden input ensures form submission works correctly
515
+ - **Disabled State**:
516
+ - Uses `aria-disabled` attribute
517
+ - Visual opacity reduction (layerStyle: 'disabled')
518
+ - Prevents interaction while maintaining focusability for context
519
+ - **Required Fields**: Use `aria-required` on Root for required groups
520
+
521
+ ### Accessibility Best Practices
522
+
523
+ ```typescript
524
+ // ✅ Always provide a descriptive group label
525
+ <RadioGroup.Root>
526
+ <RadioGroup.Label>Select shipping method</RadioGroup.Label>
527
+ {/* options */}
528
+ </RadioGroup.Root>
529
+
530
+ // ✅ Provide helpful descriptions for complex options
531
+ <RadioGroup.Item value="express">
532
+ <RadioGroup.ItemControl>
533
+ <RadioGroup.Indicator />
534
+ </RadioGroup.ItemControl>
535
+ <RadioGroup.ItemText>
536
+ <span>Express Shipping</span>
537
+ <span className={css({ fontSize: 'sm', color: 'fg.subtle' })}>
538
+ 2-3 business days, $15.99
539
+ </span>
540
+ </RadioGroup.ItemText>
541
+ <RadioGroup.ItemHiddenInput />
542
+ </RadioGroup.Item>
543
+
544
+ // ✅ Mark required fields clearly
545
+ <RadioGroup.Root aria-required="true">
546
+ <RadioGroup.Label>
547
+ Payment method
548
+ <span className={css({ color: 'error.fg' })}> *</span>
549
+ </RadioGroup.Label>
550
+ {/* options */}
551
+ </RadioGroup.Root>
552
+
553
+ // ✅ Use appropriate orientation for context
554
+ <RadioGroup.Root
555
+ orientation="horizontal" // Good for simple yes/no
556
+ defaultValue="yes"
557
+ >
558
+ <RadioGroup.Label>Accept terms?</RadioGroup.Label>
559
+ <RadioGroup.Item value="yes">Yes</RadioGroup.Item>
560
+ <RadioGroup.Item value="no">No</RadioGroup.Item>
561
+ </RadioGroup.Root>
562
+ ```
563
+
564
+ ## Orientation Selection Guide
565
+
566
+ | Scenario | Recommended Orientation | Reasoning |
567
+ | ------------------------- | ----------------------- | ----------------------------------------- |
568
+ | 2-3 simple options | `horizontal` | Saves vertical space, easy to scan |
569
+ | 4+ options | `vertical` | Easier to read and compare options |
570
+ | Options with descriptions | `vertical` | Provides room for additional text |
571
+ | Toolbar/filter settings | `horizontal` | Fits naturally in horizontal UI |
572
+ | Form fields | `vertical` | Consistent with standard form layouts |
573
+ | Yes/No or binary choices | `horizontal` | Emphasizes the choice between two options |
574
+
575
+ ## Size Selection Guide
576
+
577
+ | Scenario | Recommended Size | Reasoning |
578
+ | -------------------- | ---------------- | ----------------------------------- |
579
+ | Mobile interfaces | `lg` | Larger touch targets (44px minimum) |
580
+ | Desktop forms | `md` | Standard, comfortable size |
581
+ | Dense layouts/tables | `sm` | Saves space while remaining usable |
582
+ | Settings panels | `md` or `lg` | Emphasizes important choices |
583
+ | Inline options | `sm` or `md` | Fits naturally in content flow |
584
+
585
+ ## State Behaviors
586
+
587
+ | State | Visual Change | Behavior |
588
+ | ---------------------- | ---------------------------------------- | ---------------------------------------- |
589
+ | **Default** | Gray border circle, white background | Clickable, focusable |
590
+ | **Hover** | Subtle background change on item | Indicates interactivity |
591
+ | **Checked** | Colored background, white inner dot | Shows selection, maintains focus ring |
592
+ | **Focus** | 2px outline ring on control | Keyboard navigation indicator |
593
+ | **Disabled** | Reduced opacity, gray appearance | Cannot be interacted with, not focusable |
594
+ | **Disabled + Checked** | Reduced opacity, maintains checked state | Shows selected but unavailable option |
595
+
596
+ ## When to Use RadioGroup vs. Other Components
597
+
598
+ | Use RadioGroup When | Use Instead |
599
+ | ---------------------------------------------------- | ------------------------------------------------------ |
600
+ | User must choose exactly one option from 2-7 choices | - |
601
+ | All options should be visible at once | Use **Select** if 8+ options or limited space |
602
+ | Selection is a primary action | - |
603
+ | Options need to be compared | - |
604
+ | User needs to choose multiple items | Use **Checkbox** for multi-select |
605
+ | Binary on/off toggle for immediate action | Use **Switch** for instant state changes |
606
+ | Navigation between views/tabs | Use **Tabs** for content switching |
607
+ | Filtering data | Use **Select** or **Checkbox** based on space/quantity |
608
+
609
+ ## Responsive Considerations
610
+
611
+ ```typescript
612
+ // Mobile-first: Use larger size and vertical orientation
613
+ <RadioGroup.Root
614
+ size="lg"
615
+ orientation="vertical"
616
+ defaultValue="option1"
617
+ >
618
+ <RadioGroup.Label>Select option</RadioGroup.Label>
619
+ {/* options */}
620
+ </RadioGroup.Root>
621
+
622
+ // Responsive orientation
623
+ <RadioGroup.Root
624
+ orientation={{ base: 'vertical', md: 'horizontal' }}
625
+ size={{ base: 'lg', md: 'md' }}
626
+ defaultValue="option1"
627
+ >
628
+ <RadioGroup.Label>Delivery preference</RadioGroup.Label>
629
+ {/* options */}
630
+ </RadioGroup.Root>
631
+
632
+ // Responsive container for groups
633
+ <div className={css({
634
+ display: 'grid',
635
+ gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
636
+ gap: '6'
637
+ })}>
638
+ <RadioGroup.Root defaultValue="option1">
639
+ <RadioGroup.Label>Category 1</RadioGroup.Label>
640
+ {/* options */}
641
+ </RadioGroup.Root>
642
+ <RadioGroup.Root defaultValue="option2">
643
+ <RadioGroup.Label>Category 2</RadioGroup.Label>
644
+ {/* options */}
645
+ </RadioGroup.Root>
646
+ </div>
647
+ ```
648
+
649
+ ## Testing
650
+
651
+ When testing RadioGroup components:
652
+
653
+ ```typescript
654
+ import { render, screen } from '@testing-library/react';
655
+ import userEvent from '@testing-library/user-event';
656
+
657
+ test('selects radio option on click', async () => {
658
+ const handleChange = vi.fn();
659
+ render(
660
+ <RadioGroup.Root onValueChange={handleChange}>
661
+ <RadioGroup.Label>Choose option</RadioGroup.Label>
662
+ <RadioGroup.Item value="option1">
663
+ <RadioGroup.ItemControl>
664
+ <RadioGroup.Indicator />
665
+ </RadioGroup.ItemControl>
666
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
667
+ <RadioGroup.ItemHiddenInput />
668
+ </RadioGroup.Item>
669
+ <RadioGroup.Item value="option2">
670
+ <RadioGroup.ItemControl>
671
+ <RadioGroup.Indicator />
672
+ </RadioGroup.ItemControl>
673
+ <RadioGroup.ItemText>Option 2</RadioGroup.ItemText>
674
+ <RadioGroup.ItemHiddenInput />
675
+ </RadioGroup.Item>
676
+ </RadioGroup.Root>
677
+ );
678
+
679
+ const option2 = screen.getByText('Option 2');
680
+ await userEvent.click(option2);
681
+
682
+ expect(handleChange).toHaveBeenCalledWith(
683
+ expect.objectContaining({ value: 'option2' })
684
+ );
685
+ });
686
+
687
+ test('keyboard navigation works correctly', async () => {
688
+ const user = userEvent.setup();
689
+ render(
690
+ <RadioGroup.Root defaultValue="option1">
691
+ <RadioGroup.Label>Choose option</RadioGroup.Label>
692
+ <RadioGroup.Item value="option1">
693
+ <RadioGroup.ItemControl>
694
+ <RadioGroup.Indicator />
695
+ </RadioGroup.ItemControl>
696
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
697
+ <RadioGroup.ItemHiddenInput />
698
+ </RadioGroup.Item>
699
+ <RadioGroup.Item value="option2">
700
+ <RadioGroup.ItemControl>
701
+ <RadioGroup.Indicator />
702
+ </RadioGroup.ItemControl>
703
+ <RadioGroup.ItemText>Option 2</RadioGroup.ItemText>
704
+ <RadioGroup.ItemHiddenInput />
705
+ </RadioGroup.Item>
706
+ </RadioGroup.Root>
707
+ );
708
+
709
+ const radioGroup = screen.getByRole('radiogroup');
710
+ await user.tab(); // Focus first radio
711
+
712
+ const option1 = screen.getByRole('radio', { name: 'Option 1' });
713
+ expect(option1).toHaveFocus();
714
+
715
+ await user.keyboard('{ArrowDown}');
716
+ const option2 = screen.getByRole('radio', { name: 'Option 2' });
717
+ expect(option2).toHaveFocus();
718
+ expect(option2).toBeChecked();
719
+ });
720
+
721
+ test('disabled radio cannot be selected', async () => {
722
+ const handleChange = vi.fn();
723
+ render(
724
+ <RadioGroup.Root onValueChange={handleChange}>
725
+ <RadioGroup.Label>Choose option</RadioGroup.Label>
726
+ <RadioGroup.Item value="option1">
727
+ <RadioGroup.ItemControl>
728
+ <RadioGroup.Indicator />
729
+ </RadioGroup.ItemControl>
730
+ <RadioGroup.ItemText>Option 1</RadioGroup.ItemText>
731
+ <RadioGroup.ItemHiddenInput />
732
+ </RadioGroup.Item>
733
+ <RadioGroup.Item value="option2" disabled>
734
+ <RadioGroup.ItemControl>
735
+ <RadioGroup.Indicator />
736
+ </RadioGroup.ItemControl>
737
+ <RadioGroup.ItemText>Option 2 (Disabled)</RadioGroup.ItemText>
738
+ <RadioGroup.ItemHiddenInput />
739
+ </RadioGroup.Item>
740
+ </RadioGroup.Root>
741
+ );
742
+
743
+ const disabledOption = screen.getByRole('radio', { name: /Option 2/ });
744
+ await userEvent.click(disabledOption);
745
+
746
+ expect(handleChange).not.toHaveBeenCalled();
747
+ expect(disabledOption).not.toBeChecked();
748
+ });
749
+ ```
750
+
751
+ ## Related Components
752
+
753
+ - **Checkbox**: For multiple selection scenarios
754
+ - **Switch**: For binary on/off toggles with immediate effect
755
+ - **Select**: For choosing from many options (8+) or when space is limited
756
+ - **Button**: For triggering actions or navigation
757
+ - **Tabs**: For switching between content views