uikit-react-public 0.49.4 → 0.49.5

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,468 @@
1
+ import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import ToggleGroup from '../ToggleGroup';
5
+ import ToggleGroupItem from '../subcomponents/Item';
6
+ import { ThemeContextProvider } from '../../../theme/useTheme';
7
+
8
+ const mockMatchMedia = (matchesDesktop: boolean) => {
9
+ globalThis.matchMedia = vi.fn().mockImplementation((query) => ({
10
+ matches: query.includes('min-width') ? matchesDesktop : false,
11
+ media: query,
12
+ addListener: vi.fn(),
13
+ removeListener: vi.fn(),
14
+ addEventListener: vi.fn(),
15
+ removeEventListener: vi.fn(),
16
+ dispatchEvent: vi.fn(),
17
+ }));
18
+ };
19
+
20
+ const wrap = (ui: React.ReactElement) =>
21
+ render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
22
+
23
+ describe('ToggleGroup', () => {
24
+ beforeEach(() => {
25
+ mockMatchMedia(true);
26
+ });
27
+
28
+ test('renders a radiogroup with selectable radio items', () => {
29
+ const onValueChange = vi.fn();
30
+
31
+ wrap(
32
+ <ToggleGroup
33
+ defaultValue='option-1'
34
+ onValueChange={onValueChange}
35
+ >
36
+ <ToggleGroupItem
37
+ value='option-1'
38
+ label='Option 1'
39
+ />
40
+ <ToggleGroupItem
41
+ value='option-2'
42
+ label='Option 2'
43
+ />
44
+ </ToggleGroup>
45
+ );
46
+
47
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
48
+ expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
49
+ 'aria-checked',
50
+ 'true'
51
+ );
52
+
53
+ fireEvent.click(screen.getByRole('radio', { name: 'Option 2' }));
54
+
55
+ expect(onValueChange).toHaveBeenCalledWith('option-2');
56
+ expect(screen.getByRole('radio', { name: 'Option 2' })).toHaveAttribute(
57
+ 'aria-checked',
58
+ 'true'
59
+ );
60
+ expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
61
+ 'aria-checked',
62
+ 'false'
63
+ );
64
+ });
65
+
66
+ test('only ever has one selected item at a time', () => {
67
+ wrap(
68
+ <ToggleGroup defaultValue='option-1'>
69
+ <ToggleGroupItem
70
+ value='option-1'
71
+ label='Option 1'
72
+ />
73
+ <ToggleGroupItem
74
+ value='option-2'
75
+ label='Option 2'
76
+ />
77
+ <ToggleGroupItem
78
+ value='option-3'
79
+ label='Option 3'
80
+ />
81
+ </ToggleGroup>
82
+ );
83
+
84
+ fireEvent.click(screen.getByRole('radio', { name: 'Option 3' }));
85
+
86
+ const checked = screen
87
+ .getAllByRole('radio')
88
+ .filter((item) => item.getAttribute('aria-checked') === 'true');
89
+
90
+ expect(checked).toHaveLength(1);
91
+ expect(checked[0]).toHaveAccessibleName('Option 3');
92
+ });
93
+
94
+ test('behaves as a controlled component when value is provided', () => {
95
+ const onValueChange = vi.fn();
96
+
97
+ const { rerender } = wrap(
98
+ <ToggleGroup
99
+ value='option-1'
100
+ onValueChange={onValueChange}
101
+ >
102
+ <ToggleGroupItem
103
+ value='option-1'
104
+ label='Option 1'
105
+ />
106
+ <ToggleGroupItem
107
+ value='option-2'
108
+ label='Option 2'
109
+ />
110
+ </ToggleGroup>
111
+ );
112
+
113
+ fireEvent.click(screen.getByRole('radio', { name: 'Option 2' }));
114
+
115
+ expect(onValueChange).toHaveBeenCalledWith('option-2');
116
+ // Selection does not change without the caller updating the value prop
117
+ expect(screen.getByRole('radio', { name: 'Option 1' })).toHaveAttribute(
118
+ 'aria-checked',
119
+ 'true'
120
+ );
121
+
122
+ rerender(
123
+ <ThemeContextProvider>
124
+ <ToggleGroup
125
+ value='option-2'
126
+ onValueChange={onValueChange}
127
+ >
128
+ <ToggleGroupItem
129
+ value='option-1'
130
+ label='Option 1'
131
+ />
132
+ <ToggleGroupItem
133
+ value='option-2'
134
+ label='Option 2'
135
+ />
136
+ </ToggleGroup>
137
+ </ThemeContextProvider>
138
+ );
139
+
140
+ expect(screen.getByRole('radio', { name: 'Option 2' })).toHaveAttribute(
141
+ 'aria-checked',
142
+ 'true'
143
+ );
144
+ });
145
+
146
+ test('moves focus and selection with arrow keys', () => {
147
+ wrap(
148
+ <ToggleGroup defaultValue='option-1'>
149
+ <ToggleGroupItem
150
+ value='option-1'
151
+ label='Option 1'
152
+ />
153
+ <ToggleGroupItem
154
+ value='option-2'
155
+ label='Option 2'
156
+ />
157
+ <ToggleGroupItem
158
+ value='option-3'
159
+ label='Option 3'
160
+ />
161
+ </ToggleGroup>
162
+ );
163
+
164
+ const option1 = screen.getByRole('radio', { name: 'Option 1' });
165
+ const option2 = screen.getByRole('radio', { name: 'Option 2' });
166
+ const option3 = screen.getByRole('radio', { name: 'Option 3' });
167
+
168
+ option1.focus();
169
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' });
170
+
171
+ expect(option2).toHaveFocus();
172
+ expect(option2).toHaveAttribute('aria-checked', 'true');
173
+
174
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'End' });
175
+
176
+ expect(option3).toHaveFocus();
177
+ expect(option3).toHaveAttribute('aria-checked', 'true');
178
+
179
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Home' });
180
+
181
+ expect(option1).toHaveFocus();
182
+ expect(option1).toHaveAttribute('aria-checked', 'true');
183
+
184
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowLeft' });
185
+
186
+ expect(option3).toHaveFocus();
187
+ expect(option3).toHaveAttribute('aria-checked', 'true');
188
+ });
189
+
190
+ test('skips disabled items when navigating with arrow keys', () => {
191
+ wrap(
192
+ <ToggleGroup defaultValue='option-1'>
193
+ <ToggleGroupItem
194
+ value='option-1'
195
+ label='Option 1'
196
+ />
197
+ <ToggleGroupItem
198
+ value='option-2'
199
+ label='Option 2'
200
+ disabled
201
+ />
202
+ <ToggleGroupItem
203
+ value='option-3'
204
+ label='Option 3'
205
+ />
206
+ </ToggleGroup>
207
+ );
208
+
209
+ const option1 = screen.getByRole('radio', { name: 'Option 1' });
210
+ const option3 = screen.getByRole('radio', { name: 'Option 3' });
211
+
212
+ option1.focus();
213
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'ArrowRight' });
214
+
215
+ expect(option3).toHaveFocus();
216
+ });
217
+
218
+ test('ignores non-navigation keys', () => {
219
+ wrap(
220
+ <ToggleGroup defaultValue='option-1'>
221
+ <ToggleGroupItem
222
+ value='option-1'
223
+ label='Option 1'
224
+ />
225
+ <ToggleGroupItem
226
+ value='option-2'
227
+ label='Option 2'
228
+ />
229
+ </ToggleGroup>
230
+ );
231
+
232
+ const option1 = screen.getByRole('radio', { name: 'Option 1' });
233
+
234
+ option1.focus();
235
+ fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'a' });
236
+
237
+ expect(option1).toHaveFocus();
238
+ expect(option1).toHaveAttribute('aria-checked', 'true');
239
+ });
240
+ });
241
+
242
+ describe('ToggleGroup mobile select fallback', () => {
243
+ beforeAll(() => {
244
+ // CustomOption scrolls the selected option into view; jsdom has no layout engine.
245
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
246
+ value: vi.fn(),
247
+ writable: true,
248
+ });
249
+ });
250
+
251
+ beforeEach(() => {
252
+ mockMatchMedia(false);
253
+ });
254
+
255
+ test('renders the radiogroup on mobile when item count is at or below the threshold', () => {
256
+ wrap(
257
+ <ToggleGroup defaultValue='option-1'>
258
+ <ToggleGroupItem
259
+ value='option-1'
260
+ label='Option 1'
261
+ />
262
+ <ToggleGroupItem
263
+ value='option-2'
264
+ label='Option 2'
265
+ />
266
+ </ToggleGroup>
267
+ );
268
+
269
+ expect(screen.getByRole('radiogroup')).toBeInTheDocument();
270
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
271
+ });
272
+
273
+ test('renders a Select instead of the radiogroup once item count exceeds the threshold', () => {
274
+ wrap(
275
+ <ToggleGroup defaultValue='option-1'>
276
+ <ToggleGroupItem
277
+ value='option-1'
278
+ label='Option 1'
279
+ />
280
+ <ToggleGroupItem
281
+ value='option-2'
282
+ label='Option 2'
283
+ />
284
+ <ToggleGroupItem
285
+ value='option-3'
286
+ label='Option 3'
287
+ />
288
+ </ToggleGroup>
289
+ );
290
+
291
+ expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
292
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
293
+ });
294
+
295
+ test('counts items nested in fragments toward the mobile select threshold', async () => {
296
+ const user = userEvent.setup();
297
+
298
+ wrap(
299
+ <ToggleGroup defaultValue='option-1'>
300
+ <>
301
+ <ToggleGroupItem
302
+ value='option-1'
303
+ label='Option 1'
304
+ />
305
+ <ToggleGroupItem
306
+ value='option-2'
307
+ label='Option 2'
308
+ />
309
+ </>
310
+ <ToggleGroupItem
311
+ value='option-3'
312
+ label='Option 3'
313
+ />
314
+ </ToggleGroup>
315
+ );
316
+
317
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
318
+
319
+ await user.click(screen.getByRole('combobox'));
320
+
321
+ expect(
322
+ screen.getByRole('option', { name: 'Option 1' })
323
+ ).toBeInTheDocument();
324
+ expect(
325
+ screen.getByRole('option', { name: 'Option 2' })
326
+ ).toBeInTheDocument();
327
+ expect(
328
+ screen.getByRole('option', { name: 'Option 3' })
329
+ ).toBeInTheDocument();
330
+ });
331
+
332
+ test('updates the selected value when an option is chosen from the mobile select', async () => {
333
+ const user = userEvent.setup();
334
+ const onValueChange = vi.fn();
335
+
336
+ wrap(
337
+ <ToggleGroup
338
+ defaultValue='option-1'
339
+ onValueChange={onValueChange}
340
+ >
341
+ <ToggleGroupItem
342
+ value='option-1'
343
+ label='Option 1'
344
+ />
345
+ <ToggleGroupItem
346
+ value='option-2'
347
+ label='Option 2'
348
+ />
349
+ <ToggleGroupItem
350
+ value='option-3'
351
+ label='Option 3'
352
+ />
353
+ </ToggleGroup>
354
+ );
355
+
356
+ await user.click(screen.getByRole('combobox'));
357
+ await user.click(screen.getByRole('option', { name: 'Option 2' }));
358
+
359
+ expect(onValueChange).toHaveBeenCalledWith('option-2');
360
+ expect(screen.getByRole('combobox')).toHaveTextContent('Option 2');
361
+ });
362
+
363
+ // The Select fallback only forwards `label`/`value` for each item, so a disabled
364
+ // ToggleGroupItem's disabled state is not carried over to the mobile Select option.
365
+ test('does not prevent selection of a disabled item in the mobile select', async () => {
366
+ const user = userEvent.setup();
367
+ const onValueChange = vi.fn();
368
+
369
+ wrap(
370
+ <ToggleGroup
371
+ defaultValue='option-1'
372
+ onValueChange={onValueChange}
373
+ >
374
+ <ToggleGroupItem
375
+ value='option-1'
376
+ label='Option 1'
377
+ />
378
+ <ToggleGroupItem
379
+ value='option-2'
380
+ label='Option 2'
381
+ disabled
382
+ />
383
+ <ToggleGroupItem
384
+ value='option-3'
385
+ label='Option 3'
386
+ />
387
+ </ToggleGroup>
388
+ );
389
+
390
+ await user.click(screen.getByRole('combobox'));
391
+ await user.click(screen.getByRole('option', { name: 'Option 2' }));
392
+
393
+ expect(onValueChange).toHaveBeenCalledWith('option-2');
394
+ });
395
+
396
+ test('renders a native <select> with matching options when selectProps requests the native variant', () => {
397
+ wrap(
398
+ <ToggleGroup
399
+ defaultValue='option-1'
400
+ selectProps={{
401
+ native: true,
402
+ nativeHtmlAttributes: { onChange: vi.fn() },
403
+ }}
404
+ >
405
+ <ToggleGroupItem
406
+ value='option-1'
407
+ label='Option 1'
408
+ />
409
+ <ToggleGroupItem
410
+ value='option-2'
411
+ label='Option 2'
412
+ />
413
+ <ToggleGroupItem
414
+ value='option-3'
415
+ label='Option 3'
416
+ />
417
+ </ToggleGroup>
418
+ );
419
+
420
+ const select = screen.getByTestId(
421
+ 'ucl-uikit-select--native'
422
+ ) as HTMLSelectElement;
423
+
424
+ expect(select.tagName).toBe('SELECT');
425
+ expect(select.value).toBe('option-1');
426
+ expect(
427
+ Array.from(select.querySelectorAll('option')).map(
428
+ (option) => option.textContent
429
+ )
430
+ ).toEqual(['Option 1', 'Option 2', 'Option 3']);
431
+ });
432
+
433
+ test('relies on the caller-supplied nativeHtmlAttributes.onChange rather than onValueChange for native selects', () => {
434
+ const onValueChange = vi.fn();
435
+ const nativeOnChange = vi.fn();
436
+
437
+ wrap(
438
+ <ToggleGroup
439
+ defaultValue='option-1'
440
+ onValueChange={onValueChange}
441
+ selectProps={{
442
+ native: true,
443
+ nativeHtmlAttributes: { onChange: nativeOnChange },
444
+ }}
445
+ >
446
+ <ToggleGroupItem
447
+ value='option-1'
448
+ label='Option 1'
449
+ />
450
+ <ToggleGroupItem
451
+ value='option-2'
452
+ label='Option 2'
453
+ />
454
+ <ToggleGroupItem
455
+ value='option-3'
456
+ label='Option 3'
457
+ />
458
+ </ToggleGroup>
459
+ );
460
+
461
+ fireEvent.change(screen.getByTestId('ucl-uikit-select--native'), {
462
+ target: { value: 'option-2' },
463
+ });
464
+
465
+ expect(nativeOnChange).toHaveBeenCalled();
466
+ expect(onValueChange).not.toHaveBeenCalled();
467
+ });
468
+ });
@@ -0,0 +1,4 @@
1
+ export { default } from './ToggleGroup';
2
+ export type { ToggleGroupProps } from './ToggleGroup';
3
+ export { default as Item } from './subcomponents/Item';
4
+ export type { ItemProps } from './subcomponents/Item';
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import ToggleGroup from '../ToggleGroup';
4
+ import Item from './Item';
5
+
6
+ const meta = {
7
+ title: 'Components/ToggleGroup/Item',
8
+ component: Item,
9
+ parameters: {
10
+ layout: 'centered',
11
+ },
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <ToggleGroup defaultValue='option-1'>
16
+ <Story />
17
+ </ToggleGroup>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof Item>;
21
+
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ export const Selected: Story = {
27
+ args: {
28
+ value: 'option-1',
29
+ label: 'Selected',
30
+ },
31
+ };
32
+
33
+ export const Unselected: Story = {
34
+ args: {
35
+ value: 'option-2',
36
+ label: 'Unselected',
37
+ },
38
+ };
39
+
40
+ export const WithIcon: Story = {
41
+ args: {
42
+ value: 'option-2',
43
+ label: 'With icon',
44
+ icon: 'Calendar',
45
+ },
46
+ };
47
+
48
+ export const WithSublabel: Story = {
49
+ args: {
50
+ value: 'option-2',
51
+ label: 'With sublabel',
52
+ sublabel: 'Sublabel text',
53
+ icon: 'User',
54
+ },
55
+ };
56
+
57
+ export const Disabled: Story = {
58
+ args: {
59
+ value: 'option-2',
60
+ label: 'Disabled',
61
+ disabled: true,
62
+ },
63
+ };