@afixt/test-utils 2.3.0 → 2.5.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/.claude/settings.local.json +2 -1
- package/package.json +1 -1
- package/src/constants.js +46 -0
- package/src/cssUtils.js +116 -0
- package/src/detectFocusTrap.js +484 -0
- package/src/getAccessibleText.js +26 -1
- package/src/index.js +2 -0
- package/test/cssUtils.test.js +252 -0
- package/test/detectFocusTrap.test.js +1004 -0
- package/test/getAccessibleText.test.js +26 -0
- package/test/hasValidAriaRole.test.js +270 -151
- package/test/index.test.js +3 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Tests for detectFocusTrap utility
|
|
3
|
+
* @description Verifies focus trap detection through static DOM analysis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import { detectFocusTrap } from '../src/detectFocusTrap.js';
|
|
8
|
+
|
|
9
|
+
describe('detectFocusTrap', () => {
|
|
10
|
+
let container;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
document.body.innerHTML = '';
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// === Batch 1: Input validation & edge cases ===
|
|
17
|
+
|
|
18
|
+
describe('input validation', () => {
|
|
19
|
+
it('should return not trapped for null input', () => {
|
|
20
|
+
const result = detectFocusTrap(null);
|
|
21
|
+
expect(result.isTrapped).toBe(false);
|
|
22
|
+
expect(result.focusableCount).toBe(0);
|
|
23
|
+
expect(result.indicators).toEqual([]);
|
|
24
|
+
expect(result.trapType).toBe('none');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return not trapped for undefined input', () => {
|
|
28
|
+
const result = detectFocusTrap(undefined);
|
|
29
|
+
expect(result.isTrapped).toBe(false);
|
|
30
|
+
expect(result.trapType).toBe('none');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return not trapped for non-element input', () => {
|
|
34
|
+
const result = detectFocusTrap('not an element');
|
|
35
|
+
expect(result.isTrapped).toBe(false);
|
|
36
|
+
expect(result.trapType).toBe('none');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return not trapped for empty container with no focusable elements', () => {
|
|
40
|
+
container = document.createElement('div');
|
|
41
|
+
document.body.appendChild(container);
|
|
42
|
+
const result = detectFocusTrap(container);
|
|
43
|
+
expect(result.isTrapped).toBe(false);
|
|
44
|
+
expect(result.focusableCount).toBe(0);
|
|
45
|
+
expect(result.trapType).toBe('none');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return correct focusableCount for container with focusable elements', () => {
|
|
49
|
+
container = document.createElement('div');
|
|
50
|
+
container.innerHTML = '<button>One</button><button>Two</button>';
|
|
51
|
+
document.body.appendChild(container);
|
|
52
|
+
const result = detectFocusTrap(container);
|
|
53
|
+
expect(result.focusableCount).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('return value structure', () => {
|
|
58
|
+
it('should always return an object with isTrapped, focusableCount, indicators, and trapType', () => {
|
|
59
|
+
container = document.createElement('div');
|
|
60
|
+
document.body.appendChild(container);
|
|
61
|
+
const result = detectFocusTrap(container);
|
|
62
|
+
expect(result).toHaveProperty('isTrapped');
|
|
63
|
+
expect(result).toHaveProperty('focusableCount');
|
|
64
|
+
expect(result).toHaveProperty('indicators');
|
|
65
|
+
expect(result).toHaveProperty('trapType');
|
|
66
|
+
expect(typeof result.isTrapped).toBe('boolean');
|
|
67
|
+
expect(typeof result.focusableCount).toBe('number');
|
|
68
|
+
expect(Array.isArray(result.indicators)).toBe(true);
|
|
69
|
+
expect(typeof result.trapType).toBe('string');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not be trapped when there are indicators but no focusable elements', () => {
|
|
73
|
+
container = document.createElement('div');
|
|
74
|
+
container.setAttribute('role', 'dialog');
|
|
75
|
+
container.setAttribute('aria-modal', 'true');
|
|
76
|
+
document.body.appendChild(container);
|
|
77
|
+
const result = detectFocusTrap(container);
|
|
78
|
+
expect(result.isTrapped).toBe(false);
|
|
79
|
+
expect(result.focusableCount).toBe(0);
|
|
80
|
+
expect(result.indicators.length).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// === Batch 2: Modal dialog patterns ===
|
|
85
|
+
|
|
86
|
+
describe('modal dialog detection', () => {
|
|
87
|
+
it('should detect role="dialog" with aria-modal="true" on container', () => {
|
|
88
|
+
container = document.createElement('div');
|
|
89
|
+
container.setAttribute('role', 'dialog');
|
|
90
|
+
container.setAttribute('aria-modal', 'true');
|
|
91
|
+
container.innerHTML = '<button>Close</button><input type="text">';
|
|
92
|
+
document.body.appendChild(container);
|
|
93
|
+
const result = detectFocusTrap(container);
|
|
94
|
+
expect(result.isTrapped).toBe(true);
|
|
95
|
+
expect(result.trapType).toBe('modal');
|
|
96
|
+
expect(result.indicators).toContain('modal-dialog');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should detect role="alertdialog" with aria-modal="true"', () => {
|
|
100
|
+
container = document.createElement('div');
|
|
101
|
+
container.setAttribute('role', 'alertdialog');
|
|
102
|
+
container.setAttribute('aria-modal', 'true');
|
|
103
|
+
container.innerHTML = '<button>OK</button><button>Cancel</button>';
|
|
104
|
+
document.body.appendChild(container);
|
|
105
|
+
const result = detectFocusTrap(container);
|
|
106
|
+
expect(result.isTrapped).toBe(true);
|
|
107
|
+
expect(result.trapType).toBe('modal');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should not detect dialog without aria-modal="true"', () => {
|
|
111
|
+
container = document.createElement('div');
|
|
112
|
+
container.setAttribute('role', 'dialog');
|
|
113
|
+
container.innerHTML = '<button>Close</button>';
|
|
114
|
+
document.body.appendChild(container);
|
|
115
|
+
const result = detectFocusTrap(container);
|
|
116
|
+
expect(result.indicators).not.toContain('modal-dialog');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should detect native <dialog open> element', () => {
|
|
120
|
+
container = document.createElement('dialog');
|
|
121
|
+
container.setAttribute('open', '');
|
|
122
|
+
container.innerHTML = '<button>Close</button>';
|
|
123
|
+
document.body.appendChild(container);
|
|
124
|
+
const result = detectFocusTrap(container);
|
|
125
|
+
expect(result.isTrapped).toBe(true);
|
|
126
|
+
expect(result.trapType).toBe('modal');
|
|
127
|
+
expect(result.indicators).toContain('modal-dialog');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should not detect <dialog> without open attribute', () => {
|
|
131
|
+
container = document.createElement('dialog');
|
|
132
|
+
container.innerHTML = '<button>Close</button>';
|
|
133
|
+
document.body.appendChild(container);
|
|
134
|
+
const result = detectFocusTrap(container);
|
|
135
|
+
expect(result.indicators).not.toContain('modal-dialog');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should detect modal dialog on ancestor element', () => {
|
|
139
|
+
const modal = document.createElement('div');
|
|
140
|
+
modal.setAttribute('role', 'dialog');
|
|
141
|
+
modal.setAttribute('aria-modal', 'true');
|
|
142
|
+
container = document.createElement('div');
|
|
143
|
+
container.innerHTML = '<button>Save</button>';
|
|
144
|
+
modal.appendChild(container);
|
|
145
|
+
document.body.appendChild(modal);
|
|
146
|
+
const result = detectFocusTrap(container);
|
|
147
|
+
expect(result.isTrapped).toBe(true);
|
|
148
|
+
expect(result.trapType).toBe('modal');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// === Batch 3: Inert siblings ===
|
|
153
|
+
|
|
154
|
+
describe('inert siblings detection', () => {
|
|
155
|
+
it('should detect when all siblings have inert attribute', () => {
|
|
156
|
+
const wrapper = document.createElement('div');
|
|
157
|
+
const sibling1 = document.createElement('div');
|
|
158
|
+
sibling1.setAttribute('inert', '');
|
|
159
|
+
sibling1.textContent = 'Background content';
|
|
160
|
+
const sibling2 = document.createElement('div');
|
|
161
|
+
sibling2.setAttribute('inert', '');
|
|
162
|
+
sibling2.textContent = 'More background';
|
|
163
|
+
container = document.createElement('div');
|
|
164
|
+
container.innerHTML = '<button>Action</button>';
|
|
165
|
+
wrapper.appendChild(sibling1);
|
|
166
|
+
wrapper.appendChild(container);
|
|
167
|
+
wrapper.appendChild(sibling2);
|
|
168
|
+
document.body.appendChild(wrapper);
|
|
169
|
+
const result = detectFocusTrap(container);
|
|
170
|
+
expect(result.isTrapped).toBe(true);
|
|
171
|
+
expect(result.trapType).toBe('inert');
|
|
172
|
+
expect(result.indicators).toContain('inert-siblings');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should not detect when only some siblings have inert', () => {
|
|
176
|
+
const wrapper = document.createElement('div');
|
|
177
|
+
const sibling1 = document.createElement('div');
|
|
178
|
+
sibling1.setAttribute('inert', '');
|
|
179
|
+
const sibling2 = document.createElement('div');
|
|
180
|
+
// No inert attribute
|
|
181
|
+
container = document.createElement('div');
|
|
182
|
+
container.innerHTML = '<button>Action</button>';
|
|
183
|
+
wrapper.appendChild(sibling1);
|
|
184
|
+
wrapper.appendChild(container);
|
|
185
|
+
wrapper.appendChild(sibling2);
|
|
186
|
+
document.body.appendChild(wrapper);
|
|
187
|
+
const result = detectFocusTrap(container);
|
|
188
|
+
expect(result.indicators).not.toContain('inert-siblings');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should not detect inert siblings when container has no siblings', () => {
|
|
192
|
+
const wrapper = document.createElement('div');
|
|
193
|
+
container = document.createElement('div');
|
|
194
|
+
container.innerHTML = '<button>Action</button>';
|
|
195
|
+
wrapper.appendChild(container);
|
|
196
|
+
document.body.appendChild(wrapper);
|
|
197
|
+
const result = detectFocusTrap(container);
|
|
198
|
+
expect(result.indicators).not.toContain('inert-siblings');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// === Batch 4: Library markers ===
|
|
203
|
+
|
|
204
|
+
describe('library marker detection', () => {
|
|
205
|
+
it('should detect data-focus-trap attribute on container', () => {
|
|
206
|
+
container = document.createElement('div');
|
|
207
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
208
|
+
container.innerHTML = '<button>Close</button>';
|
|
209
|
+
document.body.appendChild(container);
|
|
210
|
+
const result = detectFocusTrap(container);
|
|
211
|
+
expect(result.isTrapped).toBe(true);
|
|
212
|
+
expect(result.indicators).toContain('library-markers');
|
|
213
|
+
expect(result.trapType).toBe('library');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should detect data-focus-lock attribute on container', () => {
|
|
217
|
+
container = document.createElement('div');
|
|
218
|
+
container.setAttribute('data-focus-lock', '');
|
|
219
|
+
container.innerHTML = '<input type="text">';
|
|
220
|
+
document.body.appendChild(container);
|
|
221
|
+
const result = detectFocusTrap(container);
|
|
222
|
+
expect(result.indicators).toContain('library-markers');
|
|
223
|
+
expect(result.trapType).toBe('library');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should detect data-focus-guard attribute on ancestor', () => {
|
|
227
|
+
const wrapper = document.createElement('div');
|
|
228
|
+
wrapper.setAttribute('data-focus-guard', '');
|
|
229
|
+
container = document.createElement('div');
|
|
230
|
+
container.innerHTML = '<button>OK</button>';
|
|
231
|
+
wrapper.appendChild(container);
|
|
232
|
+
document.body.appendChild(wrapper);
|
|
233
|
+
const result = detectFocusTrap(container);
|
|
234
|
+
expect(result.indicators).toContain('library-markers');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should detect sentinel elements at container boundaries', () => {
|
|
238
|
+
container = document.createElement('div');
|
|
239
|
+
const sentinel1 = document.createElement('div');
|
|
240
|
+
sentinel1.setAttribute('tabindex', '0');
|
|
241
|
+
const content = document.createElement('div');
|
|
242
|
+
content.innerHTML = '<button>Action</button>';
|
|
243
|
+
const sentinel2 = document.createElement('div');
|
|
244
|
+
sentinel2.setAttribute('tabindex', '0');
|
|
245
|
+
container.appendChild(sentinel1);
|
|
246
|
+
container.appendChild(content);
|
|
247
|
+
container.appendChild(sentinel2);
|
|
248
|
+
document.body.appendChild(container);
|
|
249
|
+
const result = detectFocusTrap(container);
|
|
250
|
+
expect(result.indicators).toContain('library-markers');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should not detect sentinel when div has content', () => {
|
|
254
|
+
container = document.createElement('div');
|
|
255
|
+
const notSentinel = document.createElement('div');
|
|
256
|
+
notSentinel.setAttribute('tabindex', '0');
|
|
257
|
+
notSentinel.textContent = 'I have content';
|
|
258
|
+
const content = document.createElement('div');
|
|
259
|
+
content.innerHTML = '<button>Action</button>';
|
|
260
|
+
container.appendChild(notSentinel);
|
|
261
|
+
container.appendChild(content);
|
|
262
|
+
document.body.appendChild(container);
|
|
263
|
+
const result = detectFocusTrap(container);
|
|
264
|
+
expect(result.indicators).not.toContain('library-markers');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should detect data-focus-lock-disabled on ancestor', () => {
|
|
268
|
+
const wrapper = document.createElement('div');
|
|
269
|
+
wrapper.setAttribute('data-focus-lock-disabled', 'false');
|
|
270
|
+
container = document.createElement('div');
|
|
271
|
+
container.innerHTML = '<button>Submit</button>';
|
|
272
|
+
wrapper.appendChild(container);
|
|
273
|
+
document.body.appendChild(wrapper);
|
|
274
|
+
const result = detectFocusTrap(container);
|
|
275
|
+
expect(result.indicators).toContain('library-markers');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// === Batch 5: Keyboard & focus event listeners ===
|
|
280
|
+
|
|
281
|
+
describe('keyboard listener detection', () => {
|
|
282
|
+
it('should detect keydown addEventListener on container', () => {
|
|
283
|
+
container = document.createElement('div');
|
|
284
|
+
container.innerHTML = '<button>Action</button>';
|
|
285
|
+
document.body.appendChild(container);
|
|
286
|
+
container.addEventListener('keydown', () => {});
|
|
287
|
+
const result = detectFocusTrap(container);
|
|
288
|
+
expect(result.isTrapped).toBe(true);
|
|
289
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
290
|
+
expect(result.trapType).toBe('custom');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should detect keypress addEventListener on container', () => {
|
|
294
|
+
container = document.createElement('div');
|
|
295
|
+
container.innerHTML = '<button>Action</button>';
|
|
296
|
+
document.body.appendChild(container);
|
|
297
|
+
container.addEventListener('keypress', () => {});
|
|
298
|
+
const result = detectFocusTrap(container);
|
|
299
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should detect inline onkeydown attribute', () => {
|
|
303
|
+
container = document.createElement('div');
|
|
304
|
+
container.setAttribute('onkeydown', 'handleKey(event)');
|
|
305
|
+
container.innerHTML = '<button>Action</button>';
|
|
306
|
+
document.body.appendChild(container);
|
|
307
|
+
const result = detectFocusTrap(container);
|
|
308
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should detect keyboard listener on ancestor', () => {
|
|
312
|
+
const wrapper = document.createElement('div');
|
|
313
|
+
wrapper.addEventListener('keydown', () => {});
|
|
314
|
+
container = document.createElement('div');
|
|
315
|
+
container.innerHTML = '<button>Action</button>';
|
|
316
|
+
wrapper.appendChild(container);
|
|
317
|
+
document.body.appendChild(wrapper);
|
|
318
|
+
const result = detectFocusTrap(container);
|
|
319
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('focus listener detection', () => {
|
|
324
|
+
it('should detect focusin addEventListener on container', () => {
|
|
325
|
+
container = document.createElement('div');
|
|
326
|
+
container.innerHTML = '<button>Action</button>';
|
|
327
|
+
document.body.appendChild(container);
|
|
328
|
+
container.addEventListener('focusin', () => {});
|
|
329
|
+
const result = detectFocusTrap(container);
|
|
330
|
+
expect(result.isTrapped).toBe(true);
|
|
331
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
332
|
+
expect(result.trapType).toBe('custom');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should detect focusout addEventListener on container', () => {
|
|
336
|
+
container = document.createElement('div');
|
|
337
|
+
container.innerHTML = '<button>Action</button>';
|
|
338
|
+
document.body.appendChild(container);
|
|
339
|
+
container.addEventListener('focusout', () => {});
|
|
340
|
+
const result = detectFocusTrap(container);
|
|
341
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should detect blur addEventListener on container', () => {
|
|
345
|
+
container = document.createElement('div');
|
|
346
|
+
container.innerHTML = '<button>Action</button>';
|
|
347
|
+
document.body.appendChild(container);
|
|
348
|
+
container.addEventListener('blur', () => {});
|
|
349
|
+
const result = detectFocusTrap(container);
|
|
350
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should detect inline onfocusin attribute', () => {
|
|
354
|
+
container = document.createElement('div');
|
|
355
|
+
container.setAttribute('onfocusin', 'handleFocus(event)');
|
|
356
|
+
container.innerHTML = '<button>Action</button>';
|
|
357
|
+
document.body.appendChild(container);
|
|
358
|
+
const result = detectFocusTrap(container);
|
|
359
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should detect focus listener on ancestor', () => {
|
|
363
|
+
const wrapper = document.createElement('div');
|
|
364
|
+
wrapper.addEventListener('focusin', () => {});
|
|
365
|
+
container = document.createElement('div');
|
|
366
|
+
container.innerHTML = '<button>Action</button>';
|
|
367
|
+
wrapper.appendChild(container);
|
|
368
|
+
document.body.appendChild(wrapper);
|
|
369
|
+
const result = detectFocusTrap(container);
|
|
370
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// === Batch 6: Combined / priority scenarios ===
|
|
375
|
+
|
|
376
|
+
describe('priority and combined scenarios', () => {
|
|
377
|
+
it('should prioritize modal over library markers', () => {
|
|
378
|
+
container = document.createElement('div');
|
|
379
|
+
container.setAttribute('role', 'dialog');
|
|
380
|
+
container.setAttribute('aria-modal', 'true');
|
|
381
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
382
|
+
container.innerHTML = '<button>Close</button>';
|
|
383
|
+
document.body.appendChild(container);
|
|
384
|
+
const result = detectFocusTrap(container);
|
|
385
|
+
expect(result.trapType).toBe('modal');
|
|
386
|
+
expect(result.indicators).toContain('modal-dialog');
|
|
387
|
+
expect(result.indicators).toContain('library-markers');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should prioritize modal over custom listeners', () => {
|
|
391
|
+
container = document.createElement('div');
|
|
392
|
+
container.setAttribute('role', 'dialog');
|
|
393
|
+
container.setAttribute('aria-modal', 'true');
|
|
394
|
+
container.innerHTML = '<button>Close</button>';
|
|
395
|
+
document.body.appendChild(container);
|
|
396
|
+
container.addEventListener('keydown', () => {});
|
|
397
|
+
const result = detectFocusTrap(container);
|
|
398
|
+
expect(result.trapType).toBe('modal');
|
|
399
|
+
expect(result.indicators).toContain('modal-dialog');
|
|
400
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('should prioritize inert over library markers', () => {
|
|
404
|
+
const wrapper = document.createElement('div');
|
|
405
|
+
const sibling = document.createElement('div');
|
|
406
|
+
sibling.setAttribute('inert', '');
|
|
407
|
+
container = document.createElement('div');
|
|
408
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
409
|
+
container.innerHTML = '<button>Action</button>';
|
|
410
|
+
wrapper.appendChild(sibling);
|
|
411
|
+
wrapper.appendChild(container);
|
|
412
|
+
document.body.appendChild(wrapper);
|
|
413
|
+
const result = detectFocusTrap(container);
|
|
414
|
+
expect(result.trapType).toBe('inert');
|
|
415
|
+
expect(result.indicators).toContain('inert-siblings');
|
|
416
|
+
expect(result.indicators).toContain('library-markers');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should prioritize library over custom listeners when no modal/inert', () => {
|
|
420
|
+
container = document.createElement('div');
|
|
421
|
+
container.setAttribute('data-focus-lock', '');
|
|
422
|
+
container.innerHTML = '<button>Action</button>';
|
|
423
|
+
document.body.appendChild(container);
|
|
424
|
+
container.addEventListener('keydown', () => {});
|
|
425
|
+
const result = detectFocusTrap(container);
|
|
426
|
+
expect(result.trapType).toBe('library');
|
|
427
|
+
expect(result.indicators).toContain('library-markers');
|
|
428
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should report multiple indicators even when trapType follows priority', () => {
|
|
432
|
+
container = document.createElement('div');
|
|
433
|
+
container.setAttribute('role', 'dialog');
|
|
434
|
+
container.setAttribute('aria-modal', 'true');
|
|
435
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
436
|
+
container.innerHTML = '<button>Close</button>';
|
|
437
|
+
document.body.appendChild(container);
|
|
438
|
+
container.addEventListener('keydown', () => {});
|
|
439
|
+
container.addEventListener('focusin', () => {});
|
|
440
|
+
const result = detectFocusTrap(container);
|
|
441
|
+
expect(result.trapType).toBe('modal');
|
|
442
|
+
expect(result.indicators.length).toBe(4);
|
|
443
|
+
expect(result.indicators).toContain('modal-dialog');
|
|
444
|
+
expect(result.indicators).toContain('library-markers');
|
|
445
|
+
expect(result.indicators).toContain('keyboard-listeners');
|
|
446
|
+
expect(result.indicators).toContain('focus-listeners');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should return trapType custom when only keyboard listeners present', () => {
|
|
450
|
+
container = document.createElement('div');
|
|
451
|
+
container.innerHTML = '<button>Action</button>';
|
|
452
|
+
document.body.appendChild(container);
|
|
453
|
+
container.addEventListener('keydown', () => {});
|
|
454
|
+
const result = detectFocusTrap(container);
|
|
455
|
+
expect(result.trapType).toBe('custom');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should return trapType custom when only focus listeners present', () => {
|
|
459
|
+
container = document.createElement('div');
|
|
460
|
+
container.innerHTML = '<button>Action</button>';
|
|
461
|
+
document.body.appendChild(container);
|
|
462
|
+
container.addEventListener('focusin', () => {});
|
|
463
|
+
const result = detectFocusTrap(container);
|
|
464
|
+
expect(result.trapType).toBe('custom');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should return trapType none for plain container with focusable elements but no trap indicators', () => {
|
|
468
|
+
container = document.createElement('div');
|
|
469
|
+
container.innerHTML = '<button>Action</button><input type="text">';
|
|
470
|
+
document.body.appendChild(container);
|
|
471
|
+
const result = detectFocusTrap(container);
|
|
472
|
+
expect(result.isTrapped).toBe(false);
|
|
473
|
+
expect(result.trapType).toBe('none');
|
|
474
|
+
expect(result.focusableCount).toBe(2);
|
|
475
|
+
expect(result.indicators).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// === Batch 7: Harmful focus traps — concerns detection ===
|
|
480
|
+
|
|
481
|
+
describe('concerns field in return value', () => {
|
|
482
|
+
it('should include concerns array in return value', () => {
|
|
483
|
+
container = document.createElement('div');
|
|
484
|
+
document.body.appendChild(container);
|
|
485
|
+
const result = detectFocusTrap(container);
|
|
486
|
+
expect(result).toHaveProperty('concerns');
|
|
487
|
+
expect(Array.isArray(result.concerns)).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('should return empty concerns for a properly-formed modal dialog', () => {
|
|
491
|
+
container = document.createElement('div');
|
|
492
|
+
container.setAttribute('role', 'dialog');
|
|
493
|
+
container.setAttribute('aria-modal', 'true');
|
|
494
|
+
container.innerHTML = '<input type="text"><button>Close</button>';
|
|
495
|
+
document.body.appendChild(container);
|
|
496
|
+
const result = detectFocusTrap(container);
|
|
497
|
+
expect(result.isTrapped).toBe(true);
|
|
498
|
+
expect(result.concerns).toEqual([]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should return empty concerns when no trap is detected', () => {
|
|
502
|
+
container = document.createElement('div');
|
|
503
|
+
container.innerHTML = '<button>OK</button>';
|
|
504
|
+
document.body.appendChild(container);
|
|
505
|
+
const result = detectFocusTrap(container);
|
|
506
|
+
expect(result.concerns).toEqual([]);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
describe('non-modal trap detection', () => {
|
|
511
|
+
it('should flag non-modal-trap when custom trap is on a plain div', () => {
|
|
512
|
+
container = document.createElement('div');
|
|
513
|
+
container.innerHTML = '<button>Action</button>';
|
|
514
|
+
document.body.appendChild(container);
|
|
515
|
+
container.addEventListener('keydown', () => {});
|
|
516
|
+
container.addEventListener('focusin', () => {});
|
|
517
|
+
const result = detectFocusTrap(container);
|
|
518
|
+
expect(result.isTrapped).toBe(true);
|
|
519
|
+
expect(result.concerns).toContain('non-modal-trap');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should flag non-modal-trap for library trap without dialog role', () => {
|
|
523
|
+
container = document.createElement('div');
|
|
524
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
525
|
+
container.innerHTML = '<a href="#">Link</a>';
|
|
526
|
+
document.body.appendChild(container);
|
|
527
|
+
const result = detectFocusTrap(container);
|
|
528
|
+
expect(result.isTrapped).toBe(true);
|
|
529
|
+
expect(result.concerns).toContain('non-modal-trap');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should not flag non-modal-trap for modal dialog', () => {
|
|
533
|
+
container = document.createElement('div');
|
|
534
|
+
container.setAttribute('role', 'dialog');
|
|
535
|
+
container.setAttribute('aria-modal', 'true');
|
|
536
|
+
container.innerHTML = '<button>Close</button>';
|
|
537
|
+
document.body.appendChild(container);
|
|
538
|
+
const result = detectFocusTrap(container);
|
|
539
|
+
expect(result.concerns).not.toContain('non-modal-trap');
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('should not flag non-modal-trap for native <dialog open>', () => {
|
|
543
|
+
container = document.createElement('dialog');
|
|
544
|
+
container.setAttribute('open', '');
|
|
545
|
+
container.innerHTML = '<button>Close</button>';
|
|
546
|
+
document.body.appendChild(container);
|
|
547
|
+
const result = detectFocusTrap(container);
|
|
548
|
+
expect(result.concerns).not.toContain('non-modal-trap');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should not flag non-modal-trap for inert sibling pattern', () => {
|
|
552
|
+
const wrapper = document.createElement('div');
|
|
553
|
+
const sibling = document.createElement('div');
|
|
554
|
+
sibling.setAttribute('inert', '');
|
|
555
|
+
container = document.createElement('div');
|
|
556
|
+
container.innerHTML = '<button>Action</button>';
|
|
557
|
+
wrapper.appendChild(sibling);
|
|
558
|
+
wrapper.appendChild(container);
|
|
559
|
+
document.body.appendChild(wrapper);
|
|
560
|
+
const result = detectFocusTrap(container);
|
|
561
|
+
expect(result.concerns).not.toContain('non-modal-trap');
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('single focusable element trap', () => {
|
|
566
|
+
it('should flag single-focusable when trap has only one focusable element', () => {
|
|
567
|
+
container = document.createElement('div');
|
|
568
|
+
container.setAttribute('role', 'dialog');
|
|
569
|
+
container.setAttribute('aria-modal', 'true');
|
|
570
|
+
container.innerHTML = '<button>Close</button>';
|
|
571
|
+
document.body.appendChild(container);
|
|
572
|
+
const result = detectFocusTrap(container);
|
|
573
|
+
expect(result.isTrapped).toBe(true);
|
|
574
|
+
expect(result.focusableCount).toBe(1);
|
|
575
|
+
expect(result.concerns).toContain('single-focusable');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('should not flag single-focusable when trap has multiple focusable elements', () => {
|
|
579
|
+
container = document.createElement('div');
|
|
580
|
+
container.setAttribute('role', 'dialog');
|
|
581
|
+
container.setAttribute('aria-modal', 'true');
|
|
582
|
+
container.innerHTML = '<input type="text"><button>Close</button>';
|
|
583
|
+
document.body.appendChild(container);
|
|
584
|
+
const result = detectFocusTrap(container);
|
|
585
|
+
expect(result.focusableCount).toBe(2);
|
|
586
|
+
expect(result.concerns).not.toContain('single-focusable');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe('no escape mechanism', () => {
|
|
591
|
+
it('should flag no-escape-mechanism for custom trap without close/cancel button', () => {
|
|
592
|
+
container = document.createElement('div');
|
|
593
|
+
container.innerHTML = '<input type="text"><input type="text">';
|
|
594
|
+
document.body.appendChild(container);
|
|
595
|
+
container.addEventListener('keydown', () => {});
|
|
596
|
+
container.addEventListener('focusin', () => {});
|
|
597
|
+
const result = detectFocusTrap(container);
|
|
598
|
+
expect(result.isTrapped).toBe(true);
|
|
599
|
+
expect(result.concerns).toContain('no-escape-mechanism');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should not flag no-escape-mechanism when a close button exists', () => {
|
|
603
|
+
container = document.createElement('div');
|
|
604
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
605
|
+
container.innerHTML = '<input type="text"><button>Close</button>';
|
|
606
|
+
document.body.appendChild(container);
|
|
607
|
+
const result = detectFocusTrap(container);
|
|
608
|
+
expect(result.concerns).not.toContain('no-escape-mechanism');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('should not flag no-escape-mechanism when a cancel button exists', () => {
|
|
612
|
+
container = document.createElement('div');
|
|
613
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
614
|
+
container.innerHTML = '<input type="text"><button>Cancel</button>';
|
|
615
|
+
document.body.appendChild(container);
|
|
616
|
+
const result = detectFocusTrap(container);
|
|
617
|
+
expect(result.concerns).not.toContain('no-escape-mechanism');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('should not flag no-escape-mechanism for button with aria-label close', () => {
|
|
621
|
+
container = document.createElement('div');
|
|
622
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
623
|
+
container.innerHTML = '<input type="text"><button aria-label="Close">X</button>';
|
|
624
|
+
document.body.appendChild(container);
|
|
625
|
+
const result = detectFocusTrap(container);
|
|
626
|
+
expect(result.concerns).not.toContain('no-escape-mechanism');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('should not flag no-escape-mechanism when Escape keydown handler exists', () => {
|
|
630
|
+
container = document.createElement('div');
|
|
631
|
+
container.innerHTML = '<input type="text"><input type="text">';
|
|
632
|
+
document.body.appendChild(container);
|
|
633
|
+
container.addEventListener('keydown', () => {});
|
|
634
|
+
container.addEventListener('focusin', () => {});
|
|
635
|
+
// Simulate having an ancestor with keydown that could handle Escape
|
|
636
|
+
// The modal pattern inherently implies escape support
|
|
637
|
+
container.setAttribute('role', 'dialog');
|
|
638
|
+
container.setAttribute('aria-modal', 'true');
|
|
639
|
+
const result = detectFocusTrap(container);
|
|
640
|
+
expect(result.concerns).not.toContain('no-escape-mechanism');
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// === Batch 8: aria-hidden siblings creating screen reader traps ===
|
|
645
|
+
|
|
646
|
+
describe('aria-hidden siblings detection', () => {
|
|
647
|
+
it('should detect aria-hidden siblings as a trap indicator', () => {
|
|
648
|
+
const wrapper = document.createElement('div');
|
|
649
|
+
const sibling1 = document.createElement('div');
|
|
650
|
+
sibling1.setAttribute('aria-hidden', 'true');
|
|
651
|
+
sibling1.textContent = 'Hidden nav';
|
|
652
|
+
const sibling2 = document.createElement('div');
|
|
653
|
+
sibling2.setAttribute('aria-hidden', 'true');
|
|
654
|
+
sibling2.textContent = 'Hidden footer';
|
|
655
|
+
container = document.createElement('div');
|
|
656
|
+
container.innerHTML = '<button>Action</button>';
|
|
657
|
+
wrapper.appendChild(sibling1);
|
|
658
|
+
wrapper.appendChild(container);
|
|
659
|
+
wrapper.appendChild(sibling2);
|
|
660
|
+
document.body.appendChild(wrapper);
|
|
661
|
+
const result = detectFocusTrap(container);
|
|
662
|
+
expect(result.isTrapped).toBe(true);
|
|
663
|
+
expect(result.indicators).toContain('aria-hidden-siblings');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('should not detect when only some siblings have aria-hidden', () => {
|
|
667
|
+
const wrapper = document.createElement('div');
|
|
668
|
+
const sibling1 = document.createElement('div');
|
|
669
|
+
sibling1.setAttribute('aria-hidden', 'true');
|
|
670
|
+
const sibling2 = document.createElement('div');
|
|
671
|
+
container = document.createElement('div');
|
|
672
|
+
container.innerHTML = '<button>Action</button>';
|
|
673
|
+
wrapper.appendChild(sibling1);
|
|
674
|
+
wrapper.appendChild(container);
|
|
675
|
+
wrapper.appendChild(sibling2);
|
|
676
|
+
document.body.appendChild(wrapper);
|
|
677
|
+
const result = detectFocusTrap(container);
|
|
678
|
+
expect(result.indicators).not.toContain('aria-hidden-siblings');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('should flag aria-hidden-no-modal concern when no modal semantics', () => {
|
|
682
|
+
const wrapper = document.createElement('div');
|
|
683
|
+
const sibling = document.createElement('div');
|
|
684
|
+
sibling.setAttribute('aria-hidden', 'true');
|
|
685
|
+
container = document.createElement('div');
|
|
686
|
+
container.innerHTML = '<button>Action</button>';
|
|
687
|
+
wrapper.appendChild(sibling);
|
|
688
|
+
wrapper.appendChild(container);
|
|
689
|
+
document.body.appendChild(wrapper);
|
|
690
|
+
const result = detectFocusTrap(container);
|
|
691
|
+
expect(result.concerns).toContain('aria-hidden-no-modal');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should not flag aria-hidden-no-modal when container is a modal dialog', () => {
|
|
695
|
+
const wrapper = document.createElement('div');
|
|
696
|
+
const sibling = document.createElement('div');
|
|
697
|
+
sibling.setAttribute('aria-hidden', 'true');
|
|
698
|
+
container = document.createElement('div');
|
|
699
|
+
container.setAttribute('role', 'dialog');
|
|
700
|
+
container.setAttribute('aria-modal', 'true');
|
|
701
|
+
container.innerHTML = '<button>Close</button>';
|
|
702
|
+
wrapper.appendChild(sibling);
|
|
703
|
+
wrapper.appendChild(container);
|
|
704
|
+
document.body.appendChild(wrapper);
|
|
705
|
+
const result = detectFocusTrap(container);
|
|
706
|
+
expect(result.concerns).not.toContain('aria-hidden-no-modal');
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// === Batch 9: Tabindex manipulation traps ===
|
|
711
|
+
|
|
712
|
+
describe('tabindex manipulation detection', () => {
|
|
713
|
+
it('should detect tabindex="-1" on focusable siblings as a trap indicator', () => {
|
|
714
|
+
const wrapper = document.createElement('div');
|
|
715
|
+
const nav = document.createElement('nav');
|
|
716
|
+
nav.innerHTML =
|
|
717
|
+
'<a href="/home" tabindex="-1">Home</a><a href="/about" tabindex="-1">About</a>';
|
|
718
|
+
container = document.createElement('div');
|
|
719
|
+
container.innerHTML = '<button>Submit</button>';
|
|
720
|
+
wrapper.appendChild(nav);
|
|
721
|
+
wrapper.appendChild(container);
|
|
722
|
+
document.body.appendChild(wrapper);
|
|
723
|
+
const result = detectFocusTrap(container);
|
|
724
|
+
expect(result.isTrapped).toBe(true);
|
|
725
|
+
expect(result.indicators).toContain('tabindex-manipulation');
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('should not flag tabindex manipulation when siblings have no defocused elements', () => {
|
|
729
|
+
const wrapper = document.createElement('div');
|
|
730
|
+
const nav = document.createElement('nav');
|
|
731
|
+
nav.innerHTML = '<a href="/home">Home</a><a href="/about">About</a>';
|
|
732
|
+
container = document.createElement('div');
|
|
733
|
+
container.innerHTML = '<button>Submit</button>';
|
|
734
|
+
wrapper.appendChild(nav);
|
|
735
|
+
wrapper.appendChild(container);
|
|
736
|
+
document.body.appendChild(wrapper);
|
|
737
|
+
const result = detectFocusTrap(container);
|
|
738
|
+
expect(result.indicators).not.toContain('tabindex-manipulation');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('should flag tabindex-manipulation concern on non-modal trap', () => {
|
|
742
|
+
const wrapper = document.createElement('div');
|
|
743
|
+
const aside = document.createElement('aside');
|
|
744
|
+
aside.innerHTML = '<button tabindex="-1">Disabled nav button</button>';
|
|
745
|
+
container = document.createElement('div');
|
|
746
|
+
container.innerHTML = '<input type="text"><button>Go</button>';
|
|
747
|
+
wrapper.appendChild(aside);
|
|
748
|
+
wrapper.appendChild(container);
|
|
749
|
+
document.body.appendChild(wrapper);
|
|
750
|
+
const result = detectFocusTrap(container);
|
|
751
|
+
expect(result.concerns).toContain('tabindex-manipulation');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('should not flag tabindex-manipulation concern when container is modal', () => {
|
|
755
|
+
const wrapper = document.createElement('div');
|
|
756
|
+
const aside = document.createElement('aside');
|
|
757
|
+
aside.innerHTML = '<button tabindex="-1">Nav button</button>';
|
|
758
|
+
container = document.createElement('div');
|
|
759
|
+
container.setAttribute('role', 'dialog');
|
|
760
|
+
container.setAttribute('aria-modal', 'true');
|
|
761
|
+
container.innerHTML = '<button>Close</button>';
|
|
762
|
+
wrapper.appendChild(aside);
|
|
763
|
+
wrapper.appendChild(container);
|
|
764
|
+
document.body.appendChild(wrapper);
|
|
765
|
+
const result = detectFocusTrap(container);
|
|
766
|
+
expect(result.concerns).not.toContain('tabindex-manipulation');
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('should detect tabindex="-1" on naturally focusable elements outside container', () => {
|
|
770
|
+
const wrapper = document.createElement('div');
|
|
771
|
+
const header = document.createElement('header');
|
|
772
|
+
header.innerHTML =
|
|
773
|
+
'<input type="search" tabindex="-1"><button tabindex="-1">Search</button>';
|
|
774
|
+
container = document.createElement('div');
|
|
775
|
+
container.innerHTML = '<textarea></textarea><button>Save</button>';
|
|
776
|
+
wrapper.appendChild(header);
|
|
777
|
+
wrapper.appendChild(container);
|
|
778
|
+
document.body.appendChild(wrapper);
|
|
779
|
+
const result = detectFocusTrap(container);
|
|
780
|
+
expect(result.indicators).toContain('tabindex-manipulation');
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// === Batch 10: Widget-role traps (menus, tooltips, nav, listboxes) ===
|
|
785
|
+
|
|
786
|
+
describe('inappropriate widget-role traps', () => {
|
|
787
|
+
it('should flag inappropriate-trap-role for focus trap on role="menu"', () => {
|
|
788
|
+
container = document.createElement('div');
|
|
789
|
+
container.setAttribute('role', 'menu');
|
|
790
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
791
|
+
container.innerHTML =
|
|
792
|
+
'<div role="menuitem" tabindex="0">Cut</div><div role="menuitem" tabindex="0">Copy</div>';
|
|
793
|
+
document.body.appendChild(container);
|
|
794
|
+
const result = detectFocusTrap(container);
|
|
795
|
+
expect(result.isTrapped).toBe(true);
|
|
796
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('should flag inappropriate-trap-role for focus trap on role="tooltip"', () => {
|
|
800
|
+
container = document.createElement('div');
|
|
801
|
+
container.setAttribute('role', 'tooltip');
|
|
802
|
+
container.innerHTML = '<a href="/help" tabindex="0">Learn more</a>';
|
|
803
|
+
document.body.appendChild(container);
|
|
804
|
+
container.addEventListener('focusin', () => {});
|
|
805
|
+
const result = detectFocusTrap(container);
|
|
806
|
+
expect(result.isTrapped).toBe(true);
|
|
807
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('should flag inappropriate-trap-role for focus trap on role="navigation"', () => {
|
|
811
|
+
container = document.createElement('nav');
|
|
812
|
+
container.innerHTML = '<a href="/home">Home</a><a href="/about">About</a>';
|
|
813
|
+
document.body.appendChild(container);
|
|
814
|
+
container.addEventListener('keydown', () => {});
|
|
815
|
+
container.addEventListener('focusin', () => {});
|
|
816
|
+
const result = detectFocusTrap(container);
|
|
817
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('should flag inappropriate-trap-role for focus trap on role="listbox"', () => {
|
|
821
|
+
container = document.createElement('div');
|
|
822
|
+
container.setAttribute('role', 'listbox');
|
|
823
|
+
container.setAttribute('data-focus-lock', '');
|
|
824
|
+
container.innerHTML =
|
|
825
|
+
'<div role="option" tabindex="0">Option 1</div><div role="option" tabindex="0">Option 2</div>';
|
|
826
|
+
document.body.appendChild(container);
|
|
827
|
+
const result = detectFocusTrap(container);
|
|
828
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('should flag inappropriate-trap-role for focus trap on role="tabpanel"', () => {
|
|
832
|
+
container = document.createElement('div');
|
|
833
|
+
container.setAttribute('role', 'tabpanel');
|
|
834
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
835
|
+
container.innerHTML = '<input type="text"><button>Submit</button>';
|
|
836
|
+
document.body.appendChild(container);
|
|
837
|
+
const result = detectFocusTrap(container);
|
|
838
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('should not flag inappropriate-trap-role for role="dialog"', () => {
|
|
842
|
+
container = document.createElement('div');
|
|
843
|
+
container.setAttribute('role', 'dialog');
|
|
844
|
+
container.setAttribute('aria-modal', 'true');
|
|
845
|
+
container.innerHTML = '<button>Close</button><input type="text">';
|
|
846
|
+
document.body.appendChild(container);
|
|
847
|
+
const result = detectFocusTrap(container);
|
|
848
|
+
expect(result.concerns).not.toContain('inappropriate-trap-role');
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('should not flag inappropriate-trap-role for role="alertdialog"', () => {
|
|
852
|
+
container = document.createElement('div');
|
|
853
|
+
container.setAttribute('role', 'alertdialog');
|
|
854
|
+
container.setAttribute('aria-modal', 'true');
|
|
855
|
+
container.innerHTML = '<button>OK</button><button>Cancel</button>';
|
|
856
|
+
document.body.appendChild(container);
|
|
857
|
+
const result = detectFocusTrap(container);
|
|
858
|
+
expect(result.concerns).not.toContain('inappropriate-trap-role');
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('should flag inappropriate-trap-role for focus trap on <nav> element', () => {
|
|
862
|
+
container = document.createElement('nav');
|
|
863
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
864
|
+
container.innerHTML = '<a href="/">Home</a><a href="/contact">Contact</a>';
|
|
865
|
+
document.body.appendChild(container);
|
|
866
|
+
const result = detectFocusTrap(container);
|
|
867
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('should flag inappropriate-trap-role for focus trap on <aside>', () => {
|
|
871
|
+
container = document.createElement('aside');
|
|
872
|
+
container.setAttribute('data-focus-lock', '');
|
|
873
|
+
container.innerHTML = '<a href="/related">Related article</a><a href="/ad">Sponsor</a>';
|
|
874
|
+
document.body.appendChild(container);
|
|
875
|
+
const result = detectFocusTrap(container);
|
|
876
|
+
expect(result.concerns).toContain('inappropriate-trap-role');
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// === Batch 11: Nested focus traps ===
|
|
881
|
+
|
|
882
|
+
describe('nested focus traps', () => {
|
|
883
|
+
it('should flag nested-trap when container is inside another focus trap', () => {
|
|
884
|
+
const outer = document.createElement('div');
|
|
885
|
+
outer.setAttribute('role', 'dialog');
|
|
886
|
+
outer.setAttribute('aria-modal', 'true');
|
|
887
|
+
container = document.createElement('div');
|
|
888
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
889
|
+
container.innerHTML = '<input type="text"><button>Save</button>';
|
|
890
|
+
outer.appendChild(container);
|
|
891
|
+
document.body.appendChild(outer);
|
|
892
|
+
const result = detectFocusTrap(container);
|
|
893
|
+
expect(result.isTrapped).toBe(true);
|
|
894
|
+
expect(result.concerns).toContain('nested-trap');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('should flag nested-trap when inside ancestor with data-focus-lock', () => {
|
|
898
|
+
const outer = document.createElement('div');
|
|
899
|
+
outer.setAttribute('data-focus-lock', '');
|
|
900
|
+
const inner = document.createElement('div');
|
|
901
|
+
container = document.createElement('div');
|
|
902
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
903
|
+
container.innerHTML = '<button>Action</button><button>Cancel</button>';
|
|
904
|
+
inner.appendChild(container);
|
|
905
|
+
outer.appendChild(inner);
|
|
906
|
+
document.body.appendChild(outer);
|
|
907
|
+
const result = detectFocusTrap(container);
|
|
908
|
+
expect(result.concerns).toContain('nested-trap');
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('should not flag nested-trap when there is no outer trap', () => {
|
|
912
|
+
container = document.createElement('div');
|
|
913
|
+
container.setAttribute('role', 'dialog');
|
|
914
|
+
container.setAttribute('aria-modal', 'true');
|
|
915
|
+
container.innerHTML = '<button>Close</button><input type="text">';
|
|
916
|
+
document.body.appendChild(container);
|
|
917
|
+
const result = detectFocusTrap(container);
|
|
918
|
+
expect(result.concerns).not.toContain('nested-trap');
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// === Batch 12: Contenteditable and iframe traps ===
|
|
923
|
+
|
|
924
|
+
describe('contenteditable traps', () => {
|
|
925
|
+
it('should detect trap on wrapper around contenteditable with keyboard listeners', () => {
|
|
926
|
+
container = document.createElement('div');
|
|
927
|
+
const editor = document.createElement('div');
|
|
928
|
+
editor.setAttribute('contenteditable', 'true');
|
|
929
|
+
editor.textContent = 'Editable area';
|
|
930
|
+
container.appendChild(editor);
|
|
931
|
+
container.innerHTML += '<button>Format</button>';
|
|
932
|
+
document.body.appendChild(container);
|
|
933
|
+
container.addEventListener('keydown', () => {});
|
|
934
|
+
const result = detectFocusTrap(container);
|
|
935
|
+
expect(result.isTrapped).toBe(true);
|
|
936
|
+
expect(result.concerns).toContain('contenteditable-trap');
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it('should detect trap on container wrapping contenteditable with focus listeners', () => {
|
|
940
|
+
container = document.createElement('div');
|
|
941
|
+
const editor = document.createElement('div');
|
|
942
|
+
editor.setAttribute('contenteditable', 'true');
|
|
943
|
+
editor.textContent = 'Edit me';
|
|
944
|
+
container.appendChild(editor);
|
|
945
|
+
const toolbar = document.createElement('div');
|
|
946
|
+
toolbar.innerHTML = '<button>Bold</button><button>Italic</button>';
|
|
947
|
+
container.appendChild(toolbar);
|
|
948
|
+
document.body.appendChild(container);
|
|
949
|
+
container.addEventListener('focusin', () => {});
|
|
950
|
+
container.addEventListener('keydown', () => {});
|
|
951
|
+
const result = detectFocusTrap(container);
|
|
952
|
+
expect(result.isTrapped).toBe(true);
|
|
953
|
+
expect(result.concerns).toContain('contenteditable-trap');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('should not flag contenteditable-trap when no trap indicators', () => {
|
|
957
|
+
container = document.createElement('div');
|
|
958
|
+
container.setAttribute('contenteditable', 'true');
|
|
959
|
+
container.setAttribute('tabindex', '0');
|
|
960
|
+
container.textContent = 'Editable area';
|
|
961
|
+
document.body.appendChild(container);
|
|
962
|
+
const result = detectFocusTrap(container);
|
|
963
|
+
expect(result.isTrapped).toBe(false);
|
|
964
|
+
expect(result.concerns).not.toContain('contenteditable-trap');
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// === Batch 13: Full-page and body-level traps ===
|
|
969
|
+
|
|
970
|
+
describe('full page traps', () => {
|
|
971
|
+
it('should flag full-page-trap when container is the body element', () => {
|
|
972
|
+
document.body.innerHTML = '<button>Action</button>';
|
|
973
|
+
document.body.setAttribute('data-focus-trap', 'active');
|
|
974
|
+
const result = detectFocusTrap(document.body);
|
|
975
|
+
expect(result.isTrapped).toBe(true);
|
|
976
|
+
expect(result.concerns).toContain('full-page-trap');
|
|
977
|
+
document.body.removeAttribute('data-focus-trap');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('should flag full-page-trap when container is direct child of body with no siblings', () => {
|
|
981
|
+
document.body.innerHTML = '';
|
|
982
|
+
container = document.createElement('div');
|
|
983
|
+
container.setAttribute('data-focus-trap', 'active');
|
|
984
|
+
container.innerHTML = '<button>Action</button>';
|
|
985
|
+
document.body.appendChild(container);
|
|
986
|
+
const result = detectFocusTrap(container);
|
|
987
|
+
expect(result.concerns).toContain('full-page-trap');
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('should not flag full-page-trap for properly scoped modal', () => {
|
|
991
|
+
document.body.innerHTML = '';
|
|
992
|
+
const main = document.createElement('main');
|
|
993
|
+
main.innerHTML = '<p>Page content</p>';
|
|
994
|
+
container = document.createElement('div');
|
|
995
|
+
container.setAttribute('role', 'dialog');
|
|
996
|
+
container.setAttribute('aria-modal', 'true');
|
|
997
|
+
container.innerHTML = '<button>Close</button><input type="text">';
|
|
998
|
+
document.body.appendChild(main);
|
|
999
|
+
document.body.appendChild(container);
|
|
1000
|
+
const result = detectFocusTrap(container);
|
|
1001
|
+
expect(result.concerns).not.toContain('full-page-trap');
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
});
|