@afixt/test-utils 2.1.1 → 2.3.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/BROWSER_TESTING.md +12 -12
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/src/constants.js +1 -0
- package/src/domUtils.js +45 -11
- package/src/formUtils.js +5 -2
- package/src/getAccessibleName.js +37 -51
- package/src/getAccessibleText.js +3 -3
- package/src/getComputedRole.js +187 -122
- package/src/getImageText.js +8 -1
- package/src/index.js +4 -0
- package/src/isA11yVisible.js +13 -7
- package/src/isHidden.js +11 -4
- package/src/shadowDomUtils.js +118 -0
- package/src/stringUtils.js +5 -2
- package/src/testContrast.js +42 -1
- package/test/domUtils.test.js +52 -0
- package/test/formUtils.test.js +39 -0
- package/test/getAccessibleName.test.js +83 -0
- package/test/getComputedRole.test.js +248 -176
- package/test/isA11yVisible.test.js +33 -0
- package/test/isHidden.test.js +18 -0
- package/test/playwright/colon-id-a11y-visible.spec.js +160 -0
- package/test/playwright/fixtures/colon-id-a11y-visible.html +48 -0
- package/test/shadowDomUtils.test.js +248 -0
- package/test/testContrast.test.js +42 -5
- package/todo.md +3 -2
package/src/getComputedRole.js
CHANGED
|
@@ -1,65 +1,65 @@
|
|
|
1
1
|
const roleMapping = {
|
|
2
|
-
|
|
2
|
+
a: {
|
|
3
3
|
'[href]': 'link',
|
|
4
|
-
':not([href])': 'text'
|
|
4
|
+
':not([href])': 'text',
|
|
5
5
|
},
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
abbr: 'text',
|
|
7
|
+
address: 'text',
|
|
8
|
+
area: {
|
|
9
9
|
'[href]': 'link',
|
|
10
|
-
':not([href])': 'text'
|
|
10
|
+
':not([href])': 'text',
|
|
11
11
|
},
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
12
|
+
article: 'article',
|
|
13
|
+
aside: 'complementary',
|
|
14
|
+
audio: 'group',
|
|
15
|
+
b: 'text',
|
|
16
|
+
base: 'noRole',
|
|
17
|
+
bdi: 'noRole',
|
|
18
|
+
bdo: 'text',
|
|
19
|
+
blockquote: 'text',
|
|
20
|
+
body: 'document',
|
|
21
|
+
br: 'noRole',
|
|
22
|
+
button: 'button',
|
|
23
|
+
canvas: 'image',
|
|
24
|
+
caption: 'text',
|
|
25
|
+
cite: 'text',
|
|
26
|
+
code: 'text',
|
|
27
|
+
col: 'noRole',
|
|
28
|
+
colgroup: 'group',
|
|
29
|
+
data: 'noRole',
|
|
30
|
+
datalist: 'listbox',
|
|
31
|
+
dd: 'definition',
|
|
32
|
+
del: 'text',
|
|
33
|
+
details: 'group',
|
|
34
|
+
dfn: 'term',
|
|
35
|
+
dialog: 'dialog',
|
|
36
|
+
div: 'group',
|
|
37
|
+
dl: 'list',
|
|
38
|
+
dt: 'term',
|
|
39
|
+
em: 'text',
|
|
40
|
+
embed: 'noRole',
|
|
41
|
+
fieldset: 'group',
|
|
42
|
+
figcaption: 'text',
|
|
43
|
+
figure: 'figure',
|
|
44
|
+
footer: 'contentinfo',
|
|
45
|
+
form: 'form',
|
|
46
|
+
h1: 'heading',
|
|
47
|
+
h2: 'heading',
|
|
48
|
+
h3: 'heading',
|
|
49
|
+
h4: 'heading',
|
|
50
|
+
h5: 'heading',
|
|
51
|
+
h6: 'heading',
|
|
52
|
+
head: 'noRole',
|
|
53
|
+
header: 'banner',
|
|
54
|
+
hr: 'separator',
|
|
55
|
+
html: 'noRole',
|
|
56
|
+
i: 'text',
|
|
57
|
+
iframe: 'noRole',
|
|
58
|
+
img: {
|
|
59
59
|
'[alt=""]': 'presentation',
|
|
60
|
-
|
|
60
|
+
img: 'image',
|
|
61
61
|
},
|
|
62
|
-
|
|
62
|
+
input: {
|
|
63
63
|
'[type="button"]': 'button',
|
|
64
64
|
'[type="checkbox"]': 'checkbox',
|
|
65
65
|
'[type="hidden"]': 'noRole',
|
|
@@ -74,85 +74,150 @@ const roleMapping = {
|
|
|
74
74
|
'[type="search"]': 'searchbox',
|
|
75
75
|
'[type="tel"]': 'textbox',
|
|
76
76
|
'[type="text"]': 'textbox',
|
|
77
|
-
'[type="url"]': 'textbox'
|
|
77
|
+
'[type="url"]': 'textbox',
|
|
78
78
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
79
|
+
ins: 'text',
|
|
80
|
+
kbd: 'text',
|
|
81
|
+
label: 'text',
|
|
82
|
+
legend: 'text',
|
|
83
|
+
li: 'listitem',
|
|
84
|
+
link: 'noRole',
|
|
85
|
+
main: 'main',
|
|
86
|
+
map: 'noRole',
|
|
87
|
+
mark: 'text',
|
|
88
|
+
math: 'math',
|
|
89
|
+
menu: 'menu',
|
|
90
|
+
meta: 'noRole',
|
|
91
|
+
meter: 'text',
|
|
92
|
+
nav: 'navigation',
|
|
93
|
+
noscript: 'noRole',
|
|
94
|
+
object: 'noRole',
|
|
95
|
+
ol: 'list',
|
|
96
|
+
optgroup: 'group',
|
|
97
|
+
option: 'option',
|
|
98
|
+
output: 'status',
|
|
99
|
+
p: 'text',
|
|
100
|
+
param: 'noRole',
|
|
101
|
+
picture: 'noRole',
|
|
102
|
+
pre: 'text',
|
|
103
|
+
progress: 'progressbar',
|
|
104
|
+
q: 'text',
|
|
105
|
+
rb: 'noRole',
|
|
106
|
+
rp: 'noRole',
|
|
107
|
+
rt: 'text',
|
|
108
|
+
rtc: 'noRole',
|
|
109
|
+
ruby: 'text',
|
|
110
|
+
s: 'text',
|
|
111
|
+
samp: 'text',
|
|
112
|
+
script: 'noRole',
|
|
113
|
+
section: 'region',
|
|
114
|
+
select: 'combobox',
|
|
115
|
+
small: 'text',
|
|
116
|
+
source: 'noRole',
|
|
117
|
+
span: 'group',
|
|
118
|
+
strong: 'text',
|
|
119
|
+
style: 'noRole',
|
|
120
|
+
sub: 'text',
|
|
121
|
+
summary: 'button',
|
|
122
|
+
sup: 'text',
|
|
123
|
+
svg: 'noRole',
|
|
124
|
+
table: 'table',
|
|
125
|
+
tbody: 'rowgroup',
|
|
126
|
+
td: 'cell',
|
|
127
|
+
template: 'noRole',
|
|
128
|
+
textarea: 'textbox',
|
|
129
|
+
tfoot: 'rowgroup',
|
|
130
|
+
th: 'columnheader',
|
|
131
|
+
thead: 'rowgroup',
|
|
132
|
+
time: 'text',
|
|
133
|
+
title: 'noRole',
|
|
134
|
+
tr: 'row',
|
|
135
|
+
track: 'noRole',
|
|
136
|
+
u: 'text',
|
|
137
|
+
ul: 'list',
|
|
138
|
+
var: 'text',
|
|
139
|
+
video: 'group',
|
|
140
|
+
wbr: 'noRole',
|
|
141
141
|
};
|
|
142
142
|
|
|
143
143
|
/**
|
|
144
|
-
*
|
|
144
|
+
* Sectioning elements that change the implicit role of header/footer
|
|
145
|
+
* from landmark (banner/contentinfo) to generic.
|
|
146
|
+
*/
|
|
147
|
+
const SECTIONING_ELEMENTS = ['article', 'aside', 'main', 'nav', 'section'];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if an element has an ancestor with a given role or tag name.
|
|
151
|
+
* @param {HTMLElement} element - The element to check
|
|
152
|
+
* @param {Object} options - Search options
|
|
153
|
+
* @param {string[]} [options.roles] - ARIA roles to match
|
|
154
|
+
* @param {string[]} [options.tags] - Tag names (lowercase) to match
|
|
155
|
+
* @returns {boolean} True if a matching ancestor is found
|
|
156
|
+
*/
|
|
157
|
+
function hasAncestor(element, { roles = [], tags = [] }) {
|
|
158
|
+
let ancestor = element.parentElement;
|
|
159
|
+
while (ancestor) {
|
|
160
|
+
const ancestorRole = (ancestor.getAttribute('role') || '').toLowerCase();
|
|
161
|
+
if (roles.length && roles.includes(ancestorRole)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const ancestorTag = ancestor.tagName.toLowerCase();
|
|
165
|
+
if (tags.length && tags.includes(ancestorTag)) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
ancestor = ancestor.parentElement;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Gets the computed role of an HTML element, considering ancestor context
|
|
175
|
+
* for context-dependent implicit roles per HTML-AAM.
|
|
145
176
|
*
|
|
146
177
|
* @param {HTMLElement} element - The HTML element to get the role for.
|
|
147
178
|
* @returns {string|undefined} The computed role of the element, or undefined if no role is found.
|
|
148
179
|
*/
|
|
149
180
|
function getComputedRole(element) {
|
|
150
|
-
if (!element)
|
|
181
|
+
if (!element) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
151
184
|
|
|
152
185
|
const roleAttr = element.getAttribute('role');
|
|
153
|
-
if (roleAttr)
|
|
186
|
+
if (roleAttr) {
|
|
187
|
+
return roleAttr;
|
|
188
|
+
}
|
|
154
189
|
|
|
155
190
|
const tagName = element.tagName.toLowerCase();
|
|
191
|
+
|
|
192
|
+
// Context-dependent implicit roles per HTML-AAM
|
|
193
|
+
if (tagName === 'td') {
|
|
194
|
+
if (hasAncestor(element, { roles: ['grid', 'treegrid'] })) {
|
|
195
|
+
return 'gridcell';
|
|
196
|
+
}
|
|
197
|
+
return 'cell';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (tagName === 'th') {
|
|
201
|
+
if (hasAncestor(element, { roles: ['grid', 'treegrid'] })) {
|
|
202
|
+
return 'columnheader';
|
|
203
|
+
}
|
|
204
|
+
return 'columnheader';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (tagName === 'header') {
|
|
208
|
+
if (hasAncestor(element, { tags: SECTIONING_ELEMENTS })) {
|
|
209
|
+
return 'generic';
|
|
210
|
+
}
|
|
211
|
+
return 'banner';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (tagName === 'footer') {
|
|
215
|
+
if (hasAncestor(element, { tags: SECTIONING_ELEMENTS })) {
|
|
216
|
+
return 'generic';
|
|
217
|
+
}
|
|
218
|
+
return 'contentinfo';
|
|
219
|
+
}
|
|
220
|
+
|
|
156
221
|
const role = roleMapping[tagName];
|
|
157
222
|
|
|
158
223
|
if (typeof role === 'string') {
|
|
@@ -170,5 +235,5 @@ function getComputedRole(element) {
|
|
|
170
235
|
|
|
171
236
|
module.exports = {
|
|
172
237
|
roleMapping,
|
|
173
|
-
getComputedRole
|
|
238
|
+
getComputedRole,
|
|
174
239
|
};
|
package/src/getImageText.js
CHANGED
|
@@ -26,7 +26,14 @@ async function getImageText(imagePath, options = {}) {
|
|
|
26
26
|
try {
|
|
27
27
|
const {
|
|
28
28
|
data: { text },
|
|
29
|
-
|
|
29
|
+
// errorHandler silences the bare `throw` in tesseract.js createWorker.js when a
|
|
30
|
+
// job is rejected — without it the throw escapes try/catch and becomes an uncaught
|
|
31
|
+
// exception in the host process. The promise rejection is still caught below.
|
|
32
|
+
} = await _internal.recognize(imagePath, lang, {
|
|
33
|
+
logger,
|
|
34
|
+
errorHandler: () => {},
|
|
35
|
+
...tesseractOptions,
|
|
36
|
+
});
|
|
30
37
|
|
|
31
38
|
const extractedText = text.trim();
|
|
32
39
|
return extractedText.length > 0 ? extractedText : false;
|
package/src/index.js
CHANGED
|
@@ -82,6 +82,9 @@ const tableUtils = require('./tableUtils.js');
|
|
|
82
82
|
// Query cache utilities
|
|
83
83
|
const queryCache = require('./queryCache.js');
|
|
84
84
|
|
|
85
|
+
// Shadow DOM utilities
|
|
86
|
+
const shadowDomUtils = require('./shadowDomUtils.js');
|
|
87
|
+
|
|
85
88
|
// List event listeners
|
|
86
89
|
const listEventListeners = require('./listEventListeners.js');
|
|
87
90
|
|
|
@@ -126,4 +129,5 @@ module.exports = {
|
|
|
126
129
|
...tableUtils,
|
|
127
130
|
...queryCache,
|
|
128
131
|
...listEventListeners,
|
|
132
|
+
...shadowDomUtils,
|
|
129
133
|
};
|
package/src/isA11yVisible.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { NON_VISIBLE_SELECTORS } = require('./constants.js');
|
|
2
2
|
const isHidden = require('./isHidden.js');
|
|
3
|
+
const { cssEscape } = require('./domUtils.js');
|
|
4
|
+
const { deepQuerySelectorAll } = require('./shadowDomUtils.js');
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Checks if an element is visible to assistive technologies (AT).
|
|
@@ -67,13 +69,17 @@ function isA11yVisible(element, strict = false) {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
// Check if element is referenced by aria-labelledby or aria-describedby
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
// Use CSS.escape(id) to handle IDs containing special characters like colons
|
|
73
|
+
// (e.g. Radix UI generates IDs like "radix-:rj:-tab-account")
|
|
74
|
+
const escapedId = id ? cssEscape(id) : '';
|
|
75
|
+
deepQuerySelectorAll(
|
|
76
|
+
document,
|
|
77
|
+
`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`
|
|
78
|
+
).forEach(referencingElement => {
|
|
79
|
+
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
80
|
+
visible = true;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
77
83
|
|
|
78
84
|
// Check if any parent has aria-hidden="true" when strict mode is on
|
|
79
85
|
if (visible && strict) {
|
package/src/isHidden.js
CHANGED
|
@@ -18,15 +18,22 @@ const isHidden = (element, options = {}) => {
|
|
|
18
18
|
|
|
19
19
|
const { checkAriaHidden = false, checkOpacity = false, checkDimensions = false } = options;
|
|
20
20
|
|
|
21
|
-
// Check the hidden attribute
|
|
22
|
-
|
|
21
|
+
// Check the hidden attribute — but not hidden="until-found", which is
|
|
22
|
+
// visually hidden but still findable by Find-in-Page and accessible to AT.
|
|
23
|
+
// In real browsers, hidden="until-found" uses content-visibility:hidden
|
|
24
|
+
// rather than display:none.
|
|
25
|
+
const hiddenAttr = element.getAttribute('hidden');
|
|
26
|
+
const isUntilFound = hiddenAttr === 'until-found';
|
|
27
|
+
if (hiddenAttr !== null && !isUntilFound) {
|
|
23
28
|
return true;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
// Use computed style to catch CSS class/stylesheet rules
|
|
31
|
+
// Use computed style to catch CSS class/stylesheet rules.
|
|
32
|
+
// Skip display:none check for hidden="until-found" because JSDOM
|
|
33
|
+
// incorrectly applies display:none (real browsers don't).
|
|
27
34
|
const style = window.getComputedStyle(element);
|
|
28
35
|
|
|
29
|
-
if (style.display === 'none') {
|
|
36
|
+
if (style.display === 'none' && !isUntilFound) {
|
|
30
37
|
return true;
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Shadow-DOM-aware element lookup utilities
|
|
3
|
+
* @module shadowDomUtils
|
|
4
|
+
* @description Provides deep traversal functions that cross open shadow DOM
|
|
5
|
+
* boundaries when looking up elements by ID or CSS selector.
|
|
6
|
+
*
|
|
7
|
+
* **Limitation:** Closed shadow roots (created with `{ mode: 'closed' }`) are
|
|
8
|
+
* inaccessible by spec — `.shadowRoot` returns `null` for them — so these
|
|
9
|
+
* utilities can only traverse *open* shadow roots.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Finds an element by ID, searching through open shadow roots when the
|
|
14
|
+
* element is not found in the light DOM.
|
|
15
|
+
*
|
|
16
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
17
|
+
* Typically `document`, but can be any subtree root or ShadowRoot.
|
|
18
|
+
* @param {string} id - The ID to search for.
|
|
19
|
+
* @returns {Element|null} The first matching element, or `null`.
|
|
20
|
+
*/
|
|
21
|
+
function deepGetElementById(root, id) {
|
|
22
|
+
if (!root || !id) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Fast path: root supports getElementById (Document and ShadowRoot do)
|
|
27
|
+
if (typeof root.getElementById === 'function') {
|
|
28
|
+
const el = root.getElementById(id);
|
|
29
|
+
if (el) {
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Recurse into open shadow roots
|
|
35
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
36
|
+
for (const el of allElements) {
|
|
37
|
+
if (el.shadowRoot) {
|
|
38
|
+
const found = deepGetElementById(el.shadowRoot, id);
|
|
39
|
+
if (found) {
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs `querySelector` against the given root and, if no match is found,
|
|
50
|
+
* recursively searches open shadow roots.
|
|
51
|
+
*
|
|
52
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
53
|
+
* @param {string} selector - A CSS selector string.
|
|
54
|
+
* @returns {Element|null} The first matching element, or `null`.
|
|
55
|
+
*/
|
|
56
|
+
function deepQuerySelector(root, selector) {
|
|
57
|
+
if (!root || !selector) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fast path
|
|
62
|
+
if (typeof root.querySelector === 'function') {
|
|
63
|
+
const el = root.querySelector(selector);
|
|
64
|
+
if (el) {
|
|
65
|
+
return el;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Recurse into open shadow roots
|
|
70
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
71
|
+
for (const el of allElements) {
|
|
72
|
+
if (el.shadowRoot) {
|
|
73
|
+
const found = deepQuerySelector(el.shadowRoot, selector);
|
|
74
|
+
if (found) {
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Runs `querySelectorAll` against the given root and then recursively
|
|
85
|
+
* collects matches from all open shadow roots.
|
|
86
|
+
*
|
|
87
|
+
* @param {Document|ShadowRoot|Element} root - The root to start searching from.
|
|
88
|
+
* @param {string} selector - A CSS selector string.
|
|
89
|
+
* @returns {Element[]} An array of all matching elements (may be empty).
|
|
90
|
+
*/
|
|
91
|
+
function deepQuerySelectorAll(root, selector) {
|
|
92
|
+
if (!root || !selector) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const results = [];
|
|
97
|
+
|
|
98
|
+
// Collect from this root
|
|
99
|
+
if (typeof root.querySelectorAll === 'function') {
|
|
100
|
+
results.push(...root.querySelectorAll(selector));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recurse into open shadow roots
|
|
104
|
+
const allElements = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
|
105
|
+
for (const el of allElements) {
|
|
106
|
+
if (el.shadowRoot) {
|
|
107
|
+
results.push(...deepQuerySelectorAll(el.shadowRoot, selector));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
deepGetElementById,
|
|
116
|
+
deepQuerySelector,
|
|
117
|
+
deepQuerySelectorAll,
|
|
118
|
+
};
|
package/src/stringUtils.js
CHANGED
|
@@ -2,9 +2,12 @@ const { GENERIC_TITLES, GENERIC_LINK_TEXT } = require('./constants.js');
|
|
|
2
2
|
|
|
3
3
|
const stringUtils = (function () {
|
|
4
4
|
/**
|
|
5
|
-
* Check if a string is empty or only contains whitespace.
|
|
5
|
+
* Check if a string is empty or only contains standard whitespace.
|
|
6
|
+
* NOTE: For accessibility name/label checks, prefer {@link isEmptyOrWhitespace}
|
|
7
|
+
* which also catches non-breaking spaces (\u00A0), zero-width spaces (\u200B),
|
|
8
|
+
* and other invisible Unicode characters that do not constitute meaningful text.
|
|
6
9
|
* @param {string} str - The string to check.
|
|
7
|
-
* @returns {boolean} Whether the string is empty.
|
|
10
|
+
* @returns {boolean} Whether the string is empty or whitespace-only.
|
|
8
11
|
*/
|
|
9
12
|
function isEmpty(str) {
|
|
10
13
|
return !str || str.trim().length === 0;
|
package/src/testContrast.js
CHANGED
|
@@ -45,6 +45,40 @@ function hasBackgroundImage(el) {
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Check if an element or any of its ancestors has a CSS filter or mix-blend-mode
|
|
50
|
+
* that would alter effective colors in ways that cannot be computed from CSS values alone.
|
|
51
|
+
* @param {Element} el - The element to check
|
|
52
|
+
* @returns {boolean} True if the element or an ancestor has a color-altering CSS property
|
|
53
|
+
*/
|
|
54
|
+
function hasEffectColorModifier(el) {
|
|
55
|
+
if (!el || el.nodeType === 9 || !window.getComputedStyle) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let current = el;
|
|
60
|
+
while (current && current.nodeType !== 9) {
|
|
61
|
+
const styles = window.getComputedStyle(current);
|
|
62
|
+
if (!styles) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filter = styles.getPropertyValue('filter');
|
|
67
|
+
if (filter && filter !== 'none') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mixBlendMode = styles.getPropertyValue('mix-blend-mode');
|
|
72
|
+
if (mixBlendMode && mixBlendMode !== 'normal') {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
current = current.parentElement;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
/**
|
|
49
83
|
* Get the computed background color for an element.
|
|
50
84
|
* @param {Element} el - the element to be tested
|
|
@@ -290,7 +324,13 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
290
324
|
// Skip elements with a background image on the element itself or a visible ancestor.
|
|
291
325
|
// Contrast cannot be reliably tested against background images.
|
|
292
326
|
if (hasBackgroundImage(el)) {
|
|
293
|
-
return
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Skip elements affected by CSS filters or mix-blend-mode (self or ancestor).
|
|
331
|
+
// These alter effective colors in ways that cannot be computed from CSS values alone.
|
|
332
|
+
if (hasEffectColorModifier(el)) {
|
|
333
|
+
return null;
|
|
294
334
|
}
|
|
295
335
|
|
|
296
336
|
const styles = window.getComputedStyle(el);
|
|
@@ -400,6 +440,7 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
400
440
|
module.exports = {
|
|
401
441
|
testContrast,
|
|
402
442
|
hasBackgroundImage,
|
|
443
|
+
hasEffectColorModifier,
|
|
403
444
|
getComputedBackgroundColor,
|
|
404
445
|
luminance,
|
|
405
446
|
parseRGB,
|