@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
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
|
/**
|
package/src/cssUtils.js
CHANGED
|
@@ -3,6 +3,53 @@
|
|
|
3
3
|
* @module cssUtils
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { hasCSSGeneratedContent } = require('./hasCSSGeneratedContent.js');
|
|
7
|
+
|
|
8
|
+
const ICON_FONT_CLASS_PREFIXES = ['icon-', 'i-'];
|
|
9
|
+
const ICON_FONT_CLASSES = [
|
|
10
|
+
'fa',
|
|
11
|
+
'fas',
|
|
12
|
+
'far',
|
|
13
|
+
'fal',
|
|
14
|
+
'fad',
|
|
15
|
+
'fab',
|
|
16
|
+
'glyphicon',
|
|
17
|
+
'material-icons',
|
|
18
|
+
'material-symbols-outlined',
|
|
19
|
+
'material-symbols-rounded',
|
|
20
|
+
'dashicons',
|
|
21
|
+
'bi',
|
|
22
|
+
'octicon',
|
|
23
|
+
'feather',
|
|
24
|
+
'fi',
|
|
25
|
+
'typcn',
|
|
26
|
+
'wi',
|
|
27
|
+
'zmdi',
|
|
28
|
+
'icofont',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a single class name matches known icon font patterns.
|
|
33
|
+
* @param {string} cls - The class name to check
|
|
34
|
+
* @returns {boolean} True if the class matches an icon font pattern
|
|
35
|
+
*/
|
|
36
|
+
function matchesIconFontPattern(cls) {
|
|
37
|
+
if (ICON_FONT_CLASSES.includes(cls)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
for (const prefix of ICON_FONT_CLASS_PREFIXES) {
|
|
41
|
+
if (cls.startsWith(prefix)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const iconClass of ICON_FONT_CLASSES) {
|
|
46
|
+
if (cls.startsWith(iconClass + '-')) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
6
53
|
const cssUtils = {
|
|
7
54
|
/**
|
|
8
55
|
* Check if an element's background is transparent/none.
|
|
@@ -29,12 +76,81 @@ const cssUtils = {
|
|
|
29
76
|
return style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0;
|
|
30
77
|
},
|
|
31
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Check if the element or any descendant has a class matching common icon font patterns.
|
|
81
|
+
* @param {HTMLElement} element - The element to check
|
|
82
|
+
* @returns {boolean} True if an icon font class is found
|
|
83
|
+
*/
|
|
84
|
+
hasIconFontClass(element) {
|
|
85
|
+
if (!element) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const elements = [element, ...element.querySelectorAll('*')];
|
|
90
|
+
for (const el of elements) {
|
|
91
|
+
if (el.classList) {
|
|
92
|
+
for (const cls of el.classList) {
|
|
93
|
+
if (matchesIconFontPattern(cls)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if the element has any visual indicator beyond text styling,
|
|
104
|
+
* such as CSS-generated content, icon font classes, background images on children,
|
|
105
|
+
* or inline img/svg elements.
|
|
106
|
+
* @param {HTMLElement} element - The element to check
|
|
107
|
+
* @returns {boolean} True if the element has a visual indicator
|
|
108
|
+
*/
|
|
109
|
+
hasVisualIndicator(element) {
|
|
110
|
+
if (!element) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for CSS-generated content on the element itself
|
|
115
|
+
if (hasCSSGeneratedContent(element)) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for icon font classes on element or descendants
|
|
120
|
+
if (cssUtils.hasIconFontClass(element)) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check descendants for background-image, <img>, or <svg>
|
|
125
|
+
const descendants = element.querySelectorAll('*');
|
|
126
|
+
for (const child of descendants) {
|
|
127
|
+
const tagName = child.tagName ? child.tagName.toLowerCase() : '';
|
|
128
|
+
if (tagName === 'img' || tagName === 'svg') {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const childStyle = window.getComputedStyle(child);
|
|
133
|
+
if (childStyle.backgroundImage && childStyle.backgroundImage !== 'none') {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// skip if getComputedStyle fails
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return false;
|
|
142
|
+
},
|
|
143
|
+
|
|
32
144
|
/**
|
|
33
145
|
* Check if an element looks like regular text (no underline, background, or border).
|
|
34
146
|
* @param {HTMLElement} element - The element to check
|
|
35
147
|
* @returns {boolean} True if element looks like regular text
|
|
36
148
|
*/
|
|
37
149
|
looksLikeText(element) {
|
|
150
|
+
if (cssUtils.hasVisualIndicator(element)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
38
154
|
const style = window.getComputedStyle(element);
|
|
39
155
|
|
|
40
156
|
const noUnderline = !style.textDecoration.toLowerCase().includes('underline');
|
|
@@ -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 };
|