@afixt/test-utils 2.1.1 → 2.2.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 +1 -0
- package/src/domUtils.js +37 -4
- package/src/formUtils.js +3 -1
- package/src/getAccessibleName.js +26 -50
- package/src/getAccessibleText.js +3 -3
- package/src/getComputedRole.js +187 -122
- package/src/getImageText.js +4 -1
- package/src/isA11yVisible.js +5 -1
- package/src/isHidden.js +11 -4
- package/src/stringUtils.js +5 -2
- package/src/testContrast.js +42 -1
- package/test/domUtils.test.js +20 -0
- package/test/formUtils.test.js +18 -0
- package/test/getAccessibleName.test.js +44 -0
- package/test/getComputedRole.test.js +248 -176
- package/test/isA11yVisible.test.js +10 -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/testContrast.test.js +42 -5
package/package.json
CHANGED
package/src/constants.js
CHANGED
package/src/domUtils.js
CHANGED
|
@@ -7,6 +7,17 @@ const {
|
|
|
7
7
|
INTERACTIVE_HANDLER_ATTRIBUTES,
|
|
8
8
|
} = require('./constants.js');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Escapes a string for use inside a CSS selector.
|
|
12
|
+
* Uses the native CSS.escape when available (all modern browsers),
|
|
13
|
+
* falls back to identity for environments that lack it (e.g. JSDOM).
|
|
14
|
+
*
|
|
15
|
+
* @param {string} value
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
const cssEscape =
|
|
19
|
+
typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape : value => value;
|
|
20
|
+
|
|
10
21
|
const domUtils = {
|
|
11
22
|
/**
|
|
12
23
|
* Checks if the specified element has the given attribute.
|
|
@@ -108,7 +119,7 @@ const domUtils = {
|
|
|
108
119
|
duplicates.push(id);
|
|
109
120
|
}
|
|
110
121
|
});
|
|
111
|
-
return duplicates.map(id => document.querySelector(`#${id}`));
|
|
122
|
+
return duplicates.map(id => document.querySelector(`#${cssEscape(id)}`));
|
|
112
123
|
},
|
|
113
124
|
|
|
114
125
|
/**
|
|
@@ -162,7 +173,7 @@ const domUtils = {
|
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
// CSS-escape the ID for use in attribute selectors
|
|
165
|
-
const escaped =
|
|
176
|
+
const escaped = cssEscape(id);
|
|
166
177
|
|
|
167
178
|
// Direct attribute references
|
|
168
179
|
if (document.querySelector('[for="' + escaped + '"]')) {
|
|
@@ -226,10 +237,31 @@ const domUtils = {
|
|
|
226
237
|
if (!element) {
|
|
227
238
|
return false;
|
|
228
239
|
}
|
|
229
|
-
|
|
240
|
+
|
|
241
|
+
// Check aria-hidden (self or ancestor)
|
|
242
|
+
if (
|
|
230
243
|
element.getAttribute('aria-hidden') === 'true' ||
|
|
231
244
|
(element.closest && !!element.closest('[aria-hidden="true"]'))
|
|
232
|
-
)
|
|
245
|
+
) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check inert (self or ancestor) — inert elements are removed from the a11y tree
|
|
250
|
+
let el = element;
|
|
251
|
+
while (el) {
|
|
252
|
+
if (el.hasAttribute && el.hasAttribute('inert')) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
el = el.parentElement;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check content-visibility: hidden — genuinely hides content from AT
|
|
259
|
+
const style = window.getComputedStyle(element);
|
|
260
|
+
if (style.contentVisibility === 'hidden') {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return false;
|
|
233
265
|
},
|
|
234
266
|
|
|
235
267
|
/**
|
|
@@ -397,3 +429,4 @@ const domUtils = {
|
|
|
397
429
|
};
|
|
398
430
|
|
|
399
431
|
module.exports = domUtils;
|
|
432
|
+
module.exports.cssEscape = cssEscape;
|
package/src/formUtils.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* @module formUtils
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { isHiddenFromAT } = require('./domUtils.js');
|
|
7
|
+
|
|
6
8
|
const formUtils = {
|
|
7
9
|
/**
|
|
8
10
|
* Check if an element is a labellable form control per HTML spec.
|
|
@@ -50,7 +52,7 @@ const formUtils = {
|
|
|
50
52
|
const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
|
|
51
53
|
for (let i = 0; i < ids.length; i++) {
|
|
52
54
|
const labelEl = document.getElementById(ids[i]);
|
|
53
|
-
if (labelEl && labelEl.textContent.trim()) {
|
|
55
|
+
if (labelEl && !isHiddenFromAT(labelEl) && labelEl.textContent.trim()) {
|
|
54
56
|
return true;
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/getAccessibleName.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { isEmptyOrWhitespace } = require('./stringUtils.js');
|
|
2
2
|
const { getAccessibleText } = require('./getAccessibleText.js');
|
|
3
3
|
const {
|
|
4
4
|
TEXT_ROLES,
|
|
@@ -6,6 +6,8 @@ const {
|
|
|
6
6
|
LANDMARK_ROLES,
|
|
7
7
|
LANDMARK_ELEMENT_MAP,
|
|
8
8
|
} = require('./constants.js');
|
|
9
|
+
const { cssEscape } = require('./domUtils.js');
|
|
10
|
+
const { isA11yVisible } = require('./isA11yVisible.js');
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Gets the accessible name of an element according to the accessible name calculation algorithm
|
|
@@ -26,9 +28,21 @@ function getAccessibleName(element) {
|
|
|
26
28
|
// the title won't be used in any meaningful way by Accessibility APIs
|
|
27
29
|
const unlabellable = 'head *, hr, param, caption, colgroup, col, tbody, tfoot, thead, tr';
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (matchesSelector(element, unlabellable)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Skip inherently non-visible semantic elements (script, style, template, etc.)
|
|
36
|
+
// but exclude 'area' since area elements have accessible names via alt attribute
|
|
37
|
+
const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
|
|
38
|
+
if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// STEP 0 - Use isA11yVisible (strict mode) for visibility check.
|
|
43
|
+
// This respects aria-labelledby/describedby references, preventing false
|
|
44
|
+
// negatives for display:none elements that provide names to other elements.
|
|
45
|
+
if (!isA11yVisible(element, true)) {
|
|
32
46
|
return false;
|
|
33
47
|
}
|
|
34
48
|
|
|
@@ -58,7 +72,7 @@ function getAccessibleName(element) {
|
|
|
58
72
|
// STEP 2.1 - if aria-label exists, return the text in it
|
|
59
73
|
if (element.hasAttribute('aria-label')) {
|
|
60
74
|
const ariaLabel = element.getAttribute('aria-label');
|
|
61
|
-
if (ariaLabel) {
|
|
75
|
+
if (!isEmptyOrWhitespace(ariaLabel)) {
|
|
62
76
|
return ariaLabel;
|
|
63
77
|
}
|
|
64
78
|
// there is no 'else' here because an empty aria-label is/ should be ignored and calculation continued
|
|
@@ -75,7 +89,7 @@ function getAccessibleName(element) {
|
|
|
75
89
|
// Use getAccessibleText which handles both text nodes and
|
|
76
90
|
// image alt text in the subtree (not just textContent)
|
|
77
91
|
const text = getAccessibleText(element);
|
|
78
|
-
if (!
|
|
92
|
+
if (!isEmptyOrWhitespace(text)) {
|
|
79
93
|
return text;
|
|
80
94
|
}
|
|
81
95
|
}
|
|
@@ -95,12 +109,12 @@ function getAccessibleName(element) {
|
|
|
95
109
|
)
|
|
96
110
|
) {
|
|
97
111
|
// first we choose the explicit relationship over all others.
|
|
98
|
-
if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
|
|
112
|
+
if (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
|
|
99
113
|
id = element.id;
|
|
100
114
|
// Use only the *first* label that matches this ID.
|
|
101
115
|
// Sometimes JS libraries screw this up by hiding one of the
|
|
102
116
|
// labels or misnaming one
|
|
103
|
-
label = document.querySelector('label[for="' + id + '"]');
|
|
117
|
+
label = document.querySelector('label[for="' + cssEscape(id) + '"]');
|
|
104
118
|
if (label) {
|
|
105
119
|
return getAccessibleText(label);
|
|
106
120
|
}
|
|
@@ -210,11 +224,11 @@ function getAccessibleName(element) {
|
|
|
210
224
|
)
|
|
211
225
|
) {
|
|
212
226
|
// first we choose the explicit relationship over all others.
|
|
213
|
-
if (element.id && document.querySelector('label[for="' + element.id + '"]')) {
|
|
227
|
+
if (element.id && document.querySelector('label[for="' + cssEscape(element.id) + '"]')) {
|
|
214
228
|
id = element.id;
|
|
215
229
|
|
|
216
230
|
//Use only the *first* label that matches this ID. Sometimes ppl screw this up
|
|
217
|
-
label = document.querySelector('label[for="' + id + '"]');
|
|
231
|
+
label = document.querySelector('label[for="' + cssEscape(id) + '"]');
|
|
218
232
|
if (label) {
|
|
219
233
|
return getAccessibleText(label);
|
|
220
234
|
}
|
|
@@ -359,7 +373,7 @@ function getAccessibleName(element) {
|
|
|
359
373
|
if (element.tagName.toLowerCase() === 'meter') {
|
|
360
374
|
// Check for label with for attribute
|
|
361
375
|
if (element.id) {
|
|
362
|
-
const label = document.querySelector('label[for="' + element.id + '"]');
|
|
376
|
+
const label = document.querySelector('label[for="' + cssEscape(element.id) + '"]');
|
|
363
377
|
if (label && strlen(getAccessibleText(label)) > 0) {
|
|
364
378
|
return getAccessibleText(label);
|
|
365
379
|
}
|
|
@@ -474,44 +488,6 @@ function getAccessibleName(element) {
|
|
|
474
488
|
}
|
|
475
489
|
}
|
|
476
490
|
|
|
477
|
-
/**
|
|
478
|
-
* Helper function to check if element is NOT visible
|
|
479
|
-
* @param {Element} element - The element to check
|
|
480
|
-
* @returns {boolean} True if element is not visible, false otherwise
|
|
481
|
-
*/
|
|
482
|
-
function isNotVisible(element) {
|
|
483
|
-
// Importing isVisible would be better, but for this standalone function we'll check it this way
|
|
484
|
-
if (!element) {
|
|
485
|
-
return true;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Note: 'area' is filtered out because area elements DO have accessible names
|
|
489
|
-
// via the alt attribute and should participate in accessible name calculation
|
|
490
|
-
const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
|
|
491
|
-
|
|
492
|
-
if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
|
|
493
|
-
return true; // Not visible in accessibility tree
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Check if display is none
|
|
497
|
-
const isElemDisplayed = el => window.getComputedStyle(el).display === 'none';
|
|
498
|
-
|
|
499
|
-
if (isElemDisplayed(element)) {
|
|
500
|
-
return true;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Check parent elements
|
|
504
|
-
let parent = element.parentElement;
|
|
505
|
-
while (parent) {
|
|
506
|
-
if (isElemDisplayed(parent)) {
|
|
507
|
-
return true;
|
|
508
|
-
}
|
|
509
|
-
parent = parent.parentElement;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return element.getAttribute('aria-hidden') === 'true';
|
|
513
|
-
}
|
|
514
|
-
|
|
515
491
|
/**
|
|
516
492
|
* Helper function to check if an element matches a selector
|
|
517
493
|
* @param {Element} element - Element to check
|
|
@@ -541,7 +517,7 @@ function matchesSelector(element, selector) {
|
|
|
541
517
|
* @returns {number} The string length or 0
|
|
542
518
|
*/
|
|
543
519
|
function strlen(str) {
|
|
544
|
-
return typeof str === 'string' && !
|
|
520
|
+
return typeof str === 'string' && !isEmptyOrWhitespace(str) ? str.trim().length : 0;
|
|
545
521
|
}
|
|
546
522
|
|
|
547
523
|
// Export the function for CommonJS module usage
|
package/src/getAccessibleText.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { isEmptyOrWhitespace } = require('./stringUtils.js');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Get all accessible text for an element, including aria-labels and content from children.
|
|
@@ -27,7 +27,7 @@ function getAccessibleText(el, options) {
|
|
|
27
27
|
// Check for aria-label first (highest priority)
|
|
28
28
|
if (el.hasAttribute('aria-label')) {
|
|
29
29
|
const ariaLabel = el.getAttribute('aria-label').trim();
|
|
30
|
-
if (ariaLabel) {
|
|
30
|
+
if (!isEmptyOrWhitespace(ariaLabel)) {
|
|
31
31
|
return ariaLabel;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -65,7 +65,7 @@ function collectSubtreeText(node, visibleOnly) {
|
|
|
65
65
|
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
66
66
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
67
67
|
const text = child.nodeValue.trim();
|
|
68
|
-
if (!
|
|
68
|
+
if (!isEmptyOrWhitespace(text)) {
|
|
69
69
|
parts.push(text);
|
|
70
70
|
}
|
|
71
71
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
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,10 @@ 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, { logger, errorHandler: () => {}, ...tesseractOptions });
|
|
30
33
|
|
|
31
34
|
const extractedText = text.trim();
|
|
32
35
|
return extractedText.length > 0 ? extractedText : false;
|
package/src/isA11yVisible.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { NON_VISIBLE_SELECTORS } = require('./constants.js');
|
|
2
2
|
const isHidden = require('./isHidden.js');
|
|
3
|
+
const { cssEscape } = require('./domUtils.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Checks if an element is visible to assistive technologies (AT).
|
|
@@ -67,8 +68,11 @@ function isA11yVisible(element, strict = false) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// Check if element is referenced by aria-labelledby or aria-describedby
|
|
71
|
+
// Use CSS.escape(id) to handle IDs containing special characters like colons
|
|
72
|
+
// (e.g. Radix UI generates IDs like "radix-:rj:-tab-account")
|
|
73
|
+
const escapedId = id ? cssEscape(id) : '';
|
|
70
74
|
document
|
|
71
|
-
.querySelectorAll(`*[aria-labelledby~="${
|
|
75
|
+
.querySelectorAll(`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`)
|
|
72
76
|
.forEach(referencingElement => {
|
|
73
77
|
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
74
78
|
visible = true;
|