@arbor-education/design-system.components 0.25.1 → 0.25.3

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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/components/row/Row.d.ts +5 -4
  3. package/dist/components/row/Row.d.ts.map +1 -1
  4. package/dist/components/row/Row.js +4 -2
  5. package/dist/components/row/Row.js.map +1 -1
  6. package/dist/components/row/Row.stories.d.ts +114 -4
  7. package/dist/components/row/Row.stories.d.ts.map +1 -1
  8. package/dist/components/row/Row.stories.js +451 -63
  9. package/dist/components/row/Row.stories.js.map +1 -1
  10. package/dist/components/row/Row.test.js +39 -0
  11. package/dist/components/row/Row.test.js.map +1 -1
  12. package/dist/components/toggleGroup/ToggleGroup.d.ts +29 -0
  13. package/dist/components/toggleGroup/ToggleGroup.d.ts.map +1 -0
  14. package/dist/components/toggleGroup/ToggleGroup.js +26 -0
  15. package/dist/components/toggleGroup/ToggleGroup.js.map +1 -0
  16. package/dist/components/toggleGroup/ToggleGroup.stories.d.ts +86 -0
  17. package/dist/components/toggleGroup/ToggleGroup.stories.d.ts.map +1 -0
  18. package/dist/components/toggleGroup/ToggleGroup.stories.js +342 -0
  19. package/dist/components/toggleGroup/ToggleGroup.stories.js.map +1 -0
  20. package/dist/components/toggleGroup/ToggleGroup.test.d.ts +2 -0
  21. package/dist/components/toggleGroup/ToggleGroup.test.d.ts.map +1 -0
  22. package/dist/components/toggleGroup/ToggleGroup.test.js +123 -0
  23. package/dist/components/toggleGroup/ToggleGroup.test.js.map +1 -0
  24. package/dist/components/toggleGroup/ToggleGroupItem.d.ts +10 -0
  25. package/dist/components/toggleGroup/ToggleGroupItem.d.ts.map +1 -0
  26. package/dist/components/toggleGroup/ToggleGroupItem.js +5 -0
  27. package/dist/components/toggleGroup/ToggleGroupItem.js.map +1 -0
  28. package/dist/index.css +33 -0
  29. package/dist/index.css.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/components/row/Row.stories.tsx +638 -80
  36. package/src/components/row/Row.test.tsx +48 -0
  37. package/src/components/row/Row.tsx +15 -5
  38. package/src/components/toggleGroup/ToggleGroup.stories.tsx +429 -0
  39. package/src/components/toggleGroup/ToggleGroup.test.tsx +155 -0
  40. package/src/components/toggleGroup/ToggleGroup.tsx +74 -0
  41. package/src/components/toggleGroup/ToggleGroupItem.tsx +24 -0
  42. package/src/components/toggleGroup/toggleGroup.scss +43 -0
  43. package/src/index.scss +1 -0
  44. package/src/index.ts +1 -0
@@ -43,6 +43,31 @@ describe('Row component', () => {
43
43
  expect(row).toHaveClass('ds-row', 'custom-row');
44
44
  });
45
45
 
46
+ describe('href behaviour', () => {
47
+ test('wraps value in an anchor when href is provided', () => {
48
+ render(<Row label="Profile" value="View profile" href="/students/123" />);
49
+
50
+ const link = screen.getByRole('link', { name: 'View profile' });
51
+ expect(link).toBeInTheDocument();
52
+ expect(link).toHaveAttribute('href', '/students/123');
53
+ });
54
+
55
+ test('uses href as link text when no value is provided', () => {
56
+ render(<Row label="Document" href="/documents/report.pdf" />);
57
+
58
+ const link = screen.getByRole('link', { name: '/documents/report.pdf' });
59
+ expect(link).toBeInTheDocument();
60
+ expect(link).toHaveAttribute('href', '/documents/report.pdf');
61
+ });
62
+
63
+ test('renders value as plain text when href is not provided', () => {
64
+ render(<Row label="Profile" value="View profile" />);
65
+
66
+ expect(screen.getByText('View profile')).toBeInTheDocument();
67
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
68
+ });
69
+ });
70
+
46
71
  describe('click behaviour', () => {
47
72
  test('calls onClick handler when clicked', () => {
48
73
  const handleClick = vi.fn();
@@ -79,4 +104,27 @@ describe('Row component', () => {
79
104
  expect(handleClick.mock.calls[0]?.[0]).toBeDefined();
80
105
  });
81
106
  });
107
+
108
+ describe('ReactNode content', () => {
109
+ test('accepts ReactNode label', () => {
110
+ render(<Row label={<span className="foo">bar</span>} />);
111
+ const label = screen.getByText('bar');
112
+ expect(label).toBeInTheDocument();
113
+ expect(label).toHaveClass('foo');
114
+ });
115
+
116
+ test('accepts ReactNode value', () => {
117
+ render(<Row value={<span className="foo">bar</span>} />);
118
+ const value = screen.getByText('bar');
119
+ expect(value).toBeInTheDocument();
120
+ expect(value).toHaveClass('foo');
121
+ });
122
+
123
+ test('accepts ReactNode note', () => {
124
+ render(<Row note={<span className="foo">bar</span>} />);
125
+ const note = screen.getByText('bar');
126
+ expect(note).toBeInTheDocument();
127
+ expect(note).toHaveClass('foo');
128
+ });
129
+ });
82
130
  });
@@ -1,14 +1,15 @@
1
1
  import classNames from 'classnames';
2
2
  import { Icon } from 'Components/icon/Icon';
3
- import type { CSSProperties, MouseEvent, MouseEventHandler } from 'react';
3
+ import type { CSSProperties, MouseEvent, MouseEventHandler, ReactNode } from 'react';
4
4
  import { ENTER_KEY, SPACE_KEY } from 'Utils/keyboardConstants';
5
5
 
6
6
  export type RowProps = {
7
7
  className?: string;
8
8
  style?: CSSProperties;
9
- label?: string;
10
- value?: string;
11
- note?: string;
9
+ label?: ReactNode;
10
+ value?: ReactNode;
11
+ href?: string;
12
+ note?: ReactNode;
12
13
  onClick?: MouseEventHandler<HTMLDivElement>;
13
14
  };
14
15
 
@@ -18,6 +19,7 @@ export const Row = (props: RowProps) => {
18
19
  style,
19
20
  label,
20
21
  value,
22
+ href,
21
23
  note,
22
24
  onClick,
23
25
  } = props;
@@ -44,7 +46,15 @@ export const Row = (props: RowProps) => {
44
46
  tabIndex={isClickable ? 0 : -1}
45
47
  >
46
48
  <span className="ds-row__label">{label}</span>
47
- <span className="ds-row__value">{value}</span>
49
+ <span className="ds-row__value">
50
+ {href
51
+ ? (
52
+ <a href={href}>
53
+ {value || href}
54
+ </a>
55
+ )
56
+ : value}
57
+ </span>
48
58
  <span className="ds-row__note">{note}</span>
49
59
  {isClickable && (
50
60
  <>
@@ -0,0 +1,429 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { fn } from 'storybook/test';
4
+ import {
5
+ Controls,
6
+ Heading as DocHeading,
7
+ Markdown,
8
+ Primary as DocPrimary,
9
+ Stories,
10
+ Subtitle,
11
+ Title,
12
+ } from '@storybook/addon-docs/blocks';
13
+ import { Icon } from 'Components/icon/Icon.js';
14
+ import { ToggleGroup } from './ToggleGroup.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Docs page content
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const DESCRIPTION_INTRO = [
21
+ 'ToggleGroup renders a set of mutually exclusive toggle options built on',
22
+ '[Radix UI Toggle Group](https://www.radix-ui.com/primitives/docs/components/toggle-group).',
23
+ 'Each option can carry a semantic `color` (`"red"`, `"yellow"`, or `"green"`) that applies',
24
+ 'a background and text colour from the design token palette when the item is active.',
25
+ 'It behaves as a radio group — only one option can be selected at a time.',
26
+ 'Typical use cases include attendance status pickers (Present / Late / Absent) and any',
27
+ 'single-select choice set where each option has a semantic colour meaning.',
28
+ ].join(' ');
29
+
30
+ const USAGE_GUIDANCE = [
31
+ '### When to use',
32
+ '',
33
+ '- **Categorised single-select** — when each option has a distinct semantic colour (attendance, priority, status)',
34
+ '- **Compact radio replacement** — when a traditional radio group would take too much vertical space',
35
+ '- **Immediate selection** — when choosing an option takes effect right away without a form submission',
36
+ '',
37
+ '---',
38
+ '',
39
+ '### When NOT to use',
40
+ '',
41
+ '| Situation | Use instead |',
42
+ '|---|---|',
43
+ '| Multiple options can be selected | `CheckboxGroup` |',
44
+ '| Options are part of a larger form submission | `RadioButtonGroup` — communicates deferred action |',
45
+ '| More than ~6 options | `SelectDropdown` — toggle groups become unwieldy at scale |',
46
+ ].join('\n');
47
+
48
+ const DEVELOPER_NOTES = [
49
+ '### Controlled vs uncontrolled',
50
+ '',
51
+ 'Use `defaultValue` for uncontrolled mode — the component manages its own selection state.',
52
+ 'Use `value` + `onValueChange` for controlled mode when you need to read or sync the value externally.',
53
+ '',
54
+ '**`onValueChange` receives the new value string directly**, not a React event.',
55
+ '',
56
+ '---',
57
+ '',
58
+ '### Option colour',
59
+ '',
60
+ 'The `color` prop accepts `"red"`, `"yellow"`, or `"green"` and is optional.',
61
+ 'When set, the colour is applied as background and text colour using semantic design tokens on the active item.',
62
+ 'When omitted, the primary button style is used for the selected state.',
63
+ 'Options within the same group can mix coloured and uncoloured items.',
64
+ '',
65
+ '---',
66
+ '',
67
+ '### Accessibility',
68
+ '',
69
+ '- Rendered as `role="group"` containing `role="radio"` items',
70
+ '- Keyboard navigation: `Tab` focuses the group, arrow keys move between items, `Space`/`Enter` select',
71
+ '- Each item uses its `text` value as the accessible label',
72
+ '- The `disabled` prop on the root or individual options correctly sets the `disabled` attribute',
73
+ ].join('\n');
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Docs page
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function ToggleGroupDocsPage() {
80
+ return (
81
+ <>
82
+ <Title />
83
+ <Subtitle />
84
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
85
+ <DocHeading>Interactive example</DocHeading>
86
+ <DocPrimary />
87
+ <Controls />
88
+ <DocHeading>Usage guidance</DocHeading>
89
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
90
+ <DocHeading>Developer notes</DocHeading>
91
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
92
+ <DocHeading>Examples</DocHeading>
93
+ <Stories title="" />
94
+ </>
95
+ );
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Meta
100
+ // ---------------------------------------------------------------------------
101
+
102
+ const attendanceOptions = [
103
+ { value: 'present', text: 'Present' },
104
+ { value: 'late', text: 'Late' },
105
+ { value: 'absent', text: 'Absent' },
106
+ ];
107
+
108
+ const meta = {
109
+ title: 'Components/ToggleGroup',
110
+ component: ToggleGroup,
111
+ parameters: {
112
+ layout: 'centered',
113
+ docs: {
114
+ page: ToggleGroupDocsPage,
115
+ },
116
+ },
117
+ tags: ['autodocs'],
118
+ args: {
119
+ options: attendanceOptions,
120
+ onValueChange: fn(),
121
+ },
122
+ argTypes: {
123
+ value: {
124
+ control: 'text',
125
+ description: 'The controlled selected value. Pair with `onValueChange`.',
126
+ table: { type: { summary: 'string' } },
127
+ },
128
+ defaultValue: {
129
+ control: 'text',
130
+ description: 'The initial selected value for uncontrolled usage.',
131
+ table: { type: { summary: 'string' } },
132
+ },
133
+ disabled: {
134
+ control: 'boolean',
135
+ description: 'Disables all options in the group.',
136
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
137
+ },
138
+ onValueChange: {
139
+ action: 'valueChange',
140
+ control: false,
141
+ description: 'Callback fired when the selection changes. Receives the new value string.',
142
+ table: { type: { summary: '(value: string) => void' } },
143
+ },
144
+ options: {
145
+ control: false,
146
+ description:
147
+ 'Array of option configs. Each accepts `value`, `text`, `color` (`"red" | "yellow" | "green"`), `icon`, and `disabled`.',
148
+ table: { type: { summary: 'ToggleGroupOption[]' } },
149
+ },
150
+ },
151
+ } satisfies Meta<typeof ToggleGroup>;
152
+
153
+ export default meta;
154
+ type Story = StoryObj<typeof meta>;
155
+
156
+ const withDescription = (story: Story, description: string): Story => ({
157
+ ...story,
158
+ parameters: {
159
+ ...story.parameters,
160
+ docs: { ...story.parameters?.docs, description: { story: description } },
161
+ },
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Stories
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export const Default: Story = withDescription(
169
+ {
170
+ args: {
171
+ options: attendanceOptions,
172
+ value: undefined,
173
+ onValueChange: fn(),
174
+ },
175
+ },
176
+ 'No option selected. Click an option to see the selected state.',
177
+ );
178
+
179
+ export const WithDefaultValue: Story = withDescription(
180
+ {
181
+ args: {
182
+ options: attendanceOptions,
183
+ defaultValue: 'present',
184
+ onValueChange: undefined,
185
+ },
186
+ parameters: {
187
+ controls: { disable: true },
188
+ docs: {
189
+ source: {
190
+ language: 'tsx',
191
+ code: `
192
+ import { ToggleGroup } from '@arbor-education/design-system.components';
193
+
194
+ const options = [
195
+ { value: 'present', text: 'Present' },
196
+ { value: 'late', text: 'Late' },
197
+ { value: 'absent', text: 'Absent' },
198
+ ];
199
+
200
+ <ToggleGroup options={options} defaultValue="present" />
201
+ `.trim(),
202
+ },
203
+ },
204
+ },
205
+ },
206
+ 'Uncontrolled mode — `defaultValue` sets the initial selection and the component manages state internally.',
207
+ );
208
+
209
+ export const Controlled: Story = withDescription(
210
+ {
211
+ render: (args) => {
212
+ const [value, setValue] = useState('present');
213
+ return (
214
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-small)' }}>
215
+ <ToggleGroup
216
+ options={args.options}
217
+ disabled={args.disabled}
218
+ className={args.className}
219
+ value={value}
220
+ onValueChange={(v) => {
221
+ setValue(v);
222
+ args.onValueChange?.(v);
223
+ }}
224
+ />
225
+ <p style={{ margin: 0, fontSize: 'var(--font-size-2-13)', color: 'var(--color-grey-600)' }}>
226
+ Selected:
227
+ {' '}
228
+ <strong>{value || '—'}</strong>
229
+ </p>
230
+ </div>
231
+ );
232
+ },
233
+ parameters: {
234
+ controls: { disable: true },
235
+ docs: {
236
+ source: {
237
+ language: 'tsx',
238
+ code: `
239
+ import { useState } from 'react';
240
+ import { ToggleGroup } from '@arbor-education/design-system.components';
241
+
242
+ const options = [
243
+ { value: 'present', text: 'Present' },
244
+ { value: 'late', text: 'Late' },
245
+ { value: 'absent', text: 'Absent' },
246
+ ];
247
+
248
+ function AttendancePicker() {
249
+ const [status, setStatus] = useState('present');
250
+ return <ToggleGroup options={options} value={status} onValueChange={setStatus} />;
251
+ }
252
+ `.trim(),
253
+ },
254
+ },
255
+ },
256
+ },
257
+ 'Controlled mode — `value` and `onValueChange` manage selection externally. The label below reflects the current value.',
258
+ );
259
+
260
+ export const WithColours: Story = withDescription(
261
+ {
262
+ args: {
263
+ options: [
264
+ { value: 'present', text: 'Present', color: 'green' as const },
265
+ { value: 'late', text: 'Late', color: 'yellow' as const },
266
+ { value: 'absent', text: 'Absent', color: 'red' as const },
267
+ ],
268
+ defaultValue: 'present',
269
+ onValueChange: undefined,
270
+ },
271
+ parameters: {
272
+ controls: { disable: true },
273
+ docs: {
274
+ source: {
275
+ language: 'tsx',
276
+ code: `
277
+ import { ToggleGroup } from '@arbor-education/design-system.components';
278
+
279
+ const options = [
280
+ { value: 'present', text: 'Present', color: 'green' },
281
+ { value: 'late', text: 'Late', color: 'yellow' },
282
+ { value: 'absent', text: 'Absent', color: 'red' },
283
+ ];
284
+
285
+ <ToggleGroup options={options} defaultValue="present" />
286
+ `.trim(),
287
+ },
288
+ },
289
+ },
290
+ },
291
+ 'Each option accepts an optional `color` prop (`"green"`, `"yellow"`, or `"red"`). The colour is applied using semantic design tokens when the item is selected.',
292
+ );
293
+
294
+ export const WithMixedColours: Story = withDescription(
295
+ {
296
+ args: {
297
+ options: [
298
+ { value: 'present', text: 'Present', color: 'green' as const },
299
+ { value: 'late', text: 'Late', color: 'yellow' as const },
300
+ { value: 'absent', text: 'Absent', color: 'red' as const },
301
+ { value: 'not-required', text: 'Not required' },
302
+ ],
303
+ defaultValue: 'not-required',
304
+ onValueChange: undefined,
305
+ },
306
+ parameters: {
307
+ controls: { disable: true },
308
+ docs: {
309
+ source: {
310
+ language: 'tsx',
311
+ code: `
312
+ import { ToggleGroup } from '@arbor-education/design-system.components';
313
+
314
+ const options = [
315
+ { value: 'present', text: 'Present', color: 'green' },
316
+ { value: 'late', text: 'Late', color: 'yellow' },
317
+ { value: 'absent', text: 'Absent', color: 'red' },
318
+ { value: 'not-required', text: 'Not required' },
319
+ ];
320
+
321
+ <ToggleGroup options={options} defaultValue="not-required" />
322
+ `.trim(),
323
+ },
324
+ },
325
+ },
326
+ },
327
+ 'Options within the same group can mix coloured and uncoloured items. Uncoloured items use the primary button style when selected.',
328
+ );
329
+
330
+ export const WithColoursAndIcons: Story = withDescription(
331
+ {
332
+ args: {
333
+ options: [
334
+ { value: 'present', text: 'Present', color: 'green' as const, icon: <Icon name="check" size={16} /> },
335
+ { value: 'late', text: 'Late', color: 'yellow' as const, icon: <Icon name="clock-3" size={16} /> },
336
+ { value: 'absent', text: 'Absent', color: 'red' as const, icon: <Icon name="x" size={16} /> },
337
+ ],
338
+ defaultValue: 'present',
339
+ onValueChange: undefined,
340
+ },
341
+ parameters: {
342
+ controls: { disable: true },
343
+ docs: {
344
+ source: {
345
+ language: 'tsx',
346
+ code: `
347
+ import { Icon, ToggleGroup } from '@arbor-education/design-system.components';
348
+
349
+ const options = [
350
+ { value: 'present', text: 'Present', color: 'green', icon: <Icon name="check" size={16} /> },
351
+ { value: 'late', text: 'Late', color: 'yellow', icon: <Icon name="clock-3" size={16} /> },
352
+ { value: 'absent', text: 'Absent', color: 'red', icon: <Icon name="x" size={16} /> },
353
+ ];
354
+
355
+ <ToggleGroup options={options} defaultValue="present" />
356
+ `.trim(),
357
+ },
358
+ },
359
+ },
360
+ },
361
+ 'Options can combine a semantic colour with an icon.',
362
+ );
363
+
364
+ export const WithIcons: Story = withDescription(
365
+ {
366
+ args: {
367
+ options: [
368
+ { value: 'present', text: 'Present', icon: <Icon name="check" size={16} /> },
369
+ { value: 'late', text: 'Late', icon: <Icon name="clock-3" size={16} /> },
370
+ { value: 'absent', text: 'Absent', icon: <Icon name="x" size={16} /> },
371
+ ],
372
+ defaultValue: 'present',
373
+ onValueChange: undefined,
374
+ },
375
+ parameters: {
376
+ controls: { disable: true },
377
+ docs: {
378
+ source: {
379
+ language: 'tsx',
380
+ code: `
381
+ import { Icon, ToggleGroup } from '@arbor-education/design-system.components';
382
+
383
+ const options = [
384
+ { value: 'present', text: 'Present', icon: <Icon name="check" size={16} /> },
385
+ { value: 'late', text: 'Late', icon: <Icon name="clock-3" size={16} /> },
386
+ { value: 'absent', text: 'Absent', icon: <Icon name="x" size={16} /> },
387
+ ];
388
+
389
+ <ToggleGroup options={options} defaultValue="present" />
390
+ `.trim(),
391
+ },
392
+ },
393
+ },
394
+ },
395
+ 'Each option can include an `icon`. The icon renders to the left of the label text.',
396
+ );
397
+
398
+ export const Disabled: Story = withDescription(
399
+ {
400
+ args: {
401
+ options: attendanceOptions,
402
+ defaultValue: 'late',
403
+ disabled: true,
404
+ onValueChange: undefined,
405
+ },
406
+ parameters: {
407
+ controls: { disable: true },
408
+ },
409
+ },
410
+ 'The entire group can be disabled with the `disabled` prop. Use when the field exists but cannot currently be changed.',
411
+ );
412
+
413
+ export const PartiallyDisabled: Story = withDescription(
414
+ {
415
+ args: {
416
+ options: [
417
+ { value: 'present', text: 'Present' },
418
+ { value: 'late', text: 'Late', disabled: true },
419
+ { value: 'absent', text: 'Absent' },
420
+ ],
421
+ defaultValue: 'present',
422
+ onValueChange: undefined,
423
+ },
424
+ parameters: {
425
+ controls: { disable: true },
426
+ },
427
+ },
428
+ 'Individual options can be disabled by setting `disabled: true` on the option config.',
429
+ );
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom/vitest';
5
+ import { ToggleGroup } from './ToggleGroup.js';
6
+
7
+ const options = [
8
+ { value: 'present', text: 'Present', color: 'green' as const },
9
+ { value: 'late', text: 'Late', color: 'yellow' as const },
10
+ { value: 'absent', text: 'Absent', color: 'red' as const },
11
+ ];
12
+
13
+ describe('ToggleGroup component', () => {
14
+ test('renders all options', () => {
15
+ render(<ToggleGroup options={options} />);
16
+ expect(screen.getByRole('group')).toBeInTheDocument();
17
+ expect(screen.getAllByRole('radio')).toHaveLength(3);
18
+ });
19
+
20
+ test('renders option text', () => {
21
+ render(<ToggleGroup options={options} />);
22
+ expect(screen.getByText('Present')).toBeInTheDocument();
23
+ expect(screen.getByText('Late')).toBeInTheDocument();
24
+ expect(screen.getByText('Absent')).toBeInTheDocument();
25
+ });
26
+
27
+ test('applies ds-toggle-group base class', () => {
28
+ render(<ToggleGroup options={options} />);
29
+ expect(screen.getByRole('group')).toHaveClass('ds-toggle-group');
30
+ });
31
+
32
+ test('applies additional className alongside base class', () => {
33
+ render(<ToggleGroup options={options} className="custom-class" />);
34
+ const group = screen.getByRole('group');
35
+ expect(group).toHaveClass('ds-toggle-group');
36
+ expect(group).toHaveClass('custom-class');
37
+ });
38
+
39
+ test('no option is selected by default', () => {
40
+ render(<ToggleGroup options={options} />);
41
+ screen.getAllByRole('radio').forEach((radio) => {
42
+ expect(radio).toHaveAttribute('data-state', 'off');
43
+ });
44
+ });
45
+
46
+ test('selects the option matching defaultValue in uncontrolled mode', () => {
47
+ render(<ToggleGroup options={options} defaultValue="late" />);
48
+ expect(screen.getByRole('radio', { name: 'Late' })).toHaveAttribute('data-state', 'on');
49
+ expect(screen.getByRole('radio', { name: 'Present' })).toHaveAttribute('data-state', 'off');
50
+ });
51
+
52
+ test('selects the option matching value in controlled mode', () => {
53
+ render(<ToggleGroup options={options} value="absent" onValueChange={vi.fn()} />);
54
+ expect(screen.getByRole('radio', { name: 'Absent' })).toHaveAttribute('data-state', 'on');
55
+ });
56
+
57
+ test('calls onValueChange with the clicked option value', () => {
58
+ const handleChange = vi.fn();
59
+ render(<ToggleGroup options={options} value="" onValueChange={handleChange} />);
60
+ fireEvent.click(screen.getByRole('radio', { name: 'Present' }));
61
+ expect(handleChange).toHaveBeenCalledTimes(1);
62
+ expect(handleChange).toHaveBeenCalledWith('present');
63
+ });
64
+
65
+ test('applies color class for green option when selected', () => {
66
+ render(<ToggleGroup options={options} value="present" onValueChange={vi.fn()} />);
67
+ expect(screen.getByRole('radio', { name: 'Present' })).toHaveClass(
68
+ 'ds-toggle-group__item--green',
69
+ );
70
+ });
71
+
72
+ test('applies color class for yellow option when selected', () => {
73
+ render(<ToggleGroup options={options} value="late" onValueChange={vi.fn()} />);
74
+ expect(screen.getByRole('radio', { name: 'Late' })).toHaveClass(
75
+ 'ds-toggle-group__item--yellow',
76
+ );
77
+ });
78
+
79
+ test('applies color class for red option when selected', () => {
80
+ render(<ToggleGroup options={options} value="absent" onValueChange={vi.fn()} />);
81
+ expect(screen.getByRole('radio', { name: 'Absent' })).toHaveClass(
82
+ 'ds-toggle-group__item--red',
83
+ );
84
+ });
85
+
86
+ test('does not apply a color class when the option is not selected', () => {
87
+ render(<ToggleGroup options={options} value="absent" onValueChange={vi.fn()} />);
88
+ const present = screen.getByRole('radio', { name: 'Present' });
89
+ expect(present).not.toHaveClass('ds-toggle-group__item--green');
90
+ });
91
+
92
+ test('does not apply a color class when no color is provided', () => {
93
+ const noColorOptions = [{ value: 'yes', text: 'Yes' }, { value: 'no', text: 'No' }];
94
+ render(<ToggleGroup options={noColorOptions} value="yes" onValueChange={vi.fn()} />);
95
+ const yes = screen.getByRole('radio', { name: 'Yes' });
96
+ expect(yes).not.toHaveClass('ds-toggle-group__item--green');
97
+ expect(yes).not.toHaveClass('ds-toggle-group__item--yellow');
98
+ expect(yes).not.toHaveClass('ds-toggle-group__item--red');
99
+ });
100
+
101
+ test('disables the whole group when disabled prop is set', () => {
102
+ render(<ToggleGroup options={options} disabled />);
103
+ screen.getAllByRole('radio').forEach((radio) => {
104
+ expect(radio).toBeDisabled();
105
+ });
106
+ });
107
+
108
+ test('disables a single option when its disabled prop is set', () => {
109
+ const mixedOptions = [
110
+ { value: 'a', text: 'A', color: 'green' as const },
111
+ { value: 'b', text: 'B', color: 'red' as const, disabled: true },
112
+ ];
113
+ render(<ToggleGroup options={mixedOptions} />);
114
+ expect(screen.getByRole('radio', { name: 'B' })).toBeDisabled();
115
+ expect(screen.getByRole('radio', { name: 'A' })).not.toBeDisabled();
116
+ });
117
+
118
+ test('does not deselect an active option when clicked again', () => {
119
+ const handleChange = vi.fn();
120
+ render(<ToggleGroup options={options} value="present" onValueChange={handleChange} />);
121
+ fireEvent.click(screen.getByRole('radio', { name: 'Present' }));
122
+ expect(handleChange).not.toHaveBeenCalled();
123
+ expect(screen.getByRole('radio', { name: 'Present' })).toHaveAttribute('data-state', 'on');
124
+ });
125
+
126
+ test('renders an icon when provided', () => {
127
+ const withIcon = [{ value: 'x', text: 'X', icon: <svg data-testid="test-icon" /> }];
128
+ render(<ToggleGroup options={withIcon} />);
129
+ expect(screen.getByTestId('test-icon')).toBeInTheDocument();
130
+ });
131
+
132
+ describe('keyboard navigation', () => {
133
+ test('all items are individually reachable via Tab', async () => {
134
+ const user = userEvent.setup();
135
+ render(
136
+ <div>
137
+ <button>Before</button>
138
+ <ToggleGroup options={options} value="late" onValueChange={vi.fn()} />
139
+ </div>,
140
+ );
141
+
142
+ await user.tab();
143
+ expect(document.activeElement).toHaveTextContent('Before');
144
+
145
+ await user.tab();
146
+ expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Present' }));
147
+
148
+ await user.tab();
149
+ expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Late' }));
150
+
151
+ await user.tab();
152
+ expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Absent' }));
153
+ });
154
+ });
155
+ });