@boostdev/design-system-components 0.1.18 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +65 -27
  2. package/README.md +64 -29
  3. package/dist/client.cjs +419 -242
  4. package/dist/client.css +1378 -1186
  5. package/dist/client.d.cts +43 -16
  6. package/dist/client.d.ts +43 -16
  7. package/dist/client.js +421 -246
  8. package/dist/index.cjs +419 -242
  9. package/dist/index.css +1378 -1186
  10. package/dist/index.d.cts +43 -16
  11. package/dist/index.d.ts +43 -16
  12. package/dist/index.js +421 -246
  13. package/package.json +9 -4
  14. package/src/components/interaction/Button/Button.mdx +8 -11
  15. package/src/components/interaction/Button/Button.module.css +74 -44
  16. package/src/components/interaction/Button/Button.stories.tsx +4 -4
  17. package/src/components/interaction/Button/Button.tsx +5 -5
  18. package/src/components/interaction/Button/index.ts +1 -0
  19. package/src/components/interaction/Command/Command.module.css +53 -38
  20. package/src/components/interaction/Command/Command.spec.tsx +24 -0
  21. package/src/components/interaction/Command/Command.tsx +5 -0
  22. package/src/components/interaction/Dialog/Dialog.mdx +2 -2
  23. package/src/components/interaction/Dialog/Dialog.module.css +20 -10
  24. package/src/components/interaction/Dialog/Dialog.spec.tsx +19 -0
  25. package/src/components/interaction/Dialog/Dialog.tsx +22 -0
  26. package/src/components/interaction/Drawer/Drawer.module.css +11 -11
  27. package/src/components/interaction/Drawer/Drawer.spec.tsx +30 -0
  28. package/src/components/interaction/Drawer/Drawer.tsx +7 -3
  29. package/src/components/interaction/DropdownMenu/DropdownMenu.module.css +24 -19
  30. package/src/components/interaction/DropdownMenu/DropdownMenu.stories.tsx +4 -4
  31. package/src/components/interaction/Popover/Popover.module.css +17 -12
  32. package/src/components/interaction/Popover/Popover.spec.tsx +25 -4
  33. package/src/components/interaction/Popover/Popover.stories.tsx +1 -1
  34. package/src/components/interaction/Popover/Popover.tsx +5 -2
  35. package/src/components/interaction/Rating/Rating.module.css +3 -3
  36. package/src/components/interaction/Toast/Toast.module.css +19 -19
  37. package/src/components/interaction/Toast/Toast.tsx +1 -1
  38. package/src/components/interaction/form/Checkbox/Checkbox.mdx +2 -2
  39. package/src/components/interaction/form/Checkbox/Checkbox.module.css +22 -18
  40. package/src/components/interaction/form/Checkbox/Checkbox.tsx +2 -2
  41. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.module.css +29 -0
  42. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.spec.tsx +87 -0
  43. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.stories.tsx +62 -0
  44. package/src/components/interaction/form/CheckboxGroup/CheckboxGroup.tsx +46 -0
  45. package/src/components/interaction/form/CheckboxGroup/index.ts +2 -0
  46. package/src/components/interaction/form/Combobox/Combobox.module.css +44 -34
  47. package/src/components/interaction/form/Combobox/Combobox.tsx +0 -1
  48. package/src/components/interaction/form/FileInput/FileInput.module.css +24 -16
  49. package/src/components/interaction/form/FileInput/FileInput.tsx +5 -2
  50. package/src/components/interaction/form/FormInput/FormInput.module.css +21 -16
  51. package/src/components/interaction/form/FormInput/FormInput.tsx +3 -0
  52. package/src/components/interaction/form/NumberInput/NumberInput.module.css +25 -19
  53. package/src/components/interaction/form/NumberInput/NumberInput.tsx +12 -3
  54. package/src/components/interaction/form/Radio/Radio.mdx +3 -3
  55. package/src/components/interaction/form/Radio/Radio.module.css +28 -24
  56. package/src/components/interaction/form/Radio/Radio.stories.tsx +1 -1
  57. package/src/components/interaction/form/Radio/Radio.tsx +2 -2
  58. package/src/components/interaction/form/RadioGroup/RadioGroup.module.css +29 -0
  59. package/src/components/interaction/form/RadioGroup/RadioGroup.spec.tsx +75 -0
  60. package/src/components/interaction/form/RadioGroup/RadioGroup.stories.tsx +64 -0
  61. package/src/components/interaction/form/RadioGroup/RadioGroup.tsx +46 -0
  62. package/src/components/interaction/form/RadioGroup/index.ts +2 -0
  63. package/src/components/interaction/form/SegmentedControl/SegmentedControl.mdx +5 -5
  64. package/src/components/interaction/form/SegmentedControl/SegmentedControl.module.css +26 -26
  65. package/src/components/interaction/form/SegmentedControl/SegmentedControl.tsx +4 -0
  66. package/src/components/interaction/form/Select/Select.module.css +24 -19
  67. package/src/components/interaction/form/Select/Select.tsx +3 -0
  68. package/src/components/interaction/form/Slider/Slider.mdx +1 -1
  69. package/src/components/interaction/form/Slider/Slider.module.css +24 -24
  70. package/src/components/interaction/form/Slider/Slider.tsx +1 -0
  71. package/src/components/interaction/form/Switch/Switch.mdx +4 -4
  72. package/src/components/interaction/form/Switch/Switch.module.css +22 -16
  73. package/src/components/interaction/form/Switch/Switch.tsx +1 -1
  74. package/src/components/interaction/form/Textarea/Textarea.module.css +23 -18
  75. package/src/components/interaction/form/Textarea/Textarea.tsx +3 -0
  76. package/src/components/interaction/form/atoms/InputContainer.module.css +2 -2
  77. package/src/components/interaction/form/atoms/Label.module.css +2 -2
  78. package/src/components/interaction/form/atoms/Message.module.css +2 -2
  79. package/src/components/layout/ButtonGroup/ButtonGroup.module.css +2 -2
  80. package/src/components/layout/ButtonGroup/ButtonGroup.stories.tsx +2 -2
  81. package/src/components/layout/ButtonGroup/ButtonGroup.tsx +8 -3
  82. package/src/components/layout/ButtonGroup/index.ts +1 -0
  83. package/src/components/layout/Card/Card.module.css +30 -16
  84. package/src/components/layout/Card/Card.tsx +1 -1
  85. package/src/components/layout/IconWrapper/IconWrapper.mdx +2 -2
  86. package/src/components/layout/IconWrapper/IconWrapper.module.css +5 -5
  87. package/src/components/layout/IconWrapper/IconWrapper.tsx +4 -3
  88. package/src/components/layout/IconWrapper/index.ts +1 -0
  89. package/src/components/layout/SectionHeader/SectionHeader.module.css +23 -23
  90. package/src/components/layout/SectionHeader/SectionHeader.spec.tsx +2 -2
  91. package/src/components/layout/SectionHeader/SectionHeader.tsx +2 -2
  92. package/src/components/ui/Accordion/Accordion.module.css +23 -18
  93. package/src/components/ui/Alert/Alert.module.css +31 -27
  94. package/src/components/ui/Avatar/Avatar.module.css +6 -6
  95. package/src/components/ui/Avatar/Avatar.tsx +1 -1
  96. package/src/components/ui/Badge/Badge.mdx +2 -2
  97. package/src/components/ui/Badge/Badge.module.css +15 -15
  98. package/src/components/ui/Breadcrumb/Breadcrumb.module.css +11 -11
  99. package/src/components/ui/Calendar/Calendar.module.css +39 -29
  100. package/src/components/ui/Calendar/Calendar.tsx +6 -2
  101. package/src/components/ui/Carousel/Carousel.module.css +15 -10
  102. package/src/components/ui/Carousel/Carousel.stories.tsx +1 -1
  103. package/src/components/ui/Collapsible/Collapsible.mdx +6 -6
  104. package/src/components/ui/Collapsible/Collapsible.module.css +18 -18
  105. package/src/components/ui/DescriptionList/DescriptionList.module.css +8 -8
  106. package/src/components/ui/Link/Link.module.css +14 -14
  107. package/src/components/ui/Loading/Loading.module.css +8 -2
  108. package/src/components/ui/NotificationBanner/NotificationBanner.module.css +27 -23
  109. package/src/components/ui/Pagination/Pagination.module.css +22 -17
  110. package/src/components/ui/Progress/Progress.mdx +1 -1
  111. package/src/components/ui/Progress/Progress.module.css +10 -10
  112. package/src/components/ui/ProgressCircle/ProgressCircle.module.css +8 -8
  113. package/src/components/ui/Separator/Separator.module.css +1 -1
  114. package/src/components/ui/Separator/Separator.tsx +1 -1
  115. package/src/components/ui/Skeleton/Skeleton.module.css +5 -5
  116. package/src/components/ui/SkipLink/SkipLink.module.css +17 -12
  117. package/src/components/ui/Table/Table.module.css +35 -30
  118. package/src/components/ui/Table/Table.tsx +2 -1
  119. package/src/components/ui/Tabs/Tabs.module.css +17 -17
  120. package/src/components/ui/Tabs/Tabs.tsx +0 -1
  121. package/src/components/ui/Tooltip/Tooltip.module.css +12 -12
  122. package/src/components/ui/Tooltip/Tooltip.spec.tsx +3 -3
  123. package/src/components/ui/Tooltip/Tooltip.tsx +10 -2
  124. package/src/components/ui/Typography/Typography.module.css +18 -18
  125. package/src/css/bdc.css +66 -0
  126. package/src/css/index.css +5 -0
  127. package/src/index.ts +7 -0
  128. package/src/stories/DesignSystem/Borders.mdx +158 -0
  129. package/src/stories/DesignSystem/Colors.mdx +196 -0
  130. package/src/stories/DesignSystem/Elevation.mdx +127 -0
  131. package/src/stories/DesignSystem/Grid.mdx +140 -0
  132. package/src/stories/DesignSystem/Motion.mdx +96 -0
  133. package/src/stories/DesignSystem/Overview.mdx +99 -0
  134. package/src/stories/DesignSystem/Spacing.mdx +74 -0
  135. package/src/stories/DesignSystem/Typography.mdx +110 -0
  136. package/src/stories/Introduction.css +2 -2
  137. package/src/stories/Introduction.mdx +7 -7
@@ -7,27 +7,37 @@
7
7
  .inputRow {
8
8
  display: flex;
9
9
  align-items: stretch;
10
- border: 1px solid currentcolor;
11
- border-radius: var(--border_radius--xs);
10
+ border: var(--numberInput_border, none);
11
+ --bdc_color: currentcolor;
12
+ --bdc-outline_radius: var(--bds-border_radius--xs);
13
+
14
+ outline: var(--bdc-outline_width) solid var(--bdc_color);
15
+ outline-offset: var(--bdc-outline_offset);
16
+ border-radius: var(--numberInput_radius, var(--bdc-outline_radius));
17
+ box-shadow: var(--numberInput_shadow, var(--bdc-outline_shadow));
12
18
  overflow: hidden;
13
- transition: var(--animation_transition);
19
+ transition: --bdc_color var(--bds-animation_transition-duration) var(--bds-animation_easing);
14
20
  }
15
21
 
16
22
  .inputRow:focus-within {
17
- border-color: var(--numberInput_border-color, var(--color_interactive));
18
- box-shadow: var(--numberInput_focus-ring, 0 0 0 2px rgb(from var(--color_interactive) r g b / 20%));
23
+ --bdc_color: var(--bdc_color--focus);
24
+ }
25
+
26
+ .inputRow:focus-within:has(:focus-visible) {
27
+ outline: var(--bds-outline_default);
28
+ outline-offset: var(--bds-outline_offset);
19
29
  }
20
30
 
21
31
  .input {
22
32
  flex: 1;
23
33
  border: none;
24
34
  outline: none;
25
- font-family: var(--font_family--body);
26
- font-size: var(--font_size--body);
35
+ font-family: var(--bds-font_family--body);
36
+ font-size: var(--bds-font_size--body);
27
37
  text-align: center;
28
- padding: var(--space_s);
38
+ padding: var(--bds-space_s);
29
39
  background: transparent;
30
- color: var(--numberInput_color, var(--color_on-bg));
40
+ color: var(--numberInput_color, var(--bds-on-bg));
31
41
  min-width: 0;
32
42
  appearance: textfield;
33
43
  }
@@ -38,11 +48,7 @@
38
48
  }
39
49
 
40
50
  .inputError {
41
- --numberInput_border-color: var(--color_error);
42
- }
43
-
44
- .inputError:focus-within {
45
- --numberInput_focus-ring: 0 0 0 2px rgb(from var(--color_error) r g b / 20%);
51
+ --bdc_color: var(--bdc_color--error);
46
52
  }
47
53
 
48
54
  .stepper {
@@ -50,11 +56,11 @@
50
56
  display: flex;
51
57
  align-items: center;
52
58
  justify-content: center;
53
- padding: var(--space_xs);
54
- background-color: var(--color_bg--subtle);
55
- color: var(--numberInput_color, var(--color_on-bg));
59
+ padding: var(--bds-space_xs);
60
+ background-color: var(--bds-bg--subtle);
61
+ color: var(--numberInput_color, var(--bds-on-bg));
56
62
  cursor: pointer;
57
- transition: var(--animation_transition);
63
+ transition: var(--bds-animation_transition);
58
64
  flex-shrink: 0;
59
65
  }
60
66
 
@@ -70,7 +76,7 @@
70
76
 
71
77
  @media (hover: hover) and (pointer: fine) {
72
78
  .stepper:not(:disabled):hover {
73
- background-color: var(--color_bg--subtle);
79
+ background-color: var(--bds-bg--subtle);
74
80
  opacity: 0.7;
75
81
  }
76
82
  }
@@ -51,7 +51,13 @@ export function NumberInput({
51
51
 
52
52
  const adjust = (delta: number) => {
53
53
  const next = clamp(currentValue + delta);
54
- if (!isControlled) setInternalValue(next);
54
+ if (!isControlled) {
55
+ setInternalValue(next);
56
+ if (inputRef.current) {
57
+ inputRef.current.value = String(next);
58
+ inputRef.current.dispatchEvent(new Event('input', { bubbles: true }));
59
+ }
60
+ }
55
61
  onChange?.(next);
56
62
  };
57
63
 
@@ -63,6 +69,7 @@ export function NumberInput({
63
69
  type="button"
64
70
  className={css.stepper}
65
71
  aria-label="Decrease"
72
+ aria-controls={uid}
66
73
  disabled={disabled || (min !== undefined && currentValue <= min)}
67
74
  onClick={() => adjust(-step)}
68
75
  tabIndex={-1}
@@ -86,14 +93,16 @@ export function NumberInput({
86
93
  className={cn(css.input, error ? css.inputError : undefined)}
87
94
  onChange={e => {
88
95
  const v = parseFloat(e.target.value);
89
- if (!isControlled) setInternalValue(isNaN(v) ? 0 : v);
90
- onChange?.(v);
96
+ const safe = isNaN(v) ? 0 : v;
97
+ if (!isControlled) setInternalValue(safe);
98
+ onChange?.(safe);
91
99
  }}
92
100
  />
93
101
  <button
94
102
  type="button"
95
103
  className={css.stepper}
96
104
  aria-label="Increase"
105
+ aria-controls={uid}
97
106
  disabled={disabled || (max !== undefined && currentValue >= max)}
98
107
  onClick={() => adjust(step)}
99
108
  tabIndex={-1}
@@ -46,9 +46,9 @@ Single-select option within a group. Renders a styled native `<input type="radio
46
46
  <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
47
47
  </thead>
48
48
  <tbody>
49
- <tr><td>`--radio_color-active`</td><td>`var(--color_active)`</td><td>Selected fill and border colour</td></tr>
50
- <tr><td>`--radio_color-on-active`</td><td>`var(--color_on-active)`</td><td>Inner dot colour</td></tr>
51
- <tr><td>`--radio_description-color`</td><td>`var(--color_on-bg--subtle)`</td><td>Description text colour</td></tr>
49
+ <tr><td>`--radio_color-active`</td><td>`var(--bds-active)`</td><td>Selected fill and border colour</td></tr>
50
+ <tr><td>`--radio_color-on-active`</td><td>`var(--bds-on-active)`</td><td>Inner dot colour</td></tr>
51
+ <tr><td>`--radio_description-color`</td><td>`var(--bds-on-bg--subtle)`</td><td>Description text colour</td></tr>
52
52
  </tbody>
53
53
  </table>
54
54
 
@@ -1,7 +1,7 @@
1
1
  @layer component {
2
2
  .radioGroup {
3
- --inputSize: calc(var(--space_s) * 2);
4
- --labelPosition: calc(var(--inputSize) + var(--space_m));
3
+ --inputSize: calc(var(--bds-space_s) * 2);
4
+ --labelPosition: calc(var(--inputSize) + var(--bds-space_m));
5
5
 
6
6
  display: flex;
7
7
  flex-direction: column;
@@ -10,20 +10,20 @@
10
10
  .inputWrapper {
11
11
  display: flex;
12
12
  align-items: flex-start;
13
- gap: var(--space_xs);
13
+ gap: var(--bds-space_xs);
14
14
  }
15
15
 
16
16
  .textWrapper {
17
17
  display: flex;
18
18
  flex-direction: column;
19
- gap: var(--space_xxxs);
20
- padding-block-start: var(--space_xxxs);
19
+ gap: var(--bds-space_xxxs);
20
+ padding-block-start: var(--bds-space_xxxs);
21
21
  }
22
22
 
23
23
  .description {
24
- font-size: var(--font_size--body--s);
25
- color: var(--radio_description-color, var(--color_on-bg--subtle));
26
- line-height: var(--font_line-height--body);
24
+ font-size: var(--bds-font_size--body--s);
25
+ color: var(--radio_description-color, var(--bds-on-bg--subtle));
26
+ line-height: var(--bds-font_line-height--body);
27
27
  }
28
28
 
29
29
  .radio {
@@ -31,19 +31,25 @@
31
31
  margin-block-start: 0.2em;
32
32
  width: var(--inputSize);
33
33
  height: var(--inputSize);
34
- color: var(--radio_color, var(--color_on-bg));
35
- border: 1.5px solid currentcolor;
36
- border-radius: 50%;
34
+ color: var(--radio_color, var(--bds-on-bg));
35
+ border: var(--radio_border, none);
36
+ --bdc_color: currentcolor;
37
+ --bdc-outline_radius: 50%;
38
+
39
+ outline: var(--bdc-outline_width) solid var(--bdc_color);
40
+ outline-offset: var(--bdc-outline_offset);
41
+ border-radius: var(--radio_radius, var(--bdc-outline_radius));
42
+ box-shadow: var(--radio_shadow, var(--bdc-outline_shadow));
37
43
  appearance: none;
38
- background-color: var(--radio_color_bg, var(--color_bg));
44
+ background-color: var(--radio_color_bg, var(--bds-bg));
39
45
  cursor: pointer;
40
46
  position: relative;
41
- transition: background-color 0.2s ease, border-color 0.2s ease;
47
+ transition: --bdc_color 0.2s ease, background-color 0.2s ease;
42
48
  }
43
49
 
44
50
  .radio:checked {
45
- background-color: var(--radio_color-active, var(--color_active));
46
- border-color: var(--radio_color-active, var(--color_active));
51
+ background-color: var(--radio_color-active, var(--bds-active));
52
+ --bdc_color: var(--radio_color-active, var(--bds-active));
47
53
  }
48
54
 
49
55
  .radio:checked::after {
@@ -55,21 +61,19 @@
55
61
  width: 0.4em;
56
62
  height: 0.4em;
57
63
  border-radius: 50%;
58
- background-color: var(--radio_color-on-active, var(--color_on-active));
64
+ background-color: var(--radio_color-on-active, var(--bds-on-active));
59
65
  }
60
66
 
61
67
  .radio:focus {
62
- outline: none;
63
- border-color: var(--color_interactive);
64
- box-shadow: 0 0 0 2px rgb(from var(--color_interactive) r g b / 20%);
68
+ --bdc_color: var(--bdc_color--focus);
65
69
  }
66
70
 
67
- .radioError {
68
- border-color: var(--color_error);
71
+ .radio:focus-visible {
72
+ outline: var(--bds-outline_default);
73
+ outline-offset: var(--bds-outline_offset);
69
74
  }
70
75
 
71
- .radioError:focus {
72
- border-color: var(--color_error);
73
- box-shadow: 0 0 0 2px rgb(from var(--color_error) r g b / 20%);
76
+ .radioError {
77
+ --bdc_color: var(--bdc_color--error);
74
78
  }
75
79
  }
@@ -17,7 +17,7 @@ export const WithHint: Story = { args: { label: 'Option', name: 'choice4', value
17
17
  export const Group: Story = {
18
18
  render: () => (
19
19
  <fieldset style={{ border: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
20
- <legend style={{ fontWeight: 'var(--font_weight--semibold)', marginBottom: '0.5rem' }}>Pick one</legend>
20
+ <legend style={{ fontWeight: 'var(--bds-font_weight--semibold)', marginBottom: '0.5rem' }}>Pick one</legend>
21
21
  <Radio label="Option A" name="group" value="a" description="Short description for option A." />
22
22
  <Radio label="Option B" name="group" value="b" description="Short description for option B." />
23
23
  <Radio label="Option C" name="group" value="c" />
@@ -1,4 +1,4 @@
1
- import { InputHTMLAttributes, useId } from 'react';
1
+ import { InputHTMLAttributes, ReactNode, useId } from 'react';
2
2
  import css from './Radio.module.css';
3
3
  import { Message } from '../atoms/Message';
4
4
  import { Label } from '../atoms/Label';
@@ -6,7 +6,7 @@ import { InputContainer } from '../atoms/InputContainer';
6
6
  import { cn } from '@boostdev/design-system-foundation';
7
7
 
8
8
  interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
9
- label: string;
9
+ label: ReactNode;
10
10
  name: string;
11
11
  description?: string;
12
12
  error?: string;
@@ -0,0 +1,29 @@
1
+ @layer component {
2
+ .group {
3
+ border: none;
4
+ padding: 0;
5
+ margin: 0;
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--bds-space_xs);
9
+ }
10
+
11
+ .legend {
12
+ font-family: var(--bds-font_family--body);
13
+ font-size: var(--bds-font_size--body--s);
14
+ font-weight: var(--bds-font_weight--medium);
15
+ color: var(--bds-on-bg);
16
+ padding: 0;
17
+ margin-block-end: var(--bds-space_xs);
18
+ }
19
+
20
+ .required {
21
+ color: var(--bds-error);
22
+ }
23
+
24
+ .items {
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: var(--bds-space_xs);
28
+ }
29
+ }
@@ -0,0 +1,75 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { Radio } from '../Radio';
3
+ import { RadioGroup } from './RadioGroup';
4
+
5
+ describe('RadioGroup', () => {
6
+ it('renders a fieldset with a legend', () => {
7
+ render(
8
+ <RadioGroup legend="Preferred contact">
9
+ <Radio label="Email" name="contact" value="email" />
10
+ </RadioGroup>
11
+ );
12
+ expect(screen.getByRole('group', { name: 'Preferred contact' })).toBeInTheDocument();
13
+ expect(screen.getByText('Preferred contact')).toBeInTheDocument();
14
+ });
15
+
16
+ it('renders child radio inputs', () => {
17
+ render(
18
+ <RadioGroup legend="Size">
19
+ <Radio label="Small" name="size" value="small" />
20
+ <Radio label="Large" name="size" value="large" />
21
+ </RadioGroup>
22
+ );
23
+ expect(screen.getByRole('radio', { name: 'Small' })).toBeInTheDocument();
24
+ expect(screen.getByRole('radio', { name: 'Large' })).toBeInTheDocument();
25
+ });
26
+
27
+ it('shows an error message', () => {
28
+ render(
29
+ <RadioGroup legend="Choice" error="Please select an option">
30
+ <Radio label="Option" name="opt" value="opt" />
31
+ </RadioGroup>
32
+ );
33
+ expect(screen.getByText('Please select an option')).toBeInTheDocument();
34
+ });
35
+
36
+ it('shows a hint message', () => {
37
+ render(
38
+ <RadioGroup legend="Plan" hint="You can change this later">
39
+ <Radio label="Free" name="plan" value="free" />
40
+ </RadioGroup>
41
+ );
42
+ expect(screen.getByText('You can change this later')).toBeInTheDocument();
43
+ });
44
+
45
+ it('links error message via aria-describedby', () => {
46
+ render(
47
+ <RadioGroup legend="Required" error="Required field">
48
+ <Radio label="Option" name="opt" value="opt" />
49
+ </RadioGroup>
50
+ );
51
+ const fieldset = screen.getByRole('group');
52
+ const errorMsg = screen.getByText('Required field');
53
+ expect(fieldset).toHaveAttribute('aria-describedby');
54
+ expect(fieldset.getAttribute('aria-describedby')).toContain(errorMsg.id);
55
+ });
56
+
57
+ it('marks fieldset as required when required prop is set', () => {
58
+ render(
59
+ <RadioGroup legend="Required" required>
60
+ <Radio label="Option" name="opt" value="opt" />
61
+ </RadioGroup>
62
+ );
63
+ expect(screen.getByRole('group')).toHaveAttribute('aria-required', 'true');
64
+ expect(screen.getByText('*')).toBeInTheDocument();
65
+ });
66
+
67
+ it('disables the fieldset when disabled prop is set', () => {
68
+ render(
69
+ <RadioGroup legend="Disabled" disabled>
70
+ <Radio label="Option" name="opt" value="opt" />
71
+ </RadioGroup>
72
+ );
73
+ expect(screen.getByRole('group')).toBeDisabled();
74
+ });
75
+ });
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Radio } from '../Radio';
3
+ import { RadioGroup } from './RadioGroup';
4
+
5
+ const meta = {
6
+ title: 'Form/RadioGroup',
7
+ component: RadioGroup,
8
+ } satisfies Meta<typeof RadioGroup>;
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const Default: Story = {
14
+ args: { legend: 'Preferred contact' },
15
+ render: args => (
16
+ <RadioGroup {...args}>
17
+ <Radio label="Email" name="contact" value="email" />
18
+ <Radio label="Phone" name="contact" value="phone" />
19
+ <Radio label="Post" name="contact" value="post" />
20
+ </RadioGroup>
21
+ ),
22
+ };
23
+
24
+ export const WithError: Story = {
25
+ args: { legend: 'Plan', error: 'Please select a plan' },
26
+ render: args => (
27
+ <RadioGroup {...args}>
28
+ <Radio label="Free" name="plan" value="free" />
29
+ <Radio label="Pro" name="plan" value="pro" />
30
+ <Radio label="Enterprise" name="plan" value="enterprise" />
31
+ </RadioGroup>
32
+ ),
33
+ };
34
+
35
+ export const WithHint: Story = {
36
+ args: { legend: 'Billing cycle', hint: 'You can change this at any time' },
37
+ render: args => (
38
+ <RadioGroup {...args}>
39
+ <Radio label="Monthly" name="billing" value="monthly" />
40
+ <Radio label="Annually" name="billing" value="annually" description="Save 20%" />
41
+ </RadioGroup>
42
+ ),
43
+ };
44
+
45
+ export const Required: Story = {
46
+ args: { legend: 'Size', required: true },
47
+ render: args => (
48
+ <RadioGroup {...args}>
49
+ <Radio label="Small" name="size" value="small" />
50
+ <Radio label="Medium" name="size" value="medium" defaultChecked />
51
+ <Radio label="Large" name="size" value="large" />
52
+ </RadioGroup>
53
+ ),
54
+ };
55
+
56
+ export const Disabled: Story = {
57
+ args: { legend: 'Region', disabled: true },
58
+ render: args => (
59
+ <RadioGroup {...args}>
60
+ <Radio label="Europe" name="region" value="eu" defaultChecked />
61
+ <Radio label="North America" name="region" value="na" />
62
+ </RadioGroup>
63
+ ),
64
+ };
@@ -0,0 +1,46 @@
1
+ import { ReactNode, useId } from 'react';
2
+ import css from './RadioGroup.module.css';
3
+ import { Message } from '../atoms/Message';
4
+ import { cn } from '@boostdev/design-system-foundation';
5
+
6
+ export interface RadioGroupProps {
7
+ legend: string;
8
+ children: ReactNode;
9
+ error?: string;
10
+ hint?: string;
11
+ required?: boolean;
12
+ disabled?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export function RadioGroup({
17
+ legend,
18
+ children,
19
+ error,
20
+ hint,
21
+ required,
22
+ disabled,
23
+ className,
24
+ }: Readonly<RadioGroupProps>) {
25
+ const id = useId();
26
+ const hintId = id + 'hint';
27
+ const errorId = id + 'error';
28
+ const describedBy = [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined;
29
+
30
+ return (
31
+ <fieldset
32
+ className={cn(css.group, className)}
33
+ disabled={disabled}
34
+ aria-required={required || undefined}
35
+ aria-describedby={describedBy}
36
+ >
37
+ <legend className={css.legend}>
38
+ {legend}
39
+ {required && <span className={css.required} aria-hidden="true"> *</span>}
40
+ </legend>
41
+ <div className={css.items}>{children}</div>
42
+ <Message inputId={id} type="error" message={error} />
43
+ <Message inputId={id} type="hint" message={hint} />
44
+ </fieldset>
45
+ );
46
+ }
@@ -0,0 +1,2 @@
1
+ export { RadioGroup } from './RadioGroup';
2
+ export type { RadioGroupProps } from './RadioGroup';
@@ -48,11 +48,11 @@ All options have **equal width** — the widest label determines the size for al
48
48
  <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
49
49
  </thead>
50
50
  <tbody>
51
- <tr><td>`--control_track-bg`</td><td>`var(--color_bg--subtle)`</td><td>Track background</td></tr>
52
- <tr><td>`--control_thumb-bg`</td><td>`var(--color_bg)`</td><td>Thumb (active indicator) background</td></tr>
53
- <tr><td>`--control_label-color`</td><td>`var(--color_on-bg--subtle)`</td><td>Unselected label colour</td></tr>
54
- <tr><td>`--control_label-color--active`</td><td>`var(--color_on-bg)`</td><td>Selected label colour</td></tr>
55
- <tr><td>`--control_label-color--hover`</td><td>`var(--color_on-bg)`</td><td>Hovered (unselected) label colour</td></tr>
51
+ <tr><td>`--control_track-bg`</td><td>`var(--bds-bg--subtle)`</td><td>Track background</td></tr>
52
+ <tr><td>`--control_thumb-bg`</td><td>`var(--bds-bg)`</td><td>Thumb (active indicator) background</td></tr>
53
+ <tr><td>`--control_label-color`</td><td>`var(--bds-on-bg--subtle)`</td><td>Unselected label colour</td></tr>
54
+ <tr><td>`--control_label-color--active`</td><td>`var(--bds-on-bg)`</td><td>Selected label colour</td></tr>
55
+ <tr><td>`--control_label-color--hover`</td><td>`var(--bds-on-bg)`</td><td>Hovered (unselected) label colour</td></tr>
56
56
  </tbody>
57
57
  </table>
58
58
 
@@ -4,24 +4,24 @@
4
4
  display: inline-grid;
5
5
  grid-auto-flow: column;
6
6
  grid-auto-columns: 1fr;
7
- padding: var(--space_xxxs);
8
- background-color: var(--control_track-bg, var(--color_bg--subtle));
9
- border-radius: var(--border_radius--m);
7
+ padding: var(--bds-space_xxxs);
8
+ background-color: var(--control_track-bg, var(--bds-bg--subtle));
9
+ border-radius: var(--bds-border_radius--m);
10
10
  gap: 0;
11
11
  }
12
12
 
13
13
  .thumb {
14
14
  position: absolute;
15
- top: var(--space_xxxs);
16
- bottom: var(--space_xxxs);
17
- left: var(--space_xxxs);
18
- width: calc((100% - 2 * var(--space_xxxs)) / var(--control_count, 1));
19
- border-radius: var(--border_radius--s);
20
- background-color: var(--control_thumb-bg, var(--color_bg));
21
- box-shadow: var(--shadow_s);
15
+ top: var(--bds-space_xxxs);
16
+ bottom: var(--bds-space_xxxs);
17
+ left: var(--bds-space_xxxs);
18
+ width: calc((100% - 2 * var(--bds-space_xxxs)) / var(--control_count, 1));
19
+ border-radius: var(--bds-border_radius--s);
20
+ background-color: var(--control_thumb-bg, var(--bds-bg));
21
+ box-shadow: var(--bds-shadow_s);
22
22
  pointer-events: none;
23
23
  transform: translateX(calc(var(--control_selected-index, 0) * 100%));
24
- transition: transform var(--animation_transition-duration) var(--animation_easing);
24
+ transition: transform var(--bds-animation_transition-duration) var(--bds-animation_easing);
25
25
  }
26
26
 
27
27
  .item {
@@ -31,8 +31,8 @@
31
31
  align-items: center;
32
32
  justify-content: center;
33
33
  cursor: pointer;
34
- border-radius: var(--border_radius--s);
35
- transition: var(--animation_transition);
34
+ border-radius: var(--bds-border_radius--s);
35
+ transition: var(--bds-animation_transition);
36
36
  }
37
37
 
38
38
  .item.--disabled {
@@ -50,40 +50,40 @@
50
50
 
51
51
  .label {
52
52
  display: block;
53
- padding: var(--space_xs) var(--space_m);
53
+ padding: var(--bds-space_xs) var(--bds-space_m);
54
54
  white-space: nowrap;
55
- font-size: var(--font_size--body);
56
- line-height: var(--font_line-height--body);
57
- color: var(--control_label-color, var(--color_on-bg--subtle));
58
- transition: color var(--animation_transition-duration) var(--animation_easing);
55
+ font-size: var(--bds-font_size--body);
56
+ line-height: var(--bds-font_line-height--body);
57
+ color: var(--control_label-color, var(--bds-on-bg--subtle));
58
+ transition: color var(--bds-animation_transition-duration) var(--bds-animation_easing);
59
59
  pointer-events: none;
60
60
  user-select: none;
61
61
  }
62
62
 
63
63
  .control.--size_small .label {
64
- padding: var(--space_xxs) var(--space_s);
65
- font-size: var(--font_size--body--s);
64
+ padding: var(--bds-space_xxs) var(--bds-space_s);
65
+ font-size: var(--bds-font_size--body--s);
66
66
  }
67
67
 
68
68
  .control.--size_large .label {
69
- padding: var(--space_s) var(--space_m);
69
+ padding: var(--bds-space_s) var(--bds-space_m);
70
70
  white-space: normal;
71
71
  text-align: center;
72
72
  }
73
73
 
74
74
  .item.--active .label {
75
- color: var(--control_label-color--active, var(--color_on-bg));
75
+ color: var(--control_label-color--active, var(--bds-on-bg));
76
76
  }
77
77
 
78
78
  .radio:focus-visible ~ .label {
79
- outline: var(--outline_default);
80
- outline-offset: calc(var(--outline_offset) * -1);
81
- border-radius: var(--border_radius--s);
79
+ outline: var(--bds-outline_default);
80
+ outline-offset: calc(var(--bds-outline_offset) * -1);
81
+ border-radius: var(--bds-border_radius--s);
82
82
  }
83
83
 
84
84
  @media (hover: hover) and (pointer: fine) {
85
85
  .item:not(.--active, .--disabled):hover .label {
86
- color: var(--control_label-color--hover, var(--color_on-bg));
86
+ color: var(--control_label-color--hover, var(--bds-on-bg));
87
87
  }
88
88
  }
89
89
 
@@ -20,6 +20,7 @@ export interface SegmentedControlProps
20
20
  disabled?: boolean;
21
21
  size?: 'small' | 'medium' | 'large';
22
22
  className?: string;
23
+ 'aria-label'?: string;
23
24
  }
24
25
 
25
26
  export function SegmentedControl({
@@ -31,6 +32,7 @@ export function SegmentedControl({
31
32
  disabled,
32
33
  size = 'medium',
33
34
  className,
35
+ 'aria-label': ariaLabel,
34
36
  ...rest
35
37
  }: Readonly<SegmentedControlProps>) {
36
38
  const baseId = name + useId();
@@ -49,6 +51,8 @@ export function SegmentedControl({
49
51
 
50
52
  return (
51
53
  <div
54
+ role="group"
55
+ aria-label={ariaLabel}
52
56
  className={cn(css.control, css[`--size_${size}`], className)}
53
57
  style={{
54
58
  '--control_count': options.length,