@sveltia/ui 0.34.0 → 0.35.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 (42) hide show
  1. package/dist/components/button/button.svelte +2 -1
  2. package/dist/components/calendar/calendar.svelte +17 -25
  3. package/dist/components/divider/spacer.svelte +2 -1
  4. package/dist/components/select/combobox.svelte +10 -7
  5. package/dist/components/text-editor/code-editor.svelte +3 -0
  6. package/dist/components/text-editor/constants.test.d.ts +1 -0
  7. package/dist/components/text-editor/constants.test.js +98 -0
  8. package/dist/components/text-editor/core.js +13 -8
  9. package/dist/components/text-editor/store.svelte.test.d.ts +1 -0
  10. package/dist/components/text-editor/store.svelte.test.js +196 -0
  11. package/dist/components/text-editor/text-editor.svelte +3 -0
  12. package/dist/components/text-editor/transformers/hr.test.d.ts +1 -0
  13. package/dist/components/text-editor/transformers/hr.test.js +108 -0
  14. package/dist/components/text-editor/transformers/table.test.d.ts +1 -0
  15. package/dist/components/text-editor/transformers/table.test.js +28 -0
  16. package/dist/components/text-field/number-input.svelte +2 -1
  17. package/dist/components/text-field/password-input.svelte +2 -1
  18. package/dist/components/text-field/search-bar.svelte +2 -1
  19. package/dist/components/text-field/secret-input.svelte +2 -1
  20. package/dist/components/text-field/text-area.svelte +2 -1
  21. package/dist/components/text-field/text-input.svelte +41 -2
  22. package/dist/components/toast/toast.svelte +7 -3
  23. package/dist/services/events.svelte.js +66 -8
  24. package/dist/services/events.test.d.ts +1 -0
  25. package/dist/services/events.test.js +221 -0
  26. package/dist/services/group.svelte.d.ts +1 -0
  27. package/dist/services/group.svelte.js +15 -10
  28. package/dist/services/group.test.d.ts +1 -0
  29. package/dist/services/group.test.js +763 -0
  30. package/dist/services/i18n.d.ts +6 -0
  31. package/dist/services/i18n.js +4 -2
  32. package/dist/services/i18n.test.d.ts +1 -0
  33. package/dist/services/i18n.test.js +106 -0
  34. package/dist/services/popup.svelte.d.ts +1 -0
  35. package/dist/services/popup.svelte.js +11 -2
  36. package/dist/services/popup.test.d.ts +1 -0
  37. package/dist/services/popup.test.js +536 -0
  38. package/dist/services/select.test.d.ts +1 -0
  39. package/dist/services/select.test.js +69 -0
  40. package/dist/typedefs.d.ts +7 -0
  41. package/dist/typedefs.js +4 -0
  42. package/package.json +12 -11
@@ -0,0 +1,763 @@
1
+ /* eslint-disable jsdoc/require-jsdoc */
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { activateGroup, normalize } from './group.svelte.js';
5
+
6
+ describe('normalize', () => {
7
+ it('should trim whitespace', () => {
8
+ expect(normalize(' hello ')).toBe('hello');
9
+ });
10
+
11
+ it('should return empty string for blank input', () => {
12
+ expect(normalize(' ')).toBe('');
13
+ expect(normalize('')).toBe('');
14
+ });
15
+
16
+ it('should convert to lower case', () => {
17
+ expect(normalize('HELLO')).toBe('hello');
18
+ expect(normalize('Hello World')).toBe('hello world');
19
+ });
20
+
21
+ it('should strip diacritics', () => {
22
+ expect(normalize('café')).toBe('cafe');
23
+ expect(normalize('naïve')).toBe('naive');
24
+ expect(normalize('résumé')).toBe('resume'); // cspell:disable-line
25
+ });
26
+
27
+ it('should handle strings without diacritics unchanged (apart from case)', () => {
28
+ expect(normalize('hello world')).toBe('hello world');
29
+ });
30
+ });
31
+
32
+ describe('Group - tablist', () => {
33
+ /** @type {HTMLElement} */
34
+ let tablist;
35
+ /** @type {HTMLElement[]} */
36
+ let tabs;
37
+
38
+ beforeEach(async () => {
39
+ vi.useFakeTimers();
40
+ tablist = document.createElement('div');
41
+ tablist.setAttribute('role', 'tablist');
42
+ tabs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
43
+ const tab = document.createElement('div');
44
+
45
+ tab.setAttribute('role', 'tab');
46
+ tab.textContent = label;
47
+ tablist.appendChild(tab);
48
+
49
+ return tab;
50
+ });
51
+ document.body.appendChild(tablist);
52
+ activateGroup(tablist);
53
+ await vi.advanceTimersByTimeAsync(150);
54
+ });
55
+
56
+ afterEach(() => {
57
+ tablist.remove();
58
+ vi.useRealTimers();
59
+ });
60
+
61
+ it('should select the first tab by default', () => {
62
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
63
+ expect(tabs[1].getAttribute('aria-selected')).toBe('false');
64
+ expect(tabs[2].getAttribute('aria-selected')).toBe('false');
65
+ });
66
+
67
+ it('should set tabIndex=0 only on the first selected tab', () => {
68
+ expect(tabs[0].tabIndex).toBe(0);
69
+ expect(tabs[1].tabIndex).toBe(-1);
70
+ expect(tabs[2].tabIndex).toBe(-1);
71
+ });
72
+
73
+ it('should assign element IDs to tabs after activation', () => {
74
+ tabs.forEach((tab) => {
75
+ expect(tab.id).toBeTruthy();
76
+ });
77
+ });
78
+
79
+ it('should set tabIndex=-1 on the parent tablist', () => {
80
+ expect(tablist.tabIndex).toBe(-1);
81
+ });
82
+
83
+ it('should select a tab by click', () => {
84
+ tabs[1].click();
85
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true');
86
+ expect(tabs[0].getAttribute('aria-selected')).toBe('false');
87
+ });
88
+
89
+ it('should move focus right on ArrowRight key from first tab', () => {
90
+ tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
91
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true');
92
+ expect(tabs[0].getAttribute('aria-selected')).toBe('false');
93
+ });
94
+
95
+ it('should wrap focus to first tab on ArrowRight from last tab', () => {
96
+ // Select last tab first
97
+ tabs[2].click();
98
+ tabs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
99
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
100
+ });
101
+
102
+ it('should wrap focus to last tab on ArrowLeft from first tab', () => {
103
+ tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
104
+ expect(tabs[2].getAttribute('aria-selected')).toBe('true');
105
+ });
106
+
107
+ it('should ignore key events when modifier keys are held', () => {
108
+ tabs[0].dispatchEvent(
109
+ new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true, bubbles: true }),
110
+ );
111
+ // Selection should remain unchanged
112
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
113
+ });
114
+
115
+ it('should return undefined currentTarget when keydown targets the tablist itself (line 411)', () => {
116
+ // Dispatching directly on the tablist — target does not match [role="tab"]
117
+ tablist.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
118
+ // No navigation happens, first tab remains selected
119
+ expect(tabs[0].getAttribute('aria-selected')).toBe('true');
120
+ });
121
+
122
+ it('should update tabIndex after requestAnimationFrame runs', async () => {
123
+ tabs[1].click();
124
+ await vi.advanceTimersByTimeAsync(16); // flush rAF (lines 314-318)
125
+ expect(tabs[1].tabIndex).toBe(0);
126
+ expect(tabs[0].tabIndex).toBe(-1);
127
+ });
128
+ });
129
+
130
+ describe('Group - listbox', () => {
131
+ /** @type {HTMLElement} */
132
+ let listbox;
133
+ /** @type {HTMLElement[]} */
134
+ let options;
135
+
136
+ beforeEach(async () => {
137
+ vi.useFakeTimers();
138
+ listbox = document.createElement('div');
139
+ listbox.setAttribute('role', 'listbox');
140
+ options = ['Option A', 'Option B', 'Option C'].map((label) => {
141
+ const opt = document.createElement('div');
142
+
143
+ opt.setAttribute('role', 'option');
144
+ opt.textContent = label;
145
+ listbox.appendChild(opt);
146
+
147
+ return opt;
148
+ });
149
+ document.body.appendChild(listbox);
150
+ activateGroup(listbox);
151
+ await vi.advanceTimersByTimeAsync(150);
152
+ });
153
+
154
+ afterEach(() => {
155
+ listbox.remove();
156
+ vi.useRealTimers();
157
+ });
158
+
159
+ it('should not pre-select any option (selectFirst=false for listbox)', () => {
160
+ options.forEach((opt) => {
161
+ expect(opt.getAttribute('aria-selected')).toBe('false');
162
+ });
163
+ });
164
+
165
+ it('should set tabIndex=-1 on all options', () => {
166
+ options.forEach((opt) => {
167
+ expect(opt.tabIndex).toBe(-1);
168
+ });
169
+ });
170
+ });
171
+
172
+ describe('Group - onUpdate (search filter)', () => {
173
+ /** @type {HTMLElement} */
174
+ let listbox;
175
+ /** @type {HTMLElement[]} */
176
+ let options;
177
+ /** @type {any} */
178
+ let action;
179
+
180
+ beforeEach(async () => {
181
+ vi.useFakeTimers();
182
+ listbox = document.createElement('div');
183
+ listbox.setAttribute('role', 'listbox');
184
+ options = [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }].map(({ label }) => {
185
+ const opt = document.createElement('div');
186
+
187
+ opt.setAttribute('role', 'option');
188
+ opt.dataset.label = label;
189
+ opt.textContent = label;
190
+ listbox.appendChild(opt);
191
+
192
+ return opt;
193
+ });
194
+ document.body.appendChild(listbox);
195
+ action = activateGroup(listbox, { searchTerms: '' });
196
+ await vi.advanceTimersByTimeAsync(150);
197
+ });
198
+
199
+ afterEach(() => {
200
+ listbox.remove();
201
+ vi.useRealTimers();
202
+ });
203
+
204
+ it('should dispatch Filter event with match count when search terms are applied', () => {
205
+ const filterEvents = /** @type {CustomEvent[]} */ ([]);
206
+
207
+ listbox.addEventListener('Filter', (e) => filterEvents.push(/** @type {CustomEvent} */ (e)));
208
+ action.update({ searchTerms: 'an' }); // matches Banana
209
+ expect(filterEvents.length).toBeGreaterThan(0);
210
+ expect(filterEvents[filterEvents.length - 1].detail.matched).toBe(1);
211
+ expect(filterEvents[filterEvents.length - 1].detail.total).toBe(3);
212
+ });
213
+
214
+ it('should dispatch Toggle event on each item during filter', () => {
215
+ const toggledItems = /** @type {string[]} */ ([]);
216
+
217
+ options.forEach((opt) => {
218
+ opt.addEventListener('Toggle', (e) => {
219
+ if (/** @type {CustomEvent} */ (e).detail.hidden) {
220
+ toggledItems.push(opt.dataset.label ?? '');
221
+ }
222
+ });
223
+ });
224
+ action.update({ searchTerms: 'apple' });
225
+ // Banana and Cherry should be toggled hidden
226
+ expect(toggledItems).toContain('Banana');
227
+ expect(toggledItems).toContain('Cherry');
228
+ expect(toggledItems).not.toContain('Apple');
229
+ });
230
+
231
+ it('should show all items when search terms are cleared', () => {
232
+ action.update({ searchTerms: 'apple' });
233
+
234
+ const filterEvents = /** @type {CustomEvent[]} */ ([]);
235
+
236
+ listbox.addEventListener('Filter', (e) => filterEvents.push(/** @type {CustomEvent} */ (e)));
237
+ action.update({ searchTerms: '' });
238
+ expect(filterEvents[filterEvents.length - 1].detail.matched).toBe(3);
239
+ });
240
+ });
241
+
242
+ describe('Group - tablist keyboard (Enter and Space)', () => {
243
+ /** @type {HTMLElement} */
244
+ let tablist;
245
+ /** @type {HTMLElement[]} */
246
+ let tabs;
247
+
248
+ beforeEach(async () => {
249
+ vi.useFakeTimers();
250
+ tablist = document.createElement('div');
251
+ tablist.setAttribute('role', 'tablist');
252
+ tabs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
253
+ const tab = document.createElement('div');
254
+
255
+ tab.setAttribute('role', 'tab');
256
+ tab.textContent = label;
257
+ tablist.appendChild(tab);
258
+
259
+ return tab;
260
+ });
261
+ document.body.appendChild(tablist);
262
+ activateGroup(tablist);
263
+ await vi.advanceTimersByTimeAsync(150);
264
+ });
265
+
266
+ afterEach(() => {
267
+ tablist.remove();
268
+ vi.useRealTimers();
269
+ });
270
+
271
+ it('should select a tab when Space key is pressed on it', () => {
272
+ tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
273
+ expect(tabs[1].getAttribute('aria-selected')).toBe('true');
274
+ expect(tabs[0].getAttribute('aria-selected')).toBe('false');
275
+ });
276
+
277
+ it('should trigger a click on the current tab when Enter is pressed', () => {
278
+ let clicked = false;
279
+
280
+ tabs[1].addEventListener('click', () => {
281
+ clicked = true;
282
+ });
283
+ tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
284
+ expect(clicked).toBe(true);
285
+ });
286
+ });
287
+
288
+ describe('Group - tablist with aria-controls panels', () => {
289
+ /** @type {HTMLElement} */
290
+ let tablist;
291
+ /** @type {HTMLElement[]} */
292
+ let tabs;
293
+ /** @type {HTMLElement[]} */
294
+ let panels;
295
+
296
+ beforeEach(async () => {
297
+ vi.useFakeTimers();
298
+ panels = ['panel-a', 'panel-b', 'panel-c'].map((id) => {
299
+ const panel = document.createElement('div');
300
+
301
+ panel.id = id;
302
+ document.body.appendChild(panel);
303
+
304
+ return panel;
305
+ });
306
+ tablist = document.createElement('div');
307
+ tablist.setAttribute('role', 'tablist');
308
+ tabs = panels.map((panel, i) => {
309
+ const tab = document.createElement('div');
310
+
311
+ tab.setAttribute('role', 'tab');
312
+ tab.setAttribute('aria-controls', panel.id);
313
+ tab.textContent = `Tab ${i + 1}`;
314
+ tablist.appendChild(tab);
315
+
316
+ return tab;
317
+ });
318
+ document.body.appendChild(tablist);
319
+ activateGroup(tablist);
320
+ // also covers scrollIntoView setTimeout(300) in activate()
321
+ await vi.advanceTimersByTimeAsync(500);
322
+ });
323
+
324
+ afterEach(() => {
325
+ tablist.remove();
326
+ panels.forEach((p) => p.remove());
327
+ vi.useRealTimers();
328
+ });
329
+
330
+ it('should set aria-hidden=false on the first panel after activation', () => {
331
+ expect(panels[0].getAttribute('aria-hidden')).toBe('false');
332
+ });
333
+
334
+ it('should set aria-hidden=true on non-selected panels after activation', () => {
335
+ expect(panels[1].getAttribute('aria-hidden')).toBe('true');
336
+ expect(panels[2].getAttribute('aria-hidden')).toBe('true');
337
+ });
338
+
339
+ it('should set inert=false on the first panel and inert=true on others', () => {
340
+ expect(panels[0].inert).toBe(false);
341
+ expect(panels[1].inert).toBe(true);
342
+ expect(panels[2].inert).toBe(true);
343
+ });
344
+
345
+ it('should set aria-labelledby on panels to match tab IDs', () => {
346
+ expect(panels[0].getAttribute('aria-labelledby')).toBe(tabs[0].id);
347
+ });
348
+
349
+ it('should switch panel visibility when a different tab is selected', () => {
350
+ tabs[1].click();
351
+ expect(panels[1].getAttribute('aria-hidden')).toBe('false');
352
+ expect(panels[0].getAttribute('aria-hidden')).toBe('true');
353
+ });
354
+
355
+ it('should update inert on panels when a different tab is selected', () => {
356
+ tabs[1].click();
357
+ expect(panels[1].inert).toBe(false);
358
+ expect(panels[0].inert).toBe(true);
359
+ });
360
+
361
+ it('should fire scrollIntoView on selected panel after 300ms (selectTarget)', async () => {
362
+ tabs[1].click();
363
+ // covers setTimeout(300) in selectTarget (lines 335-342, 352-355)
364
+ await vi.advanceTimersByTimeAsync(400);
365
+ // No crash; panel still correct
366
+ expect(panels[1].getAttribute('aria-hidden')).toBe('false');
367
+ });
368
+ });
369
+
370
+ describe('Group - disabled and read-only', () => {
371
+ /** @type {HTMLElement} */
372
+ let listbox;
373
+ /** @type {HTMLElement[]} */
374
+ let options;
375
+
376
+ beforeEach(async () => {
377
+ vi.useFakeTimers();
378
+ listbox = document.createElement('div');
379
+ listbox.setAttribute('role', 'listbox');
380
+ options = ['Option A', 'Option B'].map((label) => {
381
+ const opt = document.createElement('div');
382
+
383
+ opt.setAttribute('role', 'option');
384
+ opt.textContent = label;
385
+ listbox.appendChild(opt);
386
+
387
+ return opt;
388
+ });
389
+ document.body.appendChild(listbox);
390
+ activateGroup(listbox);
391
+ await vi.advanceTimersByTimeAsync(150);
392
+ });
393
+
394
+ afterEach(() => {
395
+ listbox.remove();
396
+ vi.useRealTimers();
397
+ });
398
+
399
+ it('should not select option when parent listbox is aria-disabled', () => {
400
+ listbox.setAttribute('aria-disabled', 'true');
401
+ options[0].click();
402
+ expect(options[0].getAttribute('aria-selected')).toBe('false');
403
+ });
404
+
405
+ it('should not select option when parent listbox is aria-readonly', () => {
406
+ listbox.setAttribute('aria-readonly', 'true');
407
+ options[0].click();
408
+ expect(options[0].getAttribute('aria-selected')).toBe('false');
409
+ });
410
+ });
411
+
412
+ describe('Group - multiselect listbox', () => {
413
+ /** @type {HTMLElement} */
414
+ let listbox;
415
+ /** @type {HTMLElement[]} */
416
+ let options;
417
+
418
+ beforeEach(async () => {
419
+ vi.useFakeTimers();
420
+ listbox = document.createElement('div');
421
+ listbox.setAttribute('role', 'listbox');
422
+ listbox.setAttribute('aria-multiselectable', 'true');
423
+ options = ['Option A', 'Option B', 'Option C'].map((label) => {
424
+ const opt = document.createElement('div');
425
+
426
+ opt.setAttribute('role', 'option');
427
+ opt.textContent = label;
428
+ listbox.appendChild(opt);
429
+
430
+ return opt;
431
+ });
432
+ document.body.appendChild(listbox);
433
+ activateGroup(listbox);
434
+ await vi.advanceTimersByTimeAsync(150);
435
+ });
436
+
437
+ afterEach(() => {
438
+ listbox.remove();
439
+ vi.useRealTimers();
440
+ });
441
+
442
+ it('should select option on click', () => {
443
+ options[0].click();
444
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
445
+ });
446
+
447
+ it('should deselect option on second click (toggle)', () => {
448
+ options[0].click();
449
+ options[0].click();
450
+ expect(options[0].getAttribute('aria-selected')).toBe('false');
451
+ });
452
+
453
+ it('should independently select multiple options', () => {
454
+ options[0].click();
455
+ options[2].click();
456
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
457
+ expect(options[1].getAttribute('aria-selected')).toBe('false');
458
+ expect(options[2].getAttribute('aria-selected')).toBe('true');
459
+ });
460
+ });
461
+
462
+ describe('Group - listbox keyboard navigation', () => {
463
+ /** @type {HTMLElement} */
464
+ let listbox;
465
+ /** @type {HTMLElement[]} */
466
+ let options;
467
+
468
+ beforeEach(async () => {
469
+ vi.useFakeTimers();
470
+ listbox = document.createElement('div');
471
+ listbox.setAttribute('role', 'listbox');
472
+ options = ['Option A', 'Option B', 'Option C'].map((label) => {
473
+ const opt = document.createElement('div');
474
+
475
+ opt.setAttribute('role', 'option');
476
+ opt.textContent = label;
477
+ listbox.appendChild(opt);
478
+
479
+ return opt;
480
+ });
481
+ document.body.appendChild(listbox);
482
+ activateGroup(listbox);
483
+ await vi.advanceTimersByTimeAsync(150);
484
+ });
485
+
486
+ afterEach(() => {
487
+ listbox.remove();
488
+ vi.useRealTimers();
489
+ });
490
+
491
+ it('should select the first option on ArrowDown when none is focused', () => {
492
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
493
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
494
+ });
495
+
496
+ it('should move to the next option on ArrowDown', () => {
497
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
498
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
499
+ expect(options[1].getAttribute('aria-selected')).toBe('true');
500
+ expect(options[0].getAttribute('aria-selected')).toBe('false');
501
+ });
502
+
503
+ it('should wrap to the first option on ArrowDown from last', () => {
504
+ // Navigate to last option
505
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
506
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
507
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
508
+ // options[2] should be selected now; ArrowDown wraps to first
509
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
510
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
511
+ });
512
+
513
+ it('should select the last option on ArrowUp when none is focused', () => {
514
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
515
+ expect(options[2].getAttribute('aria-selected')).toBe('true');
516
+ });
517
+
518
+ it('should move to the previous option on ArrowUp', () => {
519
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
520
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
521
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
522
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
523
+ expect(options[1].getAttribute('aria-selected')).toBe('true');
524
+ });
525
+
526
+ it('should add focused class to selected option', () => {
527
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
528
+ expect(options[0].classList.contains('focused')).toBe(true);
529
+ });
530
+
531
+ it('should move focused class when navigating', () => {
532
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
533
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
534
+ expect(options[1].classList.contains('focused')).toBe(true);
535
+ expect(options[0].classList.contains('focused')).toBe(false);
536
+ });
537
+ });
538
+
539
+ describe('Group - menu with menuitemradio', () => {
540
+ /** @type {HTMLElement} */
541
+ let menu;
542
+ /** @type {HTMLElement[]} */
543
+ let radioItems;
544
+
545
+ beforeEach(async () => {
546
+ vi.useFakeTimers();
547
+ menu = document.createElement('div');
548
+ menu.setAttribute('role', 'menu');
549
+ radioItems = ['Small', 'Medium', 'Large'].map((label) => {
550
+ const item = document.createElement('div');
551
+
552
+ item.setAttribute('role', 'menuitemradio');
553
+ item.textContent = label;
554
+ menu.appendChild(item);
555
+
556
+ return item;
557
+ });
558
+ document.body.appendChild(menu);
559
+ activateGroup(menu);
560
+ await vi.advanceTimersByTimeAsync(150);
561
+ });
562
+
563
+ afterEach(() => {
564
+ menu.remove();
565
+ vi.useRealTimers();
566
+ });
567
+
568
+ it('should select the clicked radio item', () => {
569
+ radioItems[0].click();
570
+ expect(radioItems[0].getAttribute('aria-checked')).toBe('true');
571
+ });
572
+
573
+ it('should deselect other radio items when one is selected', () => {
574
+ radioItems[0].click();
575
+ expect(radioItems[1].getAttribute('aria-checked')).toBe('false');
576
+ expect(radioItems[2].getAttribute('aria-checked')).toBe('false');
577
+ });
578
+
579
+ it('should switch selection when another radio item is clicked', () => {
580
+ radioItems[0].click();
581
+ radioItems[1].click();
582
+ expect(radioItems[1].getAttribute('aria-checked')).toBe('true');
583
+ expect(radioItems[0].getAttribute('aria-checked')).toBe('false');
584
+ });
585
+ });
586
+
587
+ describe('Group - menu with menuitemcheckbox', () => {
588
+ /** @type {HTMLElement} */
589
+ let menu;
590
+ /** @type {HTMLElement[]} */
591
+ let checkItems;
592
+
593
+ beforeEach(async () => {
594
+ vi.useFakeTimers();
595
+ menu = document.createElement('div');
596
+ menu.setAttribute('role', 'menu');
597
+ checkItems = ['Bold', 'Italic', 'Underline'].map((label) => {
598
+ const item = document.createElement('div');
599
+
600
+ item.setAttribute('role', 'menuitemcheckbox');
601
+ item.textContent = label;
602
+ menu.appendChild(item);
603
+
604
+ return item;
605
+ });
606
+ document.body.appendChild(menu);
607
+ activateGroup(menu);
608
+ await vi.advanceTimersByTimeAsync(150);
609
+ });
610
+
611
+ afterEach(() => {
612
+ menu.remove();
613
+ vi.useRealTimers();
614
+ });
615
+
616
+ it('should check a menuitemcheckbox on click', () => {
617
+ checkItems[0].click();
618
+ expect(checkItems[0].getAttribute('aria-checked')).toBe('true');
619
+ });
620
+
621
+ it('should uncheck a menuitemcheckbox on second click', () => {
622
+ checkItems[0].click();
623
+ checkItems[0].click();
624
+ expect(checkItems[0].getAttribute('aria-checked')).toBe('false');
625
+ });
626
+
627
+ it('should independently toggle each checkbox', () => {
628
+ checkItems[0].click();
629
+ checkItems[1].click();
630
+ expect(checkItems[0].getAttribute('aria-checked')).toBe('true');
631
+ expect(checkItems[1].getAttribute('aria-checked')).toBe('true');
632
+ expect(checkItems[2].getAttribute('aria-checked')).toBe('false');
633
+ });
634
+ });
635
+
636
+ describe('Group - radiogroup keyboard navigation', () => {
637
+ /** @type {HTMLElement} */
638
+ let radiogroup;
639
+ /** @type {HTMLElement[]} */
640
+ let radios;
641
+
642
+ beforeEach(async () => {
643
+ vi.useFakeTimers();
644
+ radiogroup = document.createElement('div');
645
+ radiogroup.setAttribute('role', 'radiogroup');
646
+ radios = ['A', 'B', 'C'].map((label) => {
647
+ const radio = document.createElement('div');
648
+
649
+ radio.setAttribute('role', 'radio');
650
+ radio.textContent = label;
651
+ radiogroup.appendChild(radio);
652
+
653
+ return radio;
654
+ });
655
+ document.body.appendChild(radiogroup);
656
+ activateGroup(radiogroup);
657
+ await vi.advanceTimersByTimeAsync(150);
658
+ radios[0].click(); // select first radio
659
+ });
660
+
661
+ afterEach(() => {
662
+ radiogroup.remove();
663
+ vi.useRealTimers();
664
+ });
665
+
666
+ it('should navigate to the next radio on ArrowRight', () => {
667
+ radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
668
+ expect(radios[1].getAttribute('aria-checked')).toBe('true');
669
+ expect(radios[0].getAttribute('aria-checked')).toBe('false');
670
+ });
671
+
672
+ it('should trigger click on the radio element during keydown navigation (line 304)', () => {
673
+ let clickCount = 0;
674
+
675
+ radios[1].addEventListener('click', () => {
676
+ clickCount += 1;
677
+ });
678
+ radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
679
+ expect(clickCount).toBe(1);
680
+ });
681
+
682
+ it('should navigate to the previous radio on ArrowLeft', () => {
683
+ radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
684
+ radios[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
685
+ expect(radios[0].getAttribute('aria-checked')).toBe('true');
686
+ expect(radios[1].getAttribute('aria-checked')).toBe('false');
687
+ });
688
+ });
689
+
690
+ describe('Group - grid listbox navigation', () => {
691
+ /** @type {HTMLElement} */
692
+ let listbox;
693
+ /** @type {HTMLElement[]} */
694
+ let options;
695
+
696
+ beforeEach(async () => {
697
+ vi.useFakeTimers();
698
+ listbox = document.createElement('div');
699
+ listbox.setAttribute('role', 'listbox');
700
+ listbox.classList.add('grid');
701
+ options = Array.from({ length: 6 }, (_, i) => {
702
+ const opt = document.createElement('div');
703
+
704
+ opt.setAttribute('role', 'option');
705
+ opt.textContent = `Item ${i + 1}`;
706
+ listbox.appendChild(opt);
707
+
708
+ return opt;
709
+ });
710
+ document.body.appendChild(listbox);
711
+ // Mock clientWidths so colCount = Math.floor(300/100) = 3
712
+ Object.defineProperty(listbox, 'clientWidth', {
713
+ configurable: true,
714
+ get: () => 300,
715
+ });
716
+ options.forEach((opt) => {
717
+ Object.defineProperty(opt, 'clientWidth', {
718
+ configurable: true,
719
+ get: () => 100,
720
+ });
721
+ });
722
+ activateGroup(listbox);
723
+ await vi.advanceTimersByTimeAsync(150);
724
+ options[0].click(); // add .focused class to options[0]
725
+ });
726
+
727
+ afterEach(() => {
728
+ listbox.remove();
729
+ vi.useRealTimers();
730
+ });
731
+
732
+ it('should navigate down by colCount on ArrowDown', () => {
733
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
734
+ // options[0] (index 0) + colCount 3 = options[3]
735
+ expect(options[3].getAttribute('aria-selected')).toBe('true');
736
+ expect(options[0].getAttribute('aria-selected')).toBe('false');
737
+ });
738
+
739
+ it('should navigate right on ArrowRight', () => {
740
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
741
+ expect(options[1].getAttribute('aria-selected')).toBe('true');
742
+ });
743
+
744
+ it('should navigate left on ArrowLeft', () => {
745
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
746
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
747
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
748
+ });
749
+
750
+ it('should navigate up by colCount on ArrowUp', () => {
751
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
752
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
753
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
754
+ });
755
+
756
+ it('should skip a disabled option during grid navigation', () => {
757
+ options[3].setAttribute('aria-disabled', 'true');
758
+ listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
759
+ // options[3] is disabled → newTarget set to undefined → no navigation
760
+ expect(options[0].getAttribute('aria-selected')).toBe('true');
761
+ expect(options[3].getAttribute('aria-selected')).not.toBe('true');
762
+ });
763
+ });