@sveltia/ui 0.36.1 → 0.37.0

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.
@@ -1,1215 +0,0 @@
1
- /* eslint-disable jsdoc/require-jsdoc */
2
-
3
- import { locale } from '@sveltia/i18n';
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import { activateGroup, Group, normalize } from './group.svelte.js';
6
-
7
- describe('normalize', () => {
8
- it('should trim whitespace', () => {
9
- expect(normalize(' hello ')).toBe('hello');
10
- });
11
-
12
- it('should return empty string for blank input', () => {
13
- expect(normalize(' ')).toBe('');
14
- expect(normalize('')).toBe('');
15
- });
16
-
17
- it('should convert to lower case', () => {
18
- expect(normalize('HELLO')).toBe('hello');
19
- expect(normalize('Hello World')).toBe('hello world');
20
- });
21
-
22
- it('should strip diacritics', () => {
23
- expect(normalize('café')).toBe('cafe');
24
- expect(normalize('naïve')).toBe('naive');
25
- expect(normalize('résumé')).toBe('resume'); // cspell:disable-line
26
- });
27
-
28
- it('should handle strings without diacritics unchanged (apart from case)', () => {
29
- expect(normalize('hello world')).toBe('hello world');
30
- });
31
- });
32
-
33
- describe('Group - tablist', () => {
34
- /** @type {HTMLElement} */
35
- let tablist;
36
- /** @type {HTMLElement[]} */
37
- let tabs;
38
-
39
- beforeEach(async () => {
40
- vi.useFakeTimers();
41
- tablist = document.createElement('div');
42
- tablist.setAttribute('role', 'tablist');
43
- tabs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
44
- const tab = document.createElement('div');
45
-
46
- tab.setAttribute('role', 'tab');
47
- tab.textContent = label;
48
- tablist.appendChild(tab);
49
-
50
- return tab;
51
- });
52
- document.body.appendChild(tablist);
53
- activateGroup()(tablist);
54
- await vi.advanceTimersByTimeAsync(150);
55
- });
56
-
57
- afterEach(() => {
58
- tablist.remove();
59
- vi.useRealTimers();
60
- });
61
-
62
- it('should select the first tab by default', () => {
63
- expect(tabs[0].getAttribute('aria-selected')).toBe('true');
64
- expect(tabs[1].getAttribute('aria-selected')).toBe('false');
65
- expect(tabs[2].getAttribute('aria-selected')).toBe('false');
66
- });
67
-
68
- it('should set tabIndex=0 only on the first selected tab', () => {
69
- expect(tabs[0].tabIndex).toBe(0);
70
- expect(tabs[1].tabIndex).toBe(-1);
71
- expect(tabs[2].tabIndex).toBe(-1);
72
- });
73
-
74
- it('should assign element IDs to tabs after activation', () => {
75
- tabs.forEach((tab) => {
76
- expect(tab.id).toBeTruthy();
77
- });
78
- });
79
-
80
- it('should set tabIndex=-1 on the parent tablist', () => {
81
- expect(tablist.tabIndex).toBe(-1);
82
- });
83
-
84
- it('should select a tab by click', () => {
85
- tabs[1].click();
86
- expect(tabs[1].getAttribute('aria-selected')).toBe('true');
87
- expect(tabs[0].getAttribute('aria-selected')).toBe('false');
88
- });
89
-
90
- it('should move focus right on ArrowRight key from first tab', () => {
91
- tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
92
- expect(tabs[1].getAttribute('aria-selected')).toBe('true');
93
- expect(tabs[0].getAttribute('aria-selected')).toBe('false');
94
- });
95
-
96
- it('should wrap focus to first tab on ArrowRight from last tab', () => {
97
- // Select last tab first
98
- tabs[2].click();
99
- tabs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
100
- expect(tabs[0].getAttribute('aria-selected')).toBe('true');
101
- });
102
-
103
- it('should wrap focus to last tab on ArrowLeft from first tab', () => {
104
- tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
105
- expect(tabs[2].getAttribute('aria-selected')).toBe('true');
106
- });
107
-
108
- it('should ignore key events when modifier keys are held', () => {
109
- tabs[0].dispatchEvent(
110
- new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true, bubbles: true }),
111
- );
112
- // Selection should remain unchanged
113
- expect(tabs[0].getAttribute('aria-selected')).toBe('true');
114
- });
115
-
116
- it('should return undefined currentTarget when keydown targets the tablist itself (line 411)', () => {
117
- // Dispatching directly on the tablist — target does not match [role="tab"]
118
- tablist.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
119
- // No navigation happens, first tab remains selected
120
- expect(tabs[0].getAttribute('aria-selected')).toBe('true');
121
- });
122
-
123
- it('should update tabIndex after requestAnimationFrame runs', async () => {
124
- tabs[1].click();
125
- await vi.advanceTimersByTimeAsync(16); // flush rAF (lines 314-318)
126
- expect(tabs[1].tabIndex).toBe(0);
127
- expect(tabs[0].tabIndex).toBe(-1);
128
- });
129
-
130
- it('should navigate backward when pressing ArrowRight in RTL (branch 63 prevKey=ArrowRight)', () => {
131
- locale.set('ar'); // RTL locale to set _isRTL=true in activate()
132
- // In RTL prevKey='ArrowRight'; press from tabs[2] → backward to tabs[1]
133
- tabs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
134
- expect(tabs[1].getAttribute('aria-selected')).toBe('true');
135
- locale.set('en'); // Reset locale to LTR
136
- });
137
-
138
- it('should navigate forward when pressing ArrowLeft in RTL (branch 65 nextKey=ArrowLeft)', () => {
139
- locale.set('ar'); // RTL locale to set _isRTL=true in activate()
140
- // In RTL nextKey='ArrowLeft'; press from tabs[0] → forward to tabs[1]
141
- tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
142
- expect(tabs[1].getAttribute('aria-selected')).toBe('true');
143
- locale.set('en'); // Reset locale to LTR
144
- });
145
- });
146
-
147
- describe('Group - listbox', () => {
148
- /** @type {HTMLElement} */
149
- let listbox;
150
- /** @type {HTMLElement[]} */
151
- let options;
152
-
153
- beforeEach(async () => {
154
- vi.useFakeTimers();
155
- listbox = document.createElement('div');
156
- listbox.setAttribute('role', 'listbox');
157
- options = ['Option A', 'Option B', 'Option C'].map((label) => {
158
- const opt = document.createElement('div');
159
-
160
- opt.setAttribute('role', 'option');
161
- opt.textContent = label;
162
- listbox.appendChild(opt);
163
-
164
- return opt;
165
- });
166
- document.body.appendChild(listbox);
167
- activateGroup()(listbox);
168
- await vi.advanceTimersByTimeAsync(150);
169
- });
170
-
171
- afterEach(() => {
172
- listbox.remove();
173
- vi.useRealTimers();
174
- });
175
-
176
- it('should not pre-select any option (selectFirst=false for listbox)', () => {
177
- options.forEach((opt) => {
178
- expect(opt.getAttribute('aria-selected')).toBe('false');
179
- });
180
- });
181
-
182
- it('should set tabIndex=-1 on all options', () => {
183
- options.forEach((opt) => {
184
- expect(opt.tabIndex).toBe(-1);
185
- });
186
- });
187
-
188
- it('should select option when clicking on a child element inside it (target.closest branch 38)', () => {
189
- const span = document.createElement('span');
190
-
191
- span.textContent = 'Inner';
192
- options[0].appendChild(span);
193
- span.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
194
- expect(options[0].getAttribute('aria-selected')).toBe('true');
195
- span.remove();
196
- });
197
-
198
- it('should not select option when right-clicked (button !== 0, branch 39 early return)', () => {
199
- options[0].dispatchEvent(new MouseEvent('click', { button: 2, bubbles: true }));
200
- expect(options[0].getAttribute('aria-selected')).toBe('false');
201
- });
202
-
203
- it('should not select when clicking the listbox container itself (!newTarget, branch 39)', () => {
204
- listbox.dispatchEvent(new MouseEvent('click', { button: 0 }));
205
- options.forEach((opt) => expect(opt.getAttribute('aria-selected')).toBe('false'));
206
- });
207
- });
208
-
209
- describe('Group - onUpdate (search filter)', () => {
210
- /** @type {HTMLElement} */
211
- let listbox;
212
- /** @type {HTMLElement[]} */
213
- let options;
214
- /** @type {any} */
215
- let group;
216
-
217
- beforeEach(async () => {
218
- vi.useFakeTimers();
219
- listbox = document.createElement('div');
220
- listbox.setAttribute('role', 'listbox');
221
- options = [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }].map(({ label }) => {
222
- const opt = document.createElement('div');
223
-
224
- opt.setAttribute('role', 'option');
225
- opt.dataset.label = label;
226
- opt.textContent = label;
227
- listbox.appendChild(opt);
228
-
229
- return opt;
230
- });
231
- document.body.appendChild(listbox);
232
- group = new Group(listbox);
233
- await vi.advanceTimersByTimeAsync(150);
234
- });
235
-
236
- afterEach(() => {
237
- listbox.remove();
238
- vi.useRealTimers();
239
- });
240
-
241
- it('should dispatch Filter event with match count when search terms are applied', () => {
242
- const filterEvents = /** @type {CustomEvent[]} */ ([]);
243
-
244
- listbox.addEventListener('Filter', (e) => filterEvents.push(/** @type {CustomEvent} */ (e)));
245
- group.onUpdate({ searchTerms: 'an' }); // matches Banana
246
- expect(filterEvents.length).toBeGreaterThan(0);
247
- expect(filterEvents[filterEvents.length - 1].detail.matched).toBe(1);
248
- expect(filterEvents[filterEvents.length - 1].detail.total).toBe(3);
249
- });
250
-
251
- it('should dispatch Toggle event on each item during filter', () => {
252
- const toggledItems = /** @type {string[]} */ ([]);
253
-
254
- options.forEach((opt) => {
255
- opt.addEventListener('Toggle', (e) => {
256
- if (/** @type {CustomEvent} */ (e).detail.hidden) {
257
- toggledItems.push(opt.dataset.label ?? '');
258
- }
259
- });
260
- });
261
- group.onUpdate({ searchTerms: 'apple' });
262
- // Banana and Cherry should be toggled hidden
263
- expect(toggledItems).toContain('Banana');
264
- expect(toggledItems).toContain('Cherry');
265
- expect(toggledItems).not.toContain('Apple');
266
- });
267
-
268
- it('should show all items when search terms are cleared', () => {
269
- group.onUpdate({ searchTerms: 'apple' });
270
-
271
- const filterEvents = /** @type {CustomEvent[]} */ ([]);
272
-
273
- listbox.addEventListener('Filter', (e) => filterEvents.push(/** @type {CustomEvent} */ (e)));
274
- group.onUpdate({ searchTerms: '' });
275
- expect(filterEvents[filterEvents.length - 1].detail.matched).toBe(3);
276
- });
277
- });
278
-
279
- describe('Group - tablist keyboard (Enter and Space)', () => {
280
- /** @type {HTMLElement} */
281
- let tablist;
282
- /** @type {HTMLElement[]} */
283
- let tabs;
284
-
285
- beforeEach(async () => {
286
- vi.useFakeTimers();
287
- tablist = document.createElement('div');
288
- tablist.setAttribute('role', 'tablist');
289
- tabs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
290
- const tab = document.createElement('div');
291
-
292
- tab.setAttribute('role', 'tab');
293
- tab.textContent = label;
294
- tablist.appendChild(tab);
295
-
296
- return tab;
297
- });
298
- document.body.appendChild(tablist);
299
- activateGroup()(tablist);
300
- await vi.advanceTimersByTimeAsync(150);
301
- });
302
-
303
- afterEach(() => {
304
- tablist.remove();
305
- vi.useRealTimers();
306
- });
307
-
308
- it('should select a tab when Space key is pressed on it', () => {
309
- tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
310
- expect(tabs[1].getAttribute('aria-selected')).toBe('true');
311
- expect(tabs[0].getAttribute('aria-selected')).toBe('false');
312
- });
313
-
314
- it('should trigger a click on the current tab when Enter is pressed', () => {
315
- let clicked = false;
316
-
317
- tabs[1].addEventListener('click', () => {
318
- clicked = true;
319
- });
320
- tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
321
- expect(clicked).toBe(true);
322
- });
323
- });
324
-
325
- describe('Group - tablist with aria-controls panels', () => {
326
- /** @type {HTMLElement} */
327
- let tablist;
328
- /** @type {HTMLElement[]} */
329
- let tabs;
330
- /** @type {HTMLElement[]} */
331
- let panels;
332
-
333
- beforeEach(async () => {
334
- vi.useFakeTimers();
335
- panels = ['panel-a', 'panel-b', 'panel-c'].map((id) => {
336
- const panel = document.createElement('div');
337
-
338
- panel.id = id;
339
- document.body.appendChild(panel);
340
-
341
- return panel;
342
- });
343
- tablist = document.createElement('div');
344
- tablist.setAttribute('role', 'tablist');
345
- tabs = panels.map((panel, i) => {
346
- const tab = document.createElement('div');
347
-
348
- tab.setAttribute('role', 'tab');
349
- tab.setAttribute('aria-controls', panel.id);
350
- tab.textContent = `Tab ${i + 1}`;
351
- tablist.appendChild(tab);
352
-
353
- return tab;
354
- });
355
- document.body.appendChild(tablist);
356
- activateGroup()(tablist);
357
- // also covers scrollIntoView setTimeout(300) in activate()
358
- await vi.advanceTimersByTimeAsync(500);
359
- });
360
-
361
- afterEach(() => {
362
- tablist.remove();
363
- panels.forEach((p) => p.remove());
364
- vi.useRealTimers();
365
- });
366
-
367
- it('should set aria-hidden=false on the first panel after activation', () => {
368
- expect(panels[0].getAttribute('aria-hidden')).toBe('false');
369
- });
370
-
371
- it('should set aria-hidden=true on non-selected panels after activation', () => {
372
- expect(panels[1].getAttribute('aria-hidden')).toBe('true');
373
- expect(panels[2].getAttribute('aria-hidden')).toBe('true');
374
- });
375
-
376
- it('should set inert=false on the first panel and inert=true on others', () => {
377
- expect(panels[0].inert).toBe(false);
378
- expect(panels[1].inert).toBe(true);
379
- expect(panels[2].inert).toBe(true);
380
- });
381
-
382
- it('should set aria-labelledby on panels to match tab IDs', () => {
383
- expect(panels[0].getAttribute('aria-labelledby')).toBe(tabs[0].id);
384
- });
385
-
386
- it('should switch panel visibility when a different tab is selected', () => {
387
- tabs[1].click();
388
- expect(panels[1].getAttribute('aria-hidden')).toBe('false');
389
- expect(panels[0].getAttribute('aria-hidden')).toBe('true');
390
- });
391
-
392
- it('should update inert on panels when a different tab is selected', () => {
393
- tabs[1].click();
394
- expect(panels[1].inert).toBe(false);
395
- expect(panels[0].inert).toBe(true);
396
- });
397
-
398
- it('should fire scrollIntoView on selected panel after 300ms (selectTarget)', async () => {
399
- tabs[1].click();
400
- // covers setTimeout(300) in selectTarget (lines 335-342, 352-355)
401
- await vi.advanceTimersByTimeAsync(400);
402
- // No crash; panel still correct
403
- expect(panels[1].getAttribute('aria-hidden')).toBe('false');
404
- });
405
- });
406
-
407
- describe('Group - disabled and read-only', () => {
408
- /** @type {HTMLElement} */
409
- let listbox;
410
- /** @type {HTMLElement[]} */
411
- let options;
412
-
413
- beforeEach(async () => {
414
- vi.useFakeTimers();
415
- listbox = document.createElement('div');
416
- listbox.setAttribute('role', 'listbox');
417
- options = ['Option A', 'Option B'].map((label) => {
418
- const opt = document.createElement('div');
419
-
420
- opt.setAttribute('role', 'option');
421
- opt.textContent = label;
422
- listbox.appendChild(opt);
423
-
424
- return opt;
425
- });
426
- document.body.appendChild(listbox);
427
- activateGroup()(listbox);
428
- await vi.advanceTimersByTimeAsync(150);
429
- });
430
-
431
- afterEach(() => {
432
- listbox.remove();
433
- vi.useRealTimers();
434
- });
435
-
436
- it('should not select option when parent listbox is aria-disabled', () => {
437
- listbox.setAttribute('aria-disabled', 'true');
438
- options[0].click();
439
- expect(options[0].getAttribute('aria-selected')).toBe('false');
440
- });
441
-
442
- it('should not select option when parent listbox is aria-readonly', () => {
443
- listbox.setAttribute('aria-readonly', 'true');
444
- options[0].click();
445
- expect(options[0].getAttribute('aria-selected')).toBe('false');
446
- });
447
- });
448
-
449
- describe('Group - multiselect listbox', () => {
450
- /** @type {HTMLElement} */
451
- let listbox;
452
- /** @type {HTMLElement[]} */
453
- let options;
454
-
455
- beforeEach(async () => {
456
- vi.useFakeTimers();
457
- listbox = document.createElement('div');
458
- listbox.setAttribute('role', 'listbox');
459
- listbox.setAttribute('aria-multiselectable', 'true');
460
- options = ['Option A', 'Option B', 'Option C'].map((label) => {
461
- const opt = document.createElement('div');
462
-
463
- opt.setAttribute('role', 'option');
464
- opt.textContent = label;
465
- listbox.appendChild(opt);
466
-
467
- return opt;
468
- });
469
- document.body.appendChild(listbox);
470
- activateGroup()(listbox);
471
- await vi.advanceTimersByTimeAsync(150);
472
- });
473
-
474
- afterEach(() => {
475
- listbox.remove();
476
- vi.useRealTimers();
477
- });
478
-
479
- it('should select option on click', () => {
480
- options[0].click();
481
- expect(options[0].getAttribute('aria-selected')).toBe('true');
482
- });
483
-
484
- it('should deselect option on second click (toggle)', () => {
485
- options[0].click();
486
- options[0].click();
487
- expect(options[0].getAttribute('aria-selected')).toBe('false');
488
- });
489
-
490
- it('should independently select multiple options', () => {
491
- options[0].click();
492
- options[2].click();
493
- expect(options[0].getAttribute('aria-selected')).toBe('true');
494
- expect(options[1].getAttribute('aria-selected')).toBe('false');
495
- expect(options[2].getAttribute('aria-selected')).toBe('true');
496
- });
497
-
498
- it('should do nothing when Space pressed with no focused element (branch 47 false path)', () => {
499
- // No navigation has occurred → no .focused element → currentTarget = undefined
500
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
501
- options.forEach((opt) => expect(opt.getAttribute('aria-selected')).toBe('false'));
502
- });
503
-
504
- it('should select multiselect option via Space keydown (selectByKeydown, branch 22 count[3])', () => {
505
- // ArrowDown gives options[0] the .focused class without selecting (multiselect ignores arrow)
506
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
507
- expect(options[0].classList.contains('focused')).toBe(true);
508
- expect(options[0].getAttribute('aria-selected')).toBe('false');
509
- // Space selects via multiSelect && isTarget && selectByKeydown path
510
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
511
- expect(options[0].getAttribute('aria-selected')).toBe('true');
512
- });
513
- });
514
-
515
- describe('Group - listbox keyboard navigation', () => {
516
- /** @type {HTMLElement} */
517
- let listbox;
518
- /** @type {HTMLElement[]} */
519
- let options;
520
-
521
- beforeEach(async () => {
522
- vi.useFakeTimers();
523
- listbox = document.createElement('div');
524
- listbox.setAttribute('role', 'listbox');
525
- options = ['Option A', 'Option B', 'Option C'].map((label) => {
526
- const opt = document.createElement('div');
527
-
528
- opt.setAttribute('role', 'option');
529
- opt.textContent = label;
530
- listbox.appendChild(opt);
531
-
532
- return opt;
533
- });
534
- document.body.appendChild(listbox);
535
- activateGroup()(listbox);
536
- await vi.advanceTimersByTimeAsync(150);
537
- });
538
-
539
- afterEach(() => {
540
- listbox.remove();
541
- vi.useRealTimers();
542
- });
543
-
544
- it('should select the first option on ArrowDown when none is focused', () => {
545
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
546
- expect(options[0].getAttribute('aria-selected')).toBe('true');
547
- });
548
-
549
- it('should move to the next option on ArrowDown', () => {
550
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
551
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
552
- expect(options[1].getAttribute('aria-selected')).toBe('true');
553
- expect(options[0].getAttribute('aria-selected')).toBe('false');
554
- });
555
-
556
- it('should wrap to the first option on ArrowDown from last', () => {
557
- // Navigate to last option
558
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
559
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
560
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
561
- // options[2] should be selected now; ArrowDown wraps to first
562
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
563
- expect(options[0].getAttribute('aria-selected')).toBe('true');
564
- });
565
-
566
- it('should select the last option on ArrowUp when none is focused', () => {
567
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
568
- expect(options[2].getAttribute('aria-selected')).toBe('true');
569
- });
570
-
571
- it('should move to the previous option on ArrowUp', () => {
572
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
573
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
574
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
575
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
576
- expect(options[1].getAttribute('aria-selected')).toBe('true');
577
- });
578
-
579
- it('should add focused class to selected option', () => {
580
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
581
- expect(options[0].classList.contains('focused')).toBe(true);
582
- });
583
-
584
- it('should move focused class when navigating', () => {
585
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
586
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
587
- expect(options[1].classList.contains('focused')).toBe(true);
588
- expect(options[0].classList.contains('focused')).toBe(false);
589
- });
590
- });
591
-
592
- describe('Group - menu with menuitemradio', () => {
593
- /** @type {HTMLElement} */
594
- let menu;
595
- /** @type {HTMLElement[]} */
596
- let radioItems;
597
-
598
- beforeEach(async () => {
599
- vi.useFakeTimers();
600
- menu = document.createElement('div');
601
- menu.setAttribute('role', 'menu');
602
- radioItems = ['Small', 'Medium', 'Large'].map((label) => {
603
- const item = document.createElement('div');
604
-
605
- item.setAttribute('role', 'menuitemradio');
606
- item.textContent = label;
607
- menu.appendChild(item);
608
-
609
- return item;
610
- });
611
- document.body.appendChild(menu);
612
- activateGroup()(menu);
613
- await vi.advanceTimersByTimeAsync(150);
614
- });
615
-
616
- afterEach(() => {
617
- menu.remove();
618
- vi.useRealTimers();
619
- });
620
-
621
- it('should select the clicked radio item', () => {
622
- radioItems[0].click();
623
- expect(radioItems[0].getAttribute('aria-checked')).toBe('true');
624
- });
625
-
626
- it('should deselect other radio items when one is selected', () => {
627
- radioItems[0].click();
628
- expect(radioItems[1].getAttribute('aria-checked')).toBe('false');
629
- expect(radioItems[2].getAttribute('aria-checked')).toBe('false');
630
- });
631
-
632
- it('should switch selection when another radio item is clicked', () => {
633
- radioItems[0].click();
634
- radioItems[1].click();
635
- expect(radioItems[1].getAttribute('aria-checked')).toBe('true');
636
- expect(radioItems[0].getAttribute('aria-checked')).toBe('false');
637
- });
638
- });
639
-
640
- describe('Group - menu with menuitemcheckbox', () => {
641
- /** @type {HTMLElement} */
642
- let menu;
643
- /** @type {HTMLElement[]} */
644
- let checkItems;
645
-
646
- beforeEach(async () => {
647
- vi.useFakeTimers();
648
- menu = document.createElement('div');
649
- menu.setAttribute('role', 'menu');
650
- checkItems = ['Bold', 'Italic', 'Underline'].map((label) => {
651
- const item = document.createElement('div');
652
-
653
- item.setAttribute('role', 'menuitemcheckbox');
654
- item.textContent = label;
655
- menu.appendChild(item);
656
-
657
- return item;
658
- });
659
- document.body.appendChild(menu);
660
- activateGroup()(menu);
661
- await vi.advanceTimersByTimeAsync(150);
662
- });
663
-
664
- afterEach(() => {
665
- menu.remove();
666
- vi.useRealTimers();
667
- });
668
-
669
- it('should check a menuitemcheckbox on click', () => {
670
- checkItems[0].click();
671
- expect(checkItems[0].getAttribute('aria-checked')).toBe('true');
672
- });
673
-
674
- it('should uncheck a menuitemcheckbox on second click', () => {
675
- checkItems[0].click();
676
- checkItems[0].click();
677
- expect(checkItems[0].getAttribute('aria-checked')).toBe('false');
678
- });
679
-
680
- it('should independently toggle each checkbox', () => {
681
- checkItems[0].click();
682
- checkItems[1].click();
683
- expect(checkItems[0].getAttribute('aria-checked')).toBe('true');
684
- expect(checkItems[1].getAttribute('aria-checked')).toBe('true');
685
- expect(checkItems[2].getAttribute('aria-checked')).toBe('false');
686
- });
687
- });
688
-
689
- describe('Group - radiogroup keyboard navigation', () => {
690
- /** @type {HTMLElement} */
691
- let radiogroup;
692
- /** @type {HTMLElement[]} */
693
- let radios;
694
-
695
- beforeEach(async () => {
696
- vi.useFakeTimers();
697
- radiogroup = document.createElement('div');
698
- radiogroup.setAttribute('role', 'radiogroup');
699
- radios = ['A', 'B', 'C'].map((label) => {
700
- const radio = document.createElement('div');
701
-
702
- radio.setAttribute('role', 'radio');
703
- radio.textContent = label;
704
- radiogroup.appendChild(radio);
705
-
706
- return radio;
707
- });
708
- document.body.appendChild(radiogroup);
709
- activateGroup()(radiogroup);
710
- await vi.advanceTimersByTimeAsync(150);
711
- radios[0].click(); // select first radio
712
- });
713
-
714
- afterEach(() => {
715
- radiogroup.remove();
716
- vi.useRealTimers();
717
- });
718
-
719
- it('should navigate to the next radio on ArrowRight', () => {
720
- radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
721
- expect(radios[1].getAttribute('aria-checked')).toBe('true');
722
- expect(radios[0].getAttribute('aria-checked')).toBe('false');
723
- });
724
-
725
- it('should trigger click on the radio element during keydown navigation (line 304)', () => {
726
- let clickCount = 0;
727
-
728
- radios[1].addEventListener('click', () => {
729
- clickCount += 1;
730
- });
731
- radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
732
- expect(clickCount).toBe(1);
733
- });
734
-
735
- it('should navigate to the previous radio on ArrowLeft', () => {
736
- radios[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
737
- radios[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
738
- expect(radios[0].getAttribute('aria-checked')).toBe('true');
739
- expect(radios[1].getAttribute('aria-checked')).toBe('false');
740
- });
741
- });
742
-
743
- describe('Group - grid listbox navigation', () => {
744
- /** @type {HTMLElement} */
745
- let listbox;
746
- /** @type {HTMLElement[]} */
747
- let options;
748
-
749
- beforeEach(async () => {
750
- vi.useFakeTimers();
751
- listbox = document.createElement('div');
752
- listbox.setAttribute('role', 'listbox');
753
- listbox.classList.add('grid');
754
- options = Array.from({ length: 6 }, (_, i) => {
755
- const opt = document.createElement('div');
756
-
757
- opt.setAttribute('role', 'option');
758
- opt.textContent = `Item ${i + 1}`;
759
- listbox.appendChild(opt);
760
-
761
- return opt;
762
- });
763
- document.body.appendChild(listbox);
764
- // Mock clientWidths so colCount = Math.floor(300/100) = 3
765
- Object.defineProperty(listbox, 'clientWidth', {
766
- configurable: true,
767
- get: () => 300,
768
- });
769
- options.forEach((opt) => {
770
- Object.defineProperty(opt, 'clientWidth', {
771
- configurable: true,
772
- get: () => 100,
773
- });
774
- });
775
- activateGroup()(listbox);
776
- await vi.advanceTimersByTimeAsync(150);
777
- options[0].click(); // add .focused class to options[0]
778
- });
779
-
780
- afterEach(() => {
781
- listbox.remove();
782
- vi.useRealTimers();
783
- });
784
-
785
- it('should navigate down by colCount on ArrowDown', () => {
786
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
787
- // options[0] (index 0) + colCount 3 = options[3]
788
- expect(options[3].getAttribute('aria-selected')).toBe('true');
789
- expect(options[0].getAttribute('aria-selected')).toBe('false');
790
- });
791
-
792
- it('should navigate right on ArrowRight', () => {
793
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
794
- expect(options[1].getAttribute('aria-selected')).toBe('true');
795
- });
796
-
797
- it('should navigate left on ArrowLeft', () => {
798
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
799
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
800
- expect(options[0].getAttribute('aria-selected')).toBe('true');
801
- });
802
-
803
- it('should navigate up by colCount on ArrowUp', () => {
804
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
805
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
806
- expect(options[0].getAttribute('aria-selected')).toBe('true');
807
- });
808
-
809
- it('should skip a disabled option during grid navigation', () => {
810
- options[3].setAttribute('aria-disabled', 'true');
811
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
812
- // options[3] is disabled → newTarget set to undefined → no navigation
813
- expect(options[0].getAttribute('aria-selected')).toBe('true');
814
- expect(options[3].getAttribute('aria-selected')).not.toBe('true');
815
- });
816
-
817
- it('should navigate grid forward (index+1) on ArrowLeft in RTL (branch 56)', () => {
818
- // Navigate to options[1] first
819
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
820
- expect(options[1].getAttribute('aria-selected')).toBe('true');
821
- // In RTL, ArrowLeft moves forward: index 1 + 1 = options[2]
822
- locale.set('ar'); // RTL locale to set _isRTL=true in activate()
823
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
824
- expect(options[2].getAttribute('aria-selected')).toBe('true');
825
- locale.set('en'); // Reset locale to LTR
826
- });
827
-
828
- it('should navigate grid backward (index-1) on ArrowRight in RTL (branch 59)', () => {
829
- // Navigate to options[1]
830
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
831
- expect(options[1].getAttribute('aria-selected')).toBe('true');
832
- // In RTL, ArrowRight moves backward: index 1 - 1 = options[0]
833
- locale.set('ar'); // RTL locale to set _isRTL=true in activate()
834
- listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
835
- expect(options[0].getAttribute('aria-selected')).toBe('true');
836
- locale.set('en'); // Reset locale to LTR
837
- });
838
- });
839
-
840
- describe('Group - scrollIntoView catch fallback in activate()', () => {
841
- /** @type {HTMLElement} */
842
- let tablist;
843
- /** @type {HTMLElement[]} */
844
- let panels;
845
-
846
- beforeEach(async () => {
847
- vi.useFakeTimers();
848
- panels = ['panel-catch-a', 'panel-catch-b'].map((id) => {
849
- const panel = document.createElement('div');
850
-
851
- panel.id = id;
852
- // Make scrollIntoView(options) throw but scrollIntoView(boolean) succeed
853
-
854
- panel.scrollIntoView = (arg) => {
855
- if (arg && typeof arg === 'object') {
856
- throw new Error('not supported');
857
- }
858
- };
859
-
860
- document.body.appendChild(panel);
861
-
862
- return panel;
863
- });
864
- tablist = document.createElement('div');
865
- tablist.setAttribute('role', 'tablist');
866
- panels.forEach((panel, i) => {
867
- const tab = document.createElement('div');
868
-
869
- tab.setAttribute('role', 'tab');
870
- tab.setAttribute('aria-controls', panel.id);
871
- tab.textContent = `Tab ${i + 1}`;
872
- tablist.appendChild(tab);
873
- });
874
- document.body.appendChild(tablist);
875
- activateGroup()(tablist);
876
- // flush sleep(100) + setTimeout(300) in activate()
877
- await vi.advanceTimersByTimeAsync(500);
878
- });
879
-
880
- afterEach(() => {
881
- tablist.remove();
882
- panels.forEach((p) => p.remove());
883
- vi.useRealTimers();
884
- });
885
-
886
- it('should fall back to scrollIntoView(true) when options form throws during activate()', () => {
887
- // If the catch branch executed without throwing, the panel is set up correctly
888
- expect(panels[0].getAttribute('aria-hidden')).toBe('false');
889
- });
890
- });
891
-
892
- describe('Group - scrollIntoView catch fallback in selectTarget()', () => {
893
- /** @type {HTMLElement} */
894
- let tablist;
895
- /** @type {HTMLElement[]} */
896
- let tabs;
897
- /** @type {HTMLElement[]} */
898
- let panels;
899
-
900
- beforeEach(async () => {
901
- vi.useFakeTimers();
902
- panels = ['panel-sel-a', 'panel-sel-b'].map((id) => {
903
- const panel = document.createElement('div');
904
-
905
- panel.id = id;
906
-
907
- panel.scrollIntoView = (arg) => {
908
- if (arg && typeof arg === 'object') {
909
- throw new Error('not supported');
910
- }
911
- };
912
-
913
- document.body.appendChild(panel);
914
-
915
- return panel;
916
- });
917
- tablist = document.createElement('div');
918
- tablist.setAttribute('role', 'tablist');
919
- tabs = panels.map((panel, i) => {
920
- const tab = document.createElement('div');
921
-
922
- tab.setAttribute('role', 'tab');
923
- tab.setAttribute('aria-controls', panel.id);
924
- tab.textContent = `Tab ${i + 1}`;
925
- // Also override the tab element's scrollIntoView to throw on options form
926
-
927
- tab.scrollIntoView = (arg) => {
928
- if (arg && typeof arg === 'object') {
929
- throw new Error('not supported');
930
- }
931
- };
932
-
933
- tablist.appendChild(tab);
934
-
935
- return tab;
936
- });
937
- document.body.appendChild(tablist);
938
- activateGroup()(tablist);
939
- await vi.advanceTimersByTimeAsync(500);
940
- });
941
-
942
- afterEach(() => {
943
- tablist.remove();
944
- panels.forEach((p) => p.remove());
945
- vi.useRealTimers();
946
- });
947
-
948
- it('should fall back to scrollIntoView(true) on controlTarget and element when options form throws', async () => {
949
- // Switch to the second tab to trigger selectTarget() catch branches
950
- tabs[1].click();
951
- await vi.advanceTimersByTimeAsync(400);
952
- // Verify the panel switched correctly despite the scrollIntoView error
953
- expect(panels[1].getAttribute('aria-hidden')).toBe('false');
954
- expect(panels[0].getAttribute('aria-hidden')).toBe('true');
955
- });
956
- });
957
-
958
- describe('Group - tablist with pre-selected second tab (branch 7 defaultSelected)', () => {
959
- it('should preserve pre-selected tab and use defaultSelected ternary path', async () => {
960
- vi.useFakeTimers();
961
-
962
- const tl = document.createElement('div');
963
-
964
- tl.setAttribute('role', 'tablist');
965
-
966
- const tbs = ['Tab 1', 'Tab 2', 'Tab 3'].map((label) => {
967
- const tab = document.createElement('div');
968
-
969
- tab.setAttribute('role', 'tab');
970
- tab.textContent = label;
971
- tl.appendChild(tab);
972
-
973
- return tab;
974
- });
975
-
976
- // Pre-select the second tab before activation
977
- tbs[1].setAttribute('aria-selected', 'true');
978
- document.body.appendChild(tl);
979
- activateGroup()(tl);
980
- await vi.advanceTimersByTimeAsync(150);
981
-
982
- // defaultSelected = tbs[1]; the ternary `defaultSelected ? element === defaultSelected : ...`
983
- // evaluates to true for tbs[1] → branch 7 count[0] is hit
984
- expect(tbs[1].getAttribute('aria-selected')).toBe('true');
985
- expect(tbs[0].getAttribute('aria-selected')).toBe('false');
986
- tl.remove();
987
- vi.useRealTimers();
988
- });
989
- });
990
-
991
- describe('Group - menu with nested radio groups (branch 16 cross-group filter)', () => {
992
- it('should not affect radio items in a different group when selecting one', async () => {
993
- vi.useFakeTimers();
994
-
995
- const menu = document.createElement('div');
996
-
997
- menu.setAttribute('role', 'menu');
998
-
999
- const group1 = document.createElement('div');
1000
-
1001
- group1.setAttribute('role', 'group');
1002
-
1003
- const radioA = document.createElement('div');
1004
-
1005
- radioA.setAttribute('role', 'menuitemradio');
1006
- radioA.textContent = 'A';
1007
- group1.appendChild(radioA);
1008
-
1009
- const group2 = document.createElement('div');
1010
-
1011
- group2.setAttribute('role', 'group');
1012
-
1013
- const radioB = document.createElement('div');
1014
-
1015
- radioB.setAttribute('role', 'menuitemradio');
1016
- radioB.textContent = 'B';
1017
- group2.appendChild(radioB);
1018
-
1019
- menu.appendChild(group1);
1020
- menu.appendChild(group2);
1021
- document.body.appendChild(menu);
1022
- activateGroup()(menu);
1023
- await vi.advanceTimersByTimeAsync(150);
1024
-
1025
- // Click radioA — radioB is in a different group, so line 267 early return filters it out
1026
- radioA.click();
1027
- expect(radioA.getAttribute('aria-checked')).toBe('true');
1028
- // radioB was skipped (early return at line 267) — remains unaffected
1029
- expect(radioB.getAttribute('aria-checked')).toBe('false');
1030
- menu.remove();
1031
- vi.useRealTimers();
1032
- });
1033
- });
1034
-
1035
- describe('Group - onClick with clickToSelect disabled (branch 39 !clickToSelect)', () => {
1036
- it('should not select option when clickToSelect is false', async () => {
1037
- vi.useFakeTimers();
1038
-
1039
- const lb = document.createElement('div');
1040
-
1041
- lb.setAttribute('role', 'listbox');
1042
-
1043
- const opt = document.createElement('div');
1044
-
1045
- opt.setAttribute('role', 'option');
1046
- opt.textContent = 'Option';
1047
- lb.appendChild(opt);
1048
- document.body.appendChild(lb);
1049
- activateGroup({ clickToSelect: false })(lb);
1050
- await vi.advanceTimersByTimeAsync(150);
1051
- opt.click();
1052
- // !clickToSelect → early return in onClick → aria-selected stays false
1053
- expect(opt.getAttribute('aria-selected')).toBe('false');
1054
- lb.remove();
1055
- vi.useRealTimers();
1056
- });
1057
- });
1058
-
1059
- describe('Group - grid listbox with no initial focus (branch 49 currentTarget?...: -1)', () => {
1060
- it('should use index -1 as fallback when no item is focused in grid', async () => {
1061
- vi.useFakeTimers();
1062
-
1063
- const gridListbox = document.createElement('div');
1064
-
1065
- gridListbox.setAttribute('role', 'listbox');
1066
- gridListbox.classList.add('grid');
1067
-
1068
- const gridOptions = Array.from({ length: 6 }, (_, i) => {
1069
- const opt = document.createElement('div');
1070
-
1071
- opt.setAttribute('role', 'option');
1072
- opt.textContent = `Item ${i + 1}`;
1073
- gridListbox.appendChild(opt);
1074
-
1075
- return opt;
1076
- });
1077
-
1078
- document.body.appendChild(gridListbox);
1079
- Object.defineProperty(gridListbox, 'clientWidth', { configurable: true, get: () => 300 });
1080
- gridOptions.forEach((opt) => {
1081
- Object.defineProperty(opt, 'clientWidth', { configurable: true, get: () => 100 });
1082
- });
1083
- activateGroup()(gridListbox);
1084
- await vi.advanceTimersByTimeAsync(150);
1085
-
1086
- // Press ArrowDown with no focused element → currentTarget=undefined → index=-1
1087
- // -1 + colCount(3) = 2 → gridOptions[2]
1088
- gridListbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
1089
- expect(gridOptions[2].getAttribute('aria-selected')).toBe('true');
1090
- gridListbox.remove();
1091
- vi.useRealTimers();
1092
- });
1093
- });
1094
-
1095
- describe('Group - onUpdate with querySelector(.label) and textContent fallbacks (branch 75)', () => {
1096
- it('should use .label child textContent and plain textContent as search sources', async () => {
1097
- vi.useFakeTimers();
1098
-
1099
- const listbox = document.createElement('div');
1100
-
1101
- listbox.setAttribute('role', 'listbox');
1102
-
1103
- // opt1: has .label span child, no dataset attrs → querySelector('.label').textContent path
1104
- const opt1 = document.createElement('div');
1105
-
1106
- opt1.setAttribute('role', 'option');
1107
-
1108
- const labelSpan = document.createElement('span');
1109
-
1110
- labelSpan.className = 'label';
1111
- labelSpan.textContent = 'Apple';
1112
- opt1.appendChild(labelSpan);
1113
-
1114
- // opt2: plain textContent only, no dataset, no .label child → member.textContent path
1115
- const opt2 = document.createElement('div');
1116
-
1117
- opt2.setAttribute('role', 'option');
1118
- opt2.textContent = 'Banana';
1119
-
1120
- listbox.appendChild(opt1);
1121
- listbox.appendChild(opt2);
1122
- document.body.appendChild(listbox);
1123
-
1124
- const group = new Group(listbox);
1125
-
1126
- await vi.advanceTimersByTimeAsync(150);
1127
-
1128
- /** @type {any} */
1129
- let filterDetail = null;
1130
-
1131
- listbox.addEventListener('Filter', (e) => {
1132
- filterDetail = /** @type {any} */ (e).detail;
1133
- });
1134
-
1135
- // Searching 'apple' matches opt1 (via .label span) but not opt2 (via textContent 'Banana')
1136
- group.onUpdate({ searchTerms: 'apple' });
1137
- expect(filterDetail.matched).toBe(1);
1138
- expect(filterDetail.total).toBe(2);
1139
-
1140
- listbox.remove();
1141
- vi.useRealTimers();
1142
- });
1143
- });
1144
-
1145
- describe('Group - destroy', () => {
1146
- it('should remove click and keydown listeners after destroy()', async () => {
1147
- vi.useFakeTimers();
1148
-
1149
- const listbox = document.createElement('div');
1150
-
1151
- listbox.setAttribute('role', 'listbox');
1152
-
1153
- const opt = document.createElement('div');
1154
-
1155
- opt.setAttribute('role', 'option');
1156
- opt.textContent = 'Option';
1157
- listbox.appendChild(opt);
1158
- document.body.appendChild(listbox);
1159
-
1160
- const group = new Group(listbox);
1161
-
1162
- await vi.advanceTimersByTimeAsync(150);
1163
-
1164
- // Click selects before destroy
1165
- opt.click();
1166
- expect(opt.getAttribute('aria-selected')).toBe('true');
1167
-
1168
- group.destroy();
1169
-
1170
- // Reset and click again — listener should be gone
1171
- opt.setAttribute('aria-selected', 'false');
1172
- opt.click();
1173
- expect(opt.getAttribute('aria-selected')).toBe('false');
1174
-
1175
- listbox.remove();
1176
- vi.useRealTimers();
1177
- });
1178
- });
1179
-
1180
- describe('activateGroup - attachment cleanup', () => {
1181
- it('should return a cleanup function that calls destroy()', async () => {
1182
- vi.useFakeTimers();
1183
-
1184
- const listbox = document.createElement('div');
1185
-
1186
- listbox.setAttribute('role', 'listbox');
1187
-
1188
- const opt = document.createElement('div');
1189
-
1190
- opt.setAttribute('role', 'option');
1191
- opt.textContent = 'Option';
1192
- listbox.appendChild(opt);
1193
- document.body.appendChild(listbox);
1194
-
1195
- const cleanup = activateGroup()(listbox);
1196
-
1197
- await vi.advanceTimersByTimeAsync(150);
1198
-
1199
- // Click selects before cleanup
1200
- opt.click();
1201
- expect(opt.getAttribute('aria-selected')).toBe('true');
1202
-
1203
- // Run cleanup
1204
- expect(typeof cleanup).toBe('function');
1205
- /** @type {() => void} */ (cleanup)();
1206
-
1207
- // Reset and click again — listener should be gone
1208
- opt.setAttribute('aria-selected', 'false');
1209
- opt.click();
1210
- expect(opt.getAttribute('aria-selected')).toBe('false');
1211
-
1212
- listbox.remove();
1213
- vi.useRealTimers();
1214
- });
1215
- });