@afixt/test-utils 2.4.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/package.json +1 -1
- package/src/constants.js +46 -0
- package/src/detectFocusTrap.js +484 -0
- package/src/getAccessibleText.js +26 -1
- package/src/index.js +2 -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
package/package.json
CHANGED
package/src/constants.js
CHANGED
|
@@ -520,6 +520,52 @@ const VALID_ARIA_ROLES = new Set([
|
|
|
520
520
|
'region',
|
|
521
521
|
'search',
|
|
522
522
|
'timer',
|
|
523
|
+
// WAI-ARIA Graphics Module roles
|
|
524
|
+
'graphics-document',
|
|
525
|
+
'graphics-object',
|
|
526
|
+
'graphics-symbol',
|
|
527
|
+
// DPub-ARIA roles
|
|
528
|
+
'doc-abstract',
|
|
529
|
+
'doc-acknowledgments',
|
|
530
|
+
'doc-afterword',
|
|
531
|
+
'doc-appendix',
|
|
532
|
+
'doc-backlink',
|
|
533
|
+
'doc-biblioentry',
|
|
534
|
+
'doc-bibliography',
|
|
535
|
+
'doc-biblioref',
|
|
536
|
+
'doc-chapter',
|
|
537
|
+
'doc-colophon',
|
|
538
|
+
'doc-conclusion',
|
|
539
|
+
'doc-cover',
|
|
540
|
+
'doc-credit',
|
|
541
|
+
'doc-credits',
|
|
542
|
+
'doc-dedication',
|
|
543
|
+
'doc-endnote',
|
|
544
|
+
'doc-endnotes',
|
|
545
|
+
'doc-epigraph',
|
|
546
|
+
'doc-epilogue',
|
|
547
|
+
'doc-errata',
|
|
548
|
+
'doc-example',
|
|
549
|
+
'doc-footnote',
|
|
550
|
+
'doc-foreword',
|
|
551
|
+
'doc-glossary',
|
|
552
|
+
'doc-glossref',
|
|
553
|
+
'doc-index',
|
|
554
|
+
'doc-introduction',
|
|
555
|
+
'doc-noteref',
|
|
556
|
+
'doc-notice',
|
|
557
|
+
'doc-pagebreak',
|
|
558
|
+
'doc-pagefooter',
|
|
559
|
+
'doc-pageheader',
|
|
560
|
+
'doc-pagelist',
|
|
561
|
+
'doc-part',
|
|
562
|
+
'doc-preface',
|
|
563
|
+
'doc-prologue',
|
|
564
|
+
'doc-pullquote',
|
|
565
|
+
'doc-qna',
|
|
566
|
+
'doc-subtitle',
|
|
567
|
+
'doc-tip',
|
|
568
|
+
'doc-toc',
|
|
523
569
|
]);
|
|
524
570
|
|
|
525
571
|
/**
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getFocusableElements } = require('./getFocusableElements.js');
|
|
4
|
+
const { getEventListeners } = require('./listEventListeners.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default result indicating no focus trap detected.
|
|
8
|
+
* @returns {{ isTrapped: boolean, focusableCount: number, indicators: string[], trapType: string }}
|
|
9
|
+
*/
|
|
10
|
+
function noTrap() {
|
|
11
|
+
return { isTrapped: false, focusableCount: 0, indicators: [], trapType: 'none', concerns: [] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detects whether a container element appears to implement a focus trap
|
|
16
|
+
* through static DOM analysis. Does not fire focus events or change page state.
|
|
17
|
+
* @param {Element} container - The container element to analyze
|
|
18
|
+
* @returns {{ isTrapped: boolean, focusableCount: number, indicators: string[], trapType: string, concerns: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
function detectFocusTrap(container) {
|
|
21
|
+
if (!container || typeof container !== 'object' || !container.querySelectorAll) {
|
|
22
|
+
return noTrap();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const indicators = [];
|
|
26
|
+
let trapType = 'none';
|
|
27
|
+
|
|
28
|
+
const focusableCount = getFocusableElements(container).length;
|
|
29
|
+
|
|
30
|
+
// Check for modal dialog pattern
|
|
31
|
+
if (checkModalDialog(container)) {
|
|
32
|
+
indicators.push('modal-dialog');
|
|
33
|
+
trapType = 'modal';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check for inert siblings
|
|
37
|
+
if (trapType === 'none' && checkInertSiblings(container)) {
|
|
38
|
+
indicators.push('inert-siblings');
|
|
39
|
+
trapType = 'inert';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check for library markers
|
|
43
|
+
if (checkLibraryMarkers(container)) {
|
|
44
|
+
indicators.push('library-markers');
|
|
45
|
+
if (trapType === 'none') {
|
|
46
|
+
trapType = 'library';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check for keyboard event listeners
|
|
51
|
+
if (checkKeyboardListeners(container)) {
|
|
52
|
+
indicators.push('keyboard-listeners');
|
|
53
|
+
if (trapType === 'none') {
|
|
54
|
+
trapType = 'custom';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for focus event listeners
|
|
59
|
+
if (checkFocusListeners(container)) {
|
|
60
|
+
indicators.push('focus-listeners');
|
|
61
|
+
if (trapType === 'none') {
|
|
62
|
+
trapType = 'custom';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check for aria-hidden siblings (screen reader trap pattern)
|
|
67
|
+
const hasAriaHiddenSiblings = checkAriaHiddenSiblings(container);
|
|
68
|
+
if (hasAriaHiddenSiblings) {
|
|
69
|
+
indicators.push('aria-hidden-siblings');
|
|
70
|
+
if (trapType === 'none') {
|
|
71
|
+
trapType = 'custom';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for tabindex manipulation on elements outside the container
|
|
76
|
+
const hasTabindexManip = checkTabindexManipulation(container);
|
|
77
|
+
if (hasTabindexManip) {
|
|
78
|
+
indicators.push('tabindex-manipulation');
|
|
79
|
+
if (trapType === 'none') {
|
|
80
|
+
trapType = 'custom';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isTrapped = indicators.length > 0 && focusableCount > 0;
|
|
85
|
+
|
|
86
|
+
// Assess concerns — problematic focus trap patterns
|
|
87
|
+
const concerns = [];
|
|
88
|
+
if (isTrapped) {
|
|
89
|
+
const isModal = trapType === 'modal' || trapType === 'inert';
|
|
90
|
+
|
|
91
|
+
// Non-modal trap: focus is constrained without modal dialog semantics
|
|
92
|
+
if (!isModal) {
|
|
93
|
+
concerns.push('non-modal-trap');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Single focusable element: user has nowhere to navigate within the trap
|
|
97
|
+
if (focusableCount === 1) {
|
|
98
|
+
concerns.push('single-focusable');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// No escape mechanism: no close/cancel button and no modal semantics
|
|
102
|
+
if (!isModal && !hasEscapeMechanism(container)) {
|
|
103
|
+
concerns.push('no-escape-mechanism');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// aria-hidden siblings without modal: screen reader trap
|
|
107
|
+
if (hasAriaHiddenSiblings && !checkModalDialog(container)) {
|
|
108
|
+
concerns.push('aria-hidden-no-modal');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// tabindex manipulation without modal: manual focus removal on outside elements
|
|
112
|
+
if (hasTabindexManip && !isModal) {
|
|
113
|
+
concerns.push('tabindex-manipulation');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Inappropriate role for a focus trap (menus, tooltips, nav, listboxes, etc.)
|
|
117
|
+
if (hasInappropriateTrapRole(container)) {
|
|
118
|
+
concerns.push('inappropriate-trap-role');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Nested focus trap: container is inside another focus trap
|
|
122
|
+
if (isNestedTrap(container)) {
|
|
123
|
+
concerns.push('nested-trap');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Contenteditable trap: keyboard trap on an editable region
|
|
127
|
+
if (hasContenteditableTrap(container)) {
|
|
128
|
+
concerns.push('contenteditable-trap');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Full page trap: focus trap covers the whole page (body or sole child of body)
|
|
132
|
+
// Modal dialogs are exempt — it's normal for a modal to be the only non-inert element
|
|
133
|
+
if (!isModal && isFullPageTrap(container)) {
|
|
134
|
+
concerns.push('full-page-trap');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { isTrapped, focusableCount, indicators, trapType, concerns };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if container or ancestor is a modal dialog
|
|
143
|
+
* @param {Element} container
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function checkModalDialog(container) {
|
|
147
|
+
let el = container;
|
|
148
|
+
while (el && el !== document.documentElement) {
|
|
149
|
+
// Native <dialog open>
|
|
150
|
+
if (el.tagName === 'DIALOG' && el.hasAttribute('open')) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// ARIA modal dialog
|
|
154
|
+
const role = el.getAttribute('role');
|
|
155
|
+
if (
|
|
156
|
+
(role === 'dialog' || role === 'alertdialog') &&
|
|
157
|
+
el.getAttribute('aria-modal') === 'true'
|
|
158
|
+
) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
el = el.parentElement;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if all sibling elements of the container have the inert attribute
|
|
168
|
+
* @param {Element} container
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
function checkInertSiblings(container) {
|
|
172
|
+
const parent = container.parentElement;
|
|
173
|
+
if (!parent) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const siblings = Array.from(parent.children).filter(child => child !== container);
|
|
177
|
+
if (siblings.length === 0) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return siblings.every(sibling => sibling.hasAttribute('inert'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check for common focus trap library markers on container or ancestors
|
|
185
|
+
* @param {Element} container
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
function checkLibraryMarkers(container) {
|
|
189
|
+
const libraryAttrs = [
|
|
190
|
+
'data-focus-trap',
|
|
191
|
+
'data-focus-lock',
|
|
192
|
+
'data-focus-guard',
|
|
193
|
+
'data-focus-lock-disabled',
|
|
194
|
+
'data-focus-on-hidden',
|
|
195
|
+
'data-no-focus-lock',
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
// Check container and ancestors
|
|
199
|
+
let el = container;
|
|
200
|
+
while (el && el !== document.documentElement) {
|
|
201
|
+
for (const attr of libraryAttrs) {
|
|
202
|
+
if (el.hasAttribute(attr)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
el = el.parentElement;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for sentinel/guard elements: empty <div tabindex="0"> at container boundaries
|
|
210
|
+
const children = container.children;
|
|
211
|
+
if (children.length >= 2) {
|
|
212
|
+
const first = children[0];
|
|
213
|
+
const last = children[children.length - 1];
|
|
214
|
+
if (isSentinelElement(first) || isSentinelElement(last)) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check if element looks like a focus trap sentinel (guard) element
|
|
224
|
+
* @param {Element} el
|
|
225
|
+
* @returns {boolean}
|
|
226
|
+
*/
|
|
227
|
+
function isSentinelElement(el) {
|
|
228
|
+
if (!el) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
return (
|
|
232
|
+
el.tagName === 'DIV' &&
|
|
233
|
+
el.getAttribute('tabindex') === '0' &&
|
|
234
|
+
el.textContent.trim() === '' &&
|
|
235
|
+
el.children.length === 0
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check for keyboard event listeners on container or ancestors
|
|
241
|
+
* @param {Element} container
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
function checkKeyboardListeners(container) {
|
|
245
|
+
const keyboardEvents = ['keydown', 'keypress'];
|
|
246
|
+
return hasEventOnChain(container, keyboardEvents);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check for focus-related event listeners on container or ancestors
|
|
251
|
+
* @param {Element} container
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
function checkFocusListeners(container) {
|
|
255
|
+
const focusEvents = ['focusin', 'focusout', 'focus', 'blur'];
|
|
256
|
+
return hasEventOnChain(container, focusEvents);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if any element in the ancestor chain has one of the given event types
|
|
261
|
+
* @param {Element} container
|
|
262
|
+
* @param {string[]} eventTypes
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
function hasEventOnChain(container, eventTypes) {
|
|
266
|
+
let el = container;
|
|
267
|
+
while (el && el !== document.documentElement) {
|
|
268
|
+
// Check via getEventListeners (tracks addEventListener + property handlers)
|
|
269
|
+
const listeners = getEventListeners(el);
|
|
270
|
+
for (const eventType of eventTypes) {
|
|
271
|
+
if (listeners[eventType] && listeners[eventType].length > 0) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Check inline attribute handlers
|
|
276
|
+
for (const eventType of eventTypes) {
|
|
277
|
+
if (el.hasAttribute('on' + eventType)) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
el = el.parentElement;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Selectors for elements that are naturally focusable (before tabindex override)
|
|
288
|
+
* @type {string}
|
|
289
|
+
*/
|
|
290
|
+
const NATURALLY_FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea';
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if siblings of the container have tabindex="-1" on naturally focusable elements,
|
|
294
|
+
* indicating manual focus removal to create a trap
|
|
295
|
+
* @param {Element} container
|
|
296
|
+
* @returns {boolean}
|
|
297
|
+
*/
|
|
298
|
+
function checkTabindexManipulation(container) {
|
|
299
|
+
const parent = container.parentElement;
|
|
300
|
+
if (!parent) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const siblings = Array.from(parent.children).filter(child => child !== container);
|
|
304
|
+
if (siblings.length === 0) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
// Check if any sibling has naturally focusable descendants with tabindex="-1"
|
|
308
|
+
for (const sibling of siblings) {
|
|
309
|
+
const focusable = sibling.querySelectorAll(NATURALLY_FOCUSABLE);
|
|
310
|
+
if (
|
|
311
|
+
focusable.length > 0 &&
|
|
312
|
+
Array.from(focusable).every(el => el.getAttribute('tabindex') === '-1')
|
|
313
|
+
) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if all sibling elements of the container have aria-hidden="true"
|
|
322
|
+
* @param {Element} container
|
|
323
|
+
* @returns {boolean}
|
|
324
|
+
*/
|
|
325
|
+
function checkAriaHiddenSiblings(container) {
|
|
326
|
+
const parent = container.parentElement;
|
|
327
|
+
if (!parent) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const siblings = Array.from(parent.children).filter(child => child !== container);
|
|
331
|
+
if (siblings.length === 0) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return siblings.every(sibling => sibling.getAttribute('aria-hidden') === 'true');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Check if container has a visible escape mechanism (close/cancel/dismiss button)
|
|
339
|
+
* @param {Element} container
|
|
340
|
+
* @returns {boolean}
|
|
341
|
+
*/
|
|
342
|
+
function hasEscapeMechanism(container) {
|
|
343
|
+
const escapePatterns = /\b(close|cancel|dismiss|exit|back|done|ok|go\s*back)\b/i;
|
|
344
|
+
const buttons = container.querySelectorAll(
|
|
345
|
+
'button, [role="button"], input[type="button"], input[type="submit"]'
|
|
346
|
+
);
|
|
347
|
+
for (const btn of buttons) {
|
|
348
|
+
const text = btn.textContent.trim();
|
|
349
|
+
const ariaLabel = btn.getAttribute('aria-label') || '';
|
|
350
|
+
const title = btn.getAttribute('title') || '';
|
|
351
|
+
if (
|
|
352
|
+
escapePatterns.test(text) ||
|
|
353
|
+
escapePatterns.test(ariaLabel) ||
|
|
354
|
+
escapePatterns.test(title)
|
|
355
|
+
) {
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Roles and element types where a focus trap is inappropriate
|
|
364
|
+
* @type {Set<string>}
|
|
365
|
+
*/
|
|
366
|
+
const INAPPROPRIATE_TRAP_ROLES = new Set([
|
|
367
|
+
'menu',
|
|
368
|
+
'menubar',
|
|
369
|
+
'tooltip',
|
|
370
|
+
'listbox',
|
|
371
|
+
'tree',
|
|
372
|
+
'grid',
|
|
373
|
+
'tablist',
|
|
374
|
+
'tabpanel',
|
|
375
|
+
'toolbar',
|
|
376
|
+
'navigation',
|
|
377
|
+
'complementary',
|
|
378
|
+
'banner',
|
|
379
|
+
'contentinfo',
|
|
380
|
+
'search',
|
|
381
|
+
'status',
|
|
382
|
+
'log',
|
|
383
|
+
'marquee',
|
|
384
|
+
'timer',
|
|
385
|
+
'feed',
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* HTML elements that map to landmark/widget roles where trapping is inappropriate
|
|
390
|
+
* @type {Set<string>}
|
|
391
|
+
*/
|
|
392
|
+
const INAPPROPRIATE_TRAP_ELEMENTS = new Set([
|
|
393
|
+
'NAV',
|
|
394
|
+
'ASIDE',
|
|
395
|
+
'HEADER',
|
|
396
|
+
'FOOTER',
|
|
397
|
+
'FORM',
|
|
398
|
+
'SECTION',
|
|
399
|
+
]);
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Check if the container has an ARIA role or element type that is inappropriate for focus trapping
|
|
403
|
+
* @param {Element} container
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
function hasInappropriateTrapRole(container) {
|
|
407
|
+
const role = container.getAttribute('role');
|
|
408
|
+
if (role && INAPPROPRIATE_TRAP_ROLES.has(role)) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
if (INAPPROPRIATE_TRAP_ELEMENTS.has(container.tagName)) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Check if the container is nested inside another focus trap
|
|
419
|
+
* @param {Element} container
|
|
420
|
+
* @returns {boolean}
|
|
421
|
+
*/
|
|
422
|
+
function isNestedTrap(container) {
|
|
423
|
+
const trapAttrs = [
|
|
424
|
+
'data-focus-trap',
|
|
425
|
+
'data-focus-lock',
|
|
426
|
+
'data-focus-guard',
|
|
427
|
+
'data-focus-lock-disabled',
|
|
428
|
+
];
|
|
429
|
+
let el = container.parentElement;
|
|
430
|
+
while (el && el !== document.documentElement) {
|
|
431
|
+
// Check for modal dialog ancestor
|
|
432
|
+
if (el.tagName === 'DIALOG' && el.hasAttribute('open')) {
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
const role = el.getAttribute('role');
|
|
436
|
+
if (
|
|
437
|
+
(role === 'dialog' || role === 'alertdialog') &&
|
|
438
|
+
el.getAttribute('aria-modal') === 'true'
|
|
439
|
+
) {
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
// Check for library trap markers on ancestors (but not the container itself)
|
|
443
|
+
for (const attr of trapAttrs) {
|
|
444
|
+
if (el.hasAttribute(attr)) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
el = el.parentElement;
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Check if the container or its descendants include a contenteditable element
|
|
455
|
+
* @param {Element} container
|
|
456
|
+
* @returns {boolean}
|
|
457
|
+
*/
|
|
458
|
+
function hasContenteditableTrap(container) {
|
|
459
|
+
if (container.getAttribute('contenteditable') === 'true') {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
return container.querySelector('[contenteditable="true"]') !== null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Check if the trap covers the entire page (body or sole child of body)
|
|
467
|
+
* @param {Element} container
|
|
468
|
+
* @returns {boolean}
|
|
469
|
+
*/
|
|
470
|
+
function isFullPageTrap(container) {
|
|
471
|
+
if (container === document.body) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
// Sole child of body (no meaningful siblings)
|
|
475
|
+
if (container.parentElement === document.body) {
|
|
476
|
+
const siblings = Array.from(document.body.children).filter(child => child !== container);
|
|
477
|
+
if (siblings.length === 0) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = { detectFocusTrap };
|
package/src/getAccessibleText.js
CHANGED
|
@@ -73,12 +73,28 @@ function collectSubtreeText(node, visibleOnly) {
|
|
|
73
73
|
|
|
74
74
|
// In visibleOnly mode, skip non-rendered content
|
|
75
75
|
if (visibleOnly) {
|
|
76
|
-
if (tag === 'style' || tag === 'script') {
|
|
76
|
+
if (tag === 'style' || tag === 'script' || tag === 'noscript') {
|
|
77
77
|
continue;
|
|
78
78
|
}
|
|
79
79
|
if (child.getAttribute('aria-hidden') === 'true') {
|
|
80
80
|
continue;
|
|
81
81
|
}
|
|
82
|
+
if (child.hasAttribute('hidden')) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Stop at nested widget containers — their content is not
|
|
86
|
+
// part of the parent element's visible label
|
|
87
|
+
const childRole = child.getAttribute('role');
|
|
88
|
+
if (
|
|
89
|
+
childRole === 'menu' ||
|
|
90
|
+
childRole === 'menubar' ||
|
|
91
|
+
childRole === 'listbox' ||
|
|
92
|
+
childRole === 'tree' ||
|
|
93
|
+
childRole === 'grid' ||
|
|
94
|
+
childRole === 'tablist'
|
|
95
|
+
) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
if (!visibleOnly) {
|
|
@@ -103,6 +119,15 @@ function collectSubtreeText(node, visibleOnly) {
|
|
|
103
119
|
}
|
|
104
120
|
continue;
|
|
105
121
|
}
|
|
122
|
+
|
|
123
|
+
// Elements with role="img" contribute their aria-label
|
|
124
|
+
if (child.getAttribute('role') === 'img' && child.hasAttribute('aria-label')) {
|
|
125
|
+
const ariaLabel = child.getAttribute('aria-label').trim();
|
|
126
|
+
if (ariaLabel) {
|
|
127
|
+
parts.push(ariaLabel);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
// Recurse into other element children
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,7 @@ const hasAttribute = require('./hasAttribute.js');
|
|
|
45
45
|
// Focus management
|
|
46
46
|
const getFocusableElements = require('./getFocusableElements.js');
|
|
47
47
|
const isFocusable = require('./isFocusable.js');
|
|
48
|
+
const detectFocusTrap = require('./detectFocusTrap.js');
|
|
48
49
|
|
|
49
50
|
// Role computation
|
|
50
51
|
const getComputedRole = require('./getComputedRole.js');
|
|
@@ -114,6 +115,7 @@ module.exports = {
|
|
|
114
115
|
...hasAttribute,
|
|
115
116
|
...getFocusableElements,
|
|
116
117
|
...isFocusable,
|
|
118
|
+
...detectFocusTrap,
|
|
117
119
|
...getComputedRole,
|
|
118
120
|
getImageText,
|
|
119
121
|
...testContrast,
|