@arbor-education/design-system.components 0.25.1 → 0.25.2
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/CHANGELOG.md +8 -0
- package/dist/components/row/Row.d.ts +4 -4
- package/dist/components/row/Row.d.ts.map +1 -1
- package/dist/components/row/Row.stories.d.ts +3 -0
- package/dist/components/row/Row.stories.d.ts.map +1 -1
- package/dist/components/row/Row.stories.js +96 -6
- package/dist/components/row/Row.stories.js.map +1 -1
- package/dist/components/row/Row.test.js +20 -0
- package/dist/components/row/Row.test.js.map +1 -1
- package/dist/components/toggleGroup/ToggleGroup.d.ts +29 -0
- package/dist/components/toggleGroup/ToggleGroup.d.ts.map +1 -0
- package/dist/components/toggleGroup/ToggleGroup.js +26 -0
- package/dist/components/toggleGroup/ToggleGroup.js.map +1 -0
- package/dist/components/toggleGroup/ToggleGroup.stories.d.ts +86 -0
- package/dist/components/toggleGroup/ToggleGroup.stories.d.ts.map +1 -0
- package/dist/components/toggleGroup/ToggleGroup.stories.js +342 -0
- package/dist/components/toggleGroup/ToggleGroup.stories.js.map +1 -0
- package/dist/components/toggleGroup/ToggleGroup.test.d.ts +2 -0
- package/dist/components/toggleGroup/ToggleGroup.test.d.ts.map +1 -0
- package/dist/components/toggleGroup/ToggleGroup.test.js +123 -0
- package/dist/components/toggleGroup/ToggleGroup.test.js.map +1 -0
- package/dist/components/toggleGroup/ToggleGroupItem.d.ts +10 -0
- package/dist/components/toggleGroup/ToggleGroupItem.d.ts.map +1 -0
- package/dist/components/toggleGroup/ToggleGroupItem.js +5 -0
- package/dist/components/toggleGroup/ToggleGroupItem.js.map +1 -0
- package/dist/index.css +33 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/row/Row.stories.tsx +141 -6
- package/src/components/row/Row.test.tsx +23 -0
- package/src/components/row/Row.tsx +4 -4
- package/src/components/toggleGroup/ToggleGroup.stories.tsx +429 -0
- package/src/components/toggleGroup/ToggleGroup.test.tsx +155 -0
- package/src/components/toggleGroup/ToggleGroup.tsx +74 -0
- package/src/components/toggleGroup/ToggleGroupItem.tsx +24 -0
- package/src/components/toggleGroup/toggleGroup.scss +43 -0
- package/src/index.scss +1 -0
- package/src/index.ts +1 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState, type KeyboardEventHandler } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import { ToggleGroup as RadixToggleGroup, type ToggleGroupSingleProps } from '@radix-ui/react-toggle-group';
|
|
4
|
+
import { ToggleGroupItem, type ToggleGroupItemProps, type ToggleGroupItemColor } from './ToggleGroupItem.js';
|
|
5
|
+
|
|
6
|
+
type ControlledProps = {
|
|
7
|
+
value: string;
|
|
8
|
+
onValueChange: (value: string) => void;
|
|
9
|
+
defaultValue?: never;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type UncontrolledProps = {
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
value?: never;
|
|
15
|
+
onValueChange?: never;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type BaseProps = Omit<ToggleGroupSingleProps, 'type' | 'value' | 'onValueChange' | 'defaultValue'> & {
|
|
19
|
+
options: ToggleGroupItemProps[];
|
|
20
|
+
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ToggleGroupProps = BaseProps & (ControlledProps | UncontrolledProps);
|
|
24
|
+
|
|
25
|
+
const isControlled = (props: ToggleGroupProps): props is ControlledProps & ToggleGroupProps =>
|
|
26
|
+
props.value !== undefined;
|
|
27
|
+
|
|
28
|
+
export const ToggleGroup = (props: ToggleGroupProps) => {
|
|
29
|
+
const { options, className, value, defaultValue, onValueChange, onKeyDown, ...radixToggleGroupProps } = props;
|
|
30
|
+
const [internalValue, setInternalValue] = useState<string | undefined>(
|
|
31
|
+
isControlled(props) ? undefined : defaultValue,
|
|
32
|
+
);
|
|
33
|
+
const selectedValue = isControlled(props) ? value : internalValue;
|
|
34
|
+
|
|
35
|
+
const handleValueChange = (newValue: string) => {
|
|
36
|
+
if (!newValue) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (isControlled(props)) {
|
|
40
|
+
onValueChange?.(newValue);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
setInternalValue(newValue);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<RadixToggleGroup
|
|
49
|
+
{...radixToggleGroupProps}
|
|
50
|
+
type="single"
|
|
51
|
+
rovingFocus={false}
|
|
52
|
+
className={classNames('ds-toggle-group', className)}
|
|
53
|
+
value={selectedValue}
|
|
54
|
+
onValueChange={handleValueChange}
|
|
55
|
+
onKeyDown={onKeyDown}
|
|
56
|
+
>
|
|
57
|
+
{options.map(({ ...itemProps }) => (
|
|
58
|
+
<ToggleGroupItem
|
|
59
|
+
{...itemProps}
|
|
60
|
+
key={itemProps.value}
|
|
61
|
+
isActive={itemProps.value === selectedValue}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</RadixToggleGroup>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
ToggleGroup.Item = ToggleGroupItem;
|
|
69
|
+
|
|
70
|
+
export namespace ToggleGroup {
|
|
71
|
+
export type Props = ToggleGroupProps;
|
|
72
|
+
export type ItemProps = ToggleGroupItemProps;
|
|
73
|
+
export type ItemColor = ToggleGroupItemColor;
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Item, type ToggleGroupItemProps as RadixToggleGroupItemProps } from '@radix-ui/react-toggle-group';
|
|
2
|
+
import { Button } from 'Components/button/Button';
|
|
3
|
+
|
|
4
|
+
export type ToggleGroupItemColor = 'red' | 'yellow' | 'green';
|
|
5
|
+
|
|
6
|
+
export type ToggleGroupItemProps = {
|
|
7
|
+
text: string;
|
|
8
|
+
color?: ToggleGroupItemColor;
|
|
9
|
+
icon?: React.ReactNode;
|
|
10
|
+
isActive?: boolean;
|
|
11
|
+
} & RadixToggleGroupItemProps;
|
|
12
|
+
|
|
13
|
+
export const ToggleGroupItem = ({ text, color, icon, isActive = false, ...radixProps }: ToggleGroupItemProps) => (
|
|
14
|
+
<Item {...radixProps} asChild>
|
|
15
|
+
<Button
|
|
16
|
+
className={isActive && color ? `ds-toggle-group__item--${color}` : undefined}
|
|
17
|
+
borderless
|
|
18
|
+
variant={isActive ? 'primary' : 'secondary'}
|
|
19
|
+
>
|
|
20
|
+
{icon}
|
|
21
|
+
{text}
|
|
22
|
+
</Button>
|
|
23
|
+
</Item>
|
|
24
|
+
);
|