@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.
- package/dist/components/util/popup.svelte +16 -29
- package/dist/services/popup.svelte.d.ts +11 -12
- package/dist/services/popup.svelte.js +51 -44
- package/package.json +40 -32
- package/dist/components/text-editor/constants.test.d.ts +0 -1
- package/dist/components/text-editor/constants.test.js +0 -98
- package/dist/components/text-editor/markdown.test.d.ts +0 -1
- package/dist/components/text-editor/markdown.test.js +0 -84
- package/dist/components/text-editor/store.svelte.test.d.ts +0 -1
- package/dist/components/text-editor/store.svelte.test.js +0 -229
- package/dist/components/text-editor/transformers/hr.test.d.ts +0 -1
- package/dist/components/text-editor/transformers/hr.test.js +0 -106
- package/dist/components/text-editor/transformers/table.test.d.ts +0 -1
- package/dist/components/text-editor/transformers/table.test.js +0 -28
- package/dist/services/group.test.d.ts +0 -1
- package/dist/services/group.test.js +0 -1215
- package/dist/services/i18n.test.d.ts +0 -1
- package/dist/services/i18n.test.js +0 -18
- package/dist/services/popup.test.d.ts +0 -1
- package/dist/services/popup.test.js +0 -641
- package/dist/services/select.test.d.ts +0 -1
- package/dist/services/select.test.js +0 -69
|
@@ -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
|
-
});
|