@afixt/test-utils 1.2.3 → 2.0.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 +1 -6
- package/BROWSER_TESTING.md +42 -22
- package/CHANGELOG.md +40 -0
- package/CLAUDE.md +10 -9
- package/package.json +1 -1
- package/src/constants.js +438 -1
- package/src/domUtils.js +17 -38
- package/src/formUtils.js +7 -24
- package/src/getAccessibleName.js +20 -56
- package/src/getCSSGeneratedContent.js +2 -0
- package/src/getFocusableElements.js +12 -21
- package/src/getGeneratedContent.js +18 -11
- package/src/getImageText.js +22 -7
- package/src/hasValidAriaRole.js +11 -19
- package/src/index.js +4 -4
- package/src/interactiveRoles.js +2 -19
- package/src/isA11yVisible.js +95 -0
- package/src/isAriaAttributesValid.js +5 -64
- package/src/isFocusable.js +30 -10
- package/src/isHidden.js +44 -8
- package/src/listEventListeners.js +115 -10
- package/src/stringUtils.js +19 -98
- package/src/tableUtils.js +4 -36
- package/src/testContrast.js +54 -0
- package/test/domUtils.test.js +156 -0
- package/test/formUtils.test.js +0 -47
- package/test/getAccessibleName.test.js +39 -0
- package/test/getGeneratedContent.test.js +305 -241
- package/test/getImageText.test.js +158 -99
- package/test/index.test.js +54 -17
- package/test/{isVisible.test.js → isA11yVisible.test.js} +39 -33
- package/test/isFocusable.test.js +265 -272
- package/test/isHidden.test.js +257 -153
- package/test/listEventListeners.test.js +163 -44
- package/test/playwright/css-pseudo-elements.spec.js +3 -13
- package/test/stringUtils.test.js +55 -228
- package/test/testContrast.test.js +104 -2
- package/todo.md +2 -2
- package/src/isVisible.js +0 -103
package/src/domUtils.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
const {
|
|
2
|
+
VALID_ARIA_NESTING,
|
|
3
|
+
LANDMARK_TAGS,
|
|
4
|
+
LANDMARK_ROLES,
|
|
5
|
+
SEMANTIC_CONTAINER_TAGS,
|
|
6
|
+
SEMANTIC_CONTAINER_ROLES,
|
|
7
|
+
INTERACTIVE_HANDLER_ATTRIBUTES,
|
|
8
|
+
} = require('./constants.js');
|
|
9
|
+
|
|
1
10
|
const domUtils = {
|
|
2
11
|
/**
|
|
3
12
|
* Checks if the specified element has the given attribute.
|
|
@@ -250,21 +259,6 @@ const domUtils = {
|
|
|
250
259
|
* @returns {boolean} True if this is a valid ARIA nesting pattern
|
|
251
260
|
*/
|
|
252
261
|
isValidAriaNesting(parent, child) {
|
|
253
|
-
const VALID_ARIA_NESTING = {
|
|
254
|
-
listbox: ['option'],
|
|
255
|
-
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
256
|
-
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
257
|
-
menuitem: ['menu', 'menubar'],
|
|
258
|
-
tablist: ['tab'],
|
|
259
|
-
tree: ['treeitem', 'group'],
|
|
260
|
-
treeitem: ['group', 'tree'],
|
|
261
|
-
grid: ['gridcell', 'row', 'rowgroup'],
|
|
262
|
-
row: ['gridcell', 'columnheader', 'rowheader', 'cell'],
|
|
263
|
-
rowgroup: ['row'],
|
|
264
|
-
radiogroup: ['radio'],
|
|
265
|
-
combobox: ['listbox', 'textbox', 'tree', 'grid', 'dialog'],
|
|
266
|
-
};
|
|
267
|
-
|
|
268
262
|
const parentRole = (parent.getAttribute('role') || '').toLowerCase();
|
|
269
263
|
const childRole = (child.getAttribute('role') || '').toLowerCase();
|
|
270
264
|
|
|
@@ -287,14 +281,7 @@ const domUtils = {
|
|
|
287
281
|
* @returns {boolean} True if element has interactive handlers
|
|
288
282
|
*/
|
|
289
283
|
hasInteractiveHandler(element) {
|
|
290
|
-
return (
|
|
291
|
-
element.hasAttribute('onclick') ||
|
|
292
|
-
element.hasAttribute('onmousedown') ||
|
|
293
|
-
element.hasAttribute('onmouseup') ||
|
|
294
|
-
element.hasAttribute('ontouchstart') ||
|
|
295
|
-
element.hasAttribute('onkeydown') ||
|
|
296
|
-
element.hasAttribute('onkeyup')
|
|
297
|
-
);
|
|
284
|
+
return INTERACTIVE_HANDLER_ATTRIBUTES.some(attr => element.hasAttribute(attr));
|
|
298
285
|
},
|
|
299
286
|
|
|
300
287
|
/**
|
|
@@ -321,22 +308,11 @@ const domUtils = {
|
|
|
321
308
|
* @returns {boolean} True if element is a landmark
|
|
322
309
|
*/
|
|
323
310
|
isLandmark(element) {
|
|
324
|
-
|
|
325
|
-
const landmarkRoles = [
|
|
326
|
-
'navigation',
|
|
327
|
-
'main',
|
|
328
|
-
'banner',
|
|
329
|
-
'contentinfo',
|
|
330
|
-
'complementary',
|
|
331
|
-
'region',
|
|
332
|
-
'search',
|
|
333
|
-
'form',
|
|
334
|
-
];
|
|
335
|
-
if (landmarkTags.includes(element.tagName)) {
|
|
311
|
+
if (LANDMARK_TAGS.includes(element.tagName)) {
|
|
336
312
|
return true;
|
|
337
313
|
}
|
|
338
314
|
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
339
|
-
return
|
|
315
|
+
return LANDMARK_ROLES.includes(role);
|
|
340
316
|
},
|
|
341
317
|
|
|
342
318
|
/**
|
|
@@ -345,10 +321,13 @@ const domUtils = {
|
|
|
345
321
|
* @returns {HTMLElement|null} The nearest semantic container
|
|
346
322
|
*/
|
|
347
323
|
getSemanticContainer(el) {
|
|
348
|
-
const containerTags = ['ARTICLE', 'SECTION', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'FIGURE'];
|
|
349
324
|
let ancestor = el.parentElement;
|
|
350
325
|
while (ancestor && ancestor !== document.body) {
|
|
351
|
-
if (
|
|
326
|
+
if (SEMANTIC_CONTAINER_TAGS.includes(ancestor.tagName)) {
|
|
327
|
+
return ancestor;
|
|
328
|
+
}
|
|
329
|
+
const role = (ancestor.getAttribute('role') || '').toLowerCase();
|
|
330
|
+
if (role && SEMANTIC_CONTAINER_ROLES.includes(role)) {
|
|
352
331
|
return ancestor;
|
|
353
332
|
}
|
|
354
333
|
ancestor = ancestor.parentElement;
|
package/src/formUtils.js
CHANGED
|
@@ -35,8 +35,13 @@ const formUtils = {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Checks if an element has an explicit accessible name via aria-labelledby, aria-label, or title.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
*
|
|
39
|
+
* Unlike getAccessibleName/hasAccessibleName, this deliberately excludes text content.
|
|
40
|
+
* This is important for container roles (radiogroup, group) where text content of
|
|
41
|
+
* child elements is not a valid accessible name source per the ARIA spec — these
|
|
42
|
+
* roles require an author-provided label.
|
|
43
|
+
*
|
|
44
|
+
* Used by isProperlyGrouped() to enforce that ARIA groups have explicit labels.
|
|
40
45
|
* @param {Element} element - The element to check
|
|
41
46
|
* @returns {boolean} True if element has an explicit accessible name
|
|
42
47
|
*/
|
|
@@ -126,28 +131,6 @@ const formUtils = {
|
|
|
126
131
|
return document.body;
|
|
127
132
|
},
|
|
128
133
|
|
|
129
|
-
/**
|
|
130
|
-
* Check if a native form element has an associated label.
|
|
131
|
-
* Checks label[for], wrapping label, aria-label, aria-labelledby, and title.
|
|
132
|
-
* @param {HTMLElement} element - The element to check
|
|
133
|
-
* @returns {boolean} True if the element has an associated label
|
|
134
|
-
*/
|
|
135
|
-
hasAssociatedLabel(element) {
|
|
136
|
-
if (element.id) {
|
|
137
|
-
const label = document.querySelector('label[for="' + element.id + '"]');
|
|
138
|
-
if (label && label.textContent.trim()) {
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const parentLabel = element.closest('label');
|
|
144
|
-
if (parentLabel && parentLabel.textContent.trim()) {
|
|
145
|
-
return true;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return false;
|
|
149
|
-
},
|
|
150
|
-
|
|
151
134
|
/**
|
|
152
135
|
* Get the text content of an element, excluding form control children.
|
|
153
136
|
* Useful for wrapped labels like <label><input type="checkbox"> Remember me</label>
|
package/src/getAccessibleName.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
const { isEmpty } = require('./stringUtils.js');
|
|
2
2
|
const { getAccessibleText } = require('./getAccessibleText.js');
|
|
3
|
+
const {
|
|
4
|
+
TEXT_ROLES,
|
|
5
|
+
NON_VISIBLE_SELECTORS,
|
|
6
|
+
LANDMARK_ROLES,
|
|
7
|
+
LANDMARK_ELEMENT_MAP,
|
|
8
|
+
} = require('./constants.js');
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Gets the accessible name of an element according to the accessible name calculation algorithm
|
|
@@ -64,28 +70,8 @@ function getAccessibleName(element) {
|
|
|
64
70
|
// We check all of those here.
|
|
65
71
|
if (element.hasAttribute('role')) {
|
|
66
72
|
const roleValue = element.getAttribute('role');
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
'checkbox',
|
|
70
|
-
'columnheader',
|
|
71
|
-
'gridcell',
|
|
72
|
-
'heading',
|
|
73
|
-
'link',
|
|
74
|
-
'listitem',
|
|
75
|
-
'menuitem',
|
|
76
|
-
'menuitemcheckbox',
|
|
77
|
-
'menuitemradio',
|
|
78
|
-
'option',
|
|
79
|
-
'radio',
|
|
80
|
-
'row',
|
|
81
|
-
'rowgroup',
|
|
82
|
-
'rowheader',
|
|
83
|
-
'tab',
|
|
84
|
-
'tooltip',
|
|
85
|
-
'treeitem',
|
|
86
|
-
];
|
|
87
|
-
|
|
88
|
-
if (textRoles.includes(roleValue)) {
|
|
73
|
+
|
|
74
|
+
if (TEXT_ROLES.includes(roleValue)) {
|
|
89
75
|
// Use getAccessibleText which handles both text nodes and
|
|
90
76
|
// image alt text in the subtree (not just textContent)
|
|
91
77
|
const text = getAccessibleText(element);
|
|
@@ -456,23 +442,23 @@ function getAccessibleName(element) {
|
|
|
456
442
|
}
|
|
457
443
|
|
|
458
444
|
// STEP 15: Landmark elements that require explicit accessible names
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// via aria-labelledby, aria-label, or title attribute.
|
|
445
|
+
// Landmark roles do NOT support "Name From: contents" per WAI-ARIA spec.
|
|
446
|
+
// They only support "Name From: author" (aria-labelledby, aria-label, title).
|
|
462
447
|
// Since steps 1 & 2 already checked aria-labelledby and aria-label,
|
|
463
448
|
// we only need to check title here, then return false.
|
|
464
|
-
const
|
|
465
|
-
|
|
449
|
+
const tagName = element.tagName.toLowerCase();
|
|
450
|
+
const role = element.getAttribute('role');
|
|
451
|
+
const isLandmark = LANDMARK_ROLES.includes(role) || tagName in LANDMARK_ELEMENT_MAP;
|
|
466
452
|
|
|
467
|
-
if (
|
|
468
|
-
// Title attribute is valid for
|
|
453
|
+
if (isLandmark) {
|
|
454
|
+
// Title attribute is valid for landmark elements
|
|
469
455
|
if (element.hasAttribute('title')) {
|
|
470
456
|
const titleValue = element.getAttribute('title');
|
|
471
457
|
if (strlen(titleValue) > 0) {
|
|
472
458
|
return titleValue;
|
|
473
459
|
}
|
|
474
460
|
}
|
|
475
|
-
//
|
|
461
|
+
// Landmark elements do not get accessible name from text content
|
|
476
462
|
return false;
|
|
477
463
|
}
|
|
478
464
|
|
|
@@ -499,33 +485,11 @@ function isNotVisible(element) {
|
|
|
499
485
|
return true;
|
|
500
486
|
}
|
|
501
487
|
|
|
502
|
-
//
|
|
503
|
-
// Note: 'area' is NOT included here because area elements DO have accessible names
|
|
488
|
+
// Note: 'area' is filtered out because area elements DO have accessible names
|
|
504
489
|
// via the alt attribute and should participate in accessible name calculation
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
'meta',
|
|
509
|
-
'title',
|
|
510
|
-
'link',
|
|
511
|
-
'style',
|
|
512
|
-
'script',
|
|
513
|
-
'br',
|
|
514
|
-
'nobr',
|
|
515
|
-
'col',
|
|
516
|
-
'embed',
|
|
517
|
-
'input[type="hidden"]',
|
|
518
|
-
'keygen',
|
|
519
|
-
'source',
|
|
520
|
-
'track',
|
|
521
|
-
'wbr',
|
|
522
|
-
'datalist',
|
|
523
|
-
'param',
|
|
524
|
-
'noframes',
|
|
525
|
-
'ruby > rp',
|
|
526
|
-
];
|
|
527
|
-
|
|
528
|
-
if (nonVisibleSelectors.some(selector => matchesSelector(element, selector))) {
|
|
490
|
+
const nonVisibleSelectorsWithoutArea = NON_VISIBLE_SELECTORS.filter(s => s !== 'area');
|
|
491
|
+
|
|
492
|
+
if (nonVisibleSelectorsWithoutArea.some(selector => matchesSelector(element, selector))) {
|
|
529
493
|
return true; // Not visible in accessibility tree
|
|
530
494
|
}
|
|
531
495
|
|
|
@@ -25,6 +25,8 @@ function extractMeaningfulContent(rawValue) {
|
|
|
25
25
|
* not the element's own text content. Empty, whitespace-only, and blank content
|
|
26
26
|
* values are filtered out as they do not convey meaningful information.
|
|
27
27
|
*
|
|
28
|
+
* Use getGeneratedContent() if you need ::before + textContent + ::after combined.
|
|
29
|
+
*
|
|
28
30
|
* @param {Element} el - The DOM element to check
|
|
29
31
|
* @param {string} [pseudoElement='both'] - Which pseudo-element to check ('before', 'after', or 'both')
|
|
30
32
|
* @returns {string|boolean} The generated content as a string or false if none exists
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { FOCUSABLE_SELECTORS } = require('./constants.js');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Gets all focusable elements within the specified container element.
|
|
3
5
|
* @param {Element} el - the element to be tested
|
|
@@ -7,33 +9,22 @@ function getFocusableElements(el) {
|
|
|
7
9
|
if (!el) {
|
|
8
10
|
throw new Error('Container element is required');
|
|
9
11
|
}
|
|
10
|
-
|
|
11
|
-
const focusableSelectors = [
|
|
12
|
-
"a[href]",
|
|
13
|
-
"area",
|
|
14
|
-
"button",
|
|
15
|
-
"select",
|
|
16
|
-
"textarea",
|
|
17
|
-
'input:not([type="hidden"])',
|
|
18
|
-
"[tabindex]",
|
|
19
|
-
];
|
|
20
12
|
|
|
21
|
-
return Array.from(
|
|
22
|
-
|
|
23
|
-
).filter((element) => {
|
|
24
|
-
const tabindex = element.getAttribute("tabindex");
|
|
13
|
+
return Array.from(el.querySelectorAll(FOCUSABLE_SELECTORS.join(', '))).filter(element => {
|
|
14
|
+
const tabindex = element.getAttribute('tabindex');
|
|
25
15
|
const hasValidTabindex = tabindex === null || parseInt(tabindex, 10) >= 0;
|
|
26
|
-
|
|
16
|
+
|
|
27
17
|
// Check visibility - handle JSDOM environment where offsetParent might not work correctly
|
|
28
|
-
const isVisible =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
const isVisible =
|
|
19
|
+
element.offsetParent !== null ||
|
|
20
|
+
(typeof window !== 'undefined' &&
|
|
21
|
+
window.navigator &&
|
|
22
|
+
window.navigator.userAgent.includes('jsdom'));
|
|
23
|
+
|
|
33
24
|
return hasValidTabindex && isVisible;
|
|
34
25
|
});
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
module.exports = {
|
|
38
|
-
getFocusableElements
|
|
29
|
+
getFocusableElements,
|
|
39
30
|
};
|
|
@@ -1,19 +1,26 @@
|
|
|
1
|
+
const { getCSSGeneratedContent } = require('./getCSSGeneratedContent.js');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Get
|
|
4
|
+
* Get all visible text for an element, including CSS-generated ::before and ::after content
|
|
5
|
+
* combined with the element's own text content.
|
|
6
|
+
*
|
|
7
|
+
* Unlike getCSSGeneratedContent (which returns only pseudo-element content),
|
|
8
|
+
* this function returns the full visible text: ::before + textContent + ::after.
|
|
9
|
+
*
|
|
3
10
|
* @param {Element} el - The DOM element.
|
|
4
|
-
* @returns {string|
|
|
11
|
+
* @returns {string|false} The combined text or false if no content exists.
|
|
5
12
|
*/
|
|
6
13
|
function getGeneratedContent(el) {
|
|
7
|
-
if (!el)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
if (!el) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const before = getCSSGeneratedContent(el, 'before') || '';
|
|
18
|
+
const inner = el.textContent || '';
|
|
19
|
+
const after = getCSSGeneratedContent(el, 'after') || '';
|
|
20
|
+
const result = [before, inner, after].filter(Boolean).join(' ').trim();
|
|
21
|
+
return result || false;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
module.exports = {
|
|
18
|
-
getGeneratedContent
|
|
25
|
+
getGeneratedContent,
|
|
19
26
|
};
|
package/src/getImageText.js
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
const Tesseract = require('tesseract.js');
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Internal reference to Tesseract.recognize, exposed for test mocking.
|
|
5
|
+
* @private
|
|
6
|
+
*/
|
|
7
|
+
const _internal = {
|
|
8
|
+
recognize: Tesseract.recognize,
|
|
9
|
+
};
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
12
|
* Extracts text from an image using OCR.
|
|
5
13
|
* @param {string} imagePath - The path or URL of the image.
|
|
14
|
+
* @param {Object} [options={}] - OCR options.
|
|
15
|
+
* @param {string} [options.lang='eng'] - Tesseract language(s) for OCR (e.g. 'fra', 'eng+deu').
|
|
16
|
+
* @param {Function|null} [options.logger=console.log] - Logger callback for Tesseract progress. Pass null to disable.
|
|
17
|
+
* @param {string} [options.corePath] - Custom path to Tesseract core WASM files.
|
|
18
|
+
* @param {string} [options.langPath] - Custom path to language training data.
|
|
19
|
+
* @param {string} [options.cachePath] - Custom cache directory path.
|
|
6
20
|
* @returns {Promise<string|false>} Extracted text if found, otherwise false.
|
|
7
21
|
*/
|
|
8
|
-
async function getImageText(imagePath) {
|
|
22
|
+
async function getImageText(imagePath, options = {}) {
|
|
23
|
+
const { lang = 'eng', logger = m => console.log(m), ...tesseractOptions } = options;
|
|
24
|
+
|
|
9
25
|
try {
|
|
10
|
-
const {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{ logger: m => console.log(m) } // Optional logging
|
|
14
|
-
);
|
|
26
|
+
const {
|
|
27
|
+
data: { text },
|
|
28
|
+
} = await _internal.recognize(imagePath, lang, { logger, ...tesseractOptions });
|
|
15
29
|
|
|
16
30
|
const extractedText = text.trim();
|
|
17
31
|
return extractedText.length > 0 ? extractedText : false;
|
|
@@ -28,5 +42,6 @@ async function getImageText(imagePath) {
|
|
|
28
42
|
// getImageText('path/to/image.jpg').then(result => console.log(result));
|
|
29
43
|
|
|
30
44
|
module.exports = {
|
|
31
|
-
getImageText
|
|
45
|
+
getImageText,
|
|
46
|
+
_internal,
|
|
32
47
|
};
|
package/src/hasValidAriaRole.js
CHANGED
|
@@ -4,30 +4,22 @@
|
|
|
4
4
|
* @param {HTMLElement} element - The DOM element to check.
|
|
5
5
|
* @returns {boolean} True if the element has a valid ARIA role, false otherwise.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
const validAriaRoles = new Set([
|
|
9
|
-
"alert", "alertdialog", "button", "checkbox", "dialog", "gridcell", "link",
|
|
10
|
-
"log", "marquee", "menuitem", "menuitemcheckbox", "menuitemradio", "option",
|
|
11
|
-
"progressbar", "radio", "scrollbar", "searchbox", "slider", "spinbutton",
|
|
12
|
-
"status", "switch", "tab", "tabpanel", "textbox", "tooltip", "treeitem",
|
|
13
|
-
"combobox", "grid", "listbox", "menu", "menubar", "radiogroup", "tablist",
|
|
14
|
-
"tree", "treegrid", "article", "cell", "columnheader", "definition",
|
|
15
|
-
"directory", "document", "feed", "figure", "group", "heading", "img",
|
|
16
|
-
"list", "listitem", "math", "none", "note", "presentation", "row",
|
|
17
|
-
"rowgroup", "rowheader", "separator", "table", "term", "toolbar",
|
|
18
|
-
"application", "banner", "complementary", "contentinfo", "form", "main",
|
|
19
|
-
"navigation", "region", "search", "timer"
|
|
20
|
-
]);
|
|
7
|
+
const { VALID_ARIA_ROLES } = require('./constants.js');
|
|
21
8
|
|
|
22
|
-
|
|
9
|
+
function hasValidAriaRole(element) {
|
|
10
|
+
if (!element || typeof element.getAttribute !== 'function') {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
23
13
|
|
|
24
14
|
const roleAttr = element.getAttribute('role');
|
|
25
|
-
if (!roleAttr)
|
|
15
|
+
if (!roleAttr) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
26
18
|
|
|
27
19
|
const roles = roleAttr.trim().split(/\s+/);
|
|
28
|
-
return
|
|
29
|
-
|
|
20
|
+
return VALID_ARIA_ROLES.has(roles[0]); // Only check the first role
|
|
21
|
+
}
|
|
30
22
|
|
|
31
23
|
module.exports = {
|
|
32
|
-
hasValidAriaRole
|
|
24
|
+
hasValidAriaRole,
|
|
33
25
|
};
|
package/src/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const isDataTable = require('./isDataTable.js');
|
|
|
35
35
|
// Visibility and positioning
|
|
36
36
|
const isHidden = require('./isHidden.js');
|
|
37
37
|
const isOffScreen = require('./isOffScreen.js');
|
|
38
|
-
const
|
|
38
|
+
const isA11yVisible = require('./isA11yVisible.js');
|
|
39
39
|
const hasHiddenParent = require('./hasHiddenParent.js');
|
|
40
40
|
|
|
41
41
|
// Element relationships
|
|
@@ -50,7 +50,7 @@ const isFocusable = require('./isFocusable.js');
|
|
|
50
50
|
const getComputedRole = require('./getComputedRole.js');
|
|
51
51
|
|
|
52
52
|
// Image utilities
|
|
53
|
-
const getImageText = require('./getImageText.js');
|
|
53
|
+
const { getImageText } = require('./getImageText.js');
|
|
54
54
|
|
|
55
55
|
// Testing utilities
|
|
56
56
|
const testContrast = require('./testContrast.js');
|
|
@@ -101,14 +101,14 @@ module.exports = {
|
|
|
101
101
|
...isDataTable,
|
|
102
102
|
...isHidden,
|
|
103
103
|
...isOffScreen,
|
|
104
|
-
...
|
|
104
|
+
...isA11yVisible,
|
|
105
105
|
...hasHiddenParent,
|
|
106
106
|
...hasParent,
|
|
107
107
|
...hasAttribute,
|
|
108
108
|
...getFocusableElements,
|
|
109
109
|
...isFocusable,
|
|
110
110
|
...getComputedRole,
|
|
111
|
-
|
|
111
|
+
getImageText,
|
|
112
112
|
...testContrast,
|
|
113
113
|
...testLang,
|
|
114
114
|
...testOrder,
|
package/src/interactiveRoles.js
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
const
|
|
2
|
-
"button",
|
|
3
|
-
"checkbox",
|
|
4
|
-
"combobox",
|
|
5
|
-
"link",
|
|
6
|
-
"menu",
|
|
7
|
-
"menuitemcheckbox",
|
|
8
|
-
"menuitemradio",
|
|
9
|
-
"radio",
|
|
10
|
-
"scrollbar",
|
|
11
|
-
"slider",
|
|
12
|
-
"spinbutton",
|
|
13
|
-
"tablist",
|
|
14
|
-
"textbox",
|
|
15
|
-
"toolbar",
|
|
16
|
-
"switch",
|
|
17
|
-
"tree",
|
|
18
|
-
];
|
|
1
|
+
const { INTERACTIVE_ROLES } = require('./constants.js');
|
|
19
2
|
|
|
20
|
-
module.exports =
|
|
3
|
+
module.exports = INTERACTIVE_ROLES;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { NON_VISIBLE_SELECTORS } = require('./constants.js');
|
|
2
|
+
const isHidden = require('./isHidden.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if an element is visible to assistive technologies (AT).
|
|
6
|
+
*
|
|
7
|
+
* Unlike isHidden (which checks simple visual hiding), this function determines
|
|
8
|
+
* whether an element is meaningful to screen readers and other AT. Key differences:
|
|
9
|
+
*
|
|
10
|
+
* - Elements referenced by aria-labelledby or aria-describedby are considered
|
|
11
|
+
* AT-visible even if visually hidden (display:none), because they provide
|
|
12
|
+
* accessible names/descriptions to other elements.
|
|
13
|
+
* - Inherently non-visible elements (script, style, input[type="hidden"], etc.)
|
|
14
|
+
* are treated as AT-visible since they carry semantic meaning.
|
|
15
|
+
* - Parent chain is walked to detect inherited hiding (CSS and aria-hidden in strict mode).
|
|
16
|
+
*
|
|
17
|
+
* @param {Element} element - The element to check.
|
|
18
|
+
* @param {boolean} [strict=false] - When true, also treats aria-hidden="true" (on the
|
|
19
|
+
* element or any ancestor) as hidden.
|
|
20
|
+
* @returns {boolean} True if the element is visible to assistive technologies.
|
|
21
|
+
*/
|
|
22
|
+
function isA11yVisible(element, strict = false) {
|
|
23
|
+
// Add null check at the beginning
|
|
24
|
+
if (!element || !(element instanceof Element)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check if element is still connected to the DOM
|
|
29
|
+
if (!element.isConnected) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const id = element.id;
|
|
34
|
+
let visible = true;
|
|
35
|
+
|
|
36
|
+
if (NON_VISIBLE_SELECTORS.some(selector => element.matches(selector))) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const optionalAriaHidden = (el, strictCheck) =>
|
|
41
|
+
strictCheck && el.getAttribute('aria-hidden') === 'true';
|
|
42
|
+
|
|
43
|
+
// Use isHidden for the element-level check (covers display:none,
|
|
44
|
+
// visibility:hidden, opacity:0, and the hidden HTML attribute)
|
|
45
|
+
if (isHidden(element, { checkOpacity: true })) {
|
|
46
|
+
visible = false;
|
|
47
|
+
} else {
|
|
48
|
+
// Walk parent chain for inherited CSS hiding (display, visibility, opacity)
|
|
49
|
+
let parent = element.parentElement;
|
|
50
|
+
while (parent) {
|
|
51
|
+
const style = window.getComputedStyle(parent);
|
|
52
|
+
if (
|
|
53
|
+
style.display === 'none' ||
|
|
54
|
+
style.visibility === 'hidden' ||
|
|
55
|
+
style.opacity === '0'
|
|
56
|
+
) {
|
|
57
|
+
visible = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
parent = parent.parentElement;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check element-level aria-hidden in strict mode (when not already hidden by CSS)
|
|
65
|
+
if (visible && optionalAriaHidden(element, strict)) {
|
|
66
|
+
visible = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if element is referenced by aria-labelledby or aria-describedby
|
|
70
|
+
document
|
|
71
|
+
.querySelectorAll(`*[aria-labelledby~="${id}"], *[aria-describedby~="${id}"]`)
|
|
72
|
+
.forEach(referencingElement => {
|
|
73
|
+
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
74
|
+
visible = true;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Check if any parent has aria-hidden="true" when strict mode is on
|
|
79
|
+
if (visible && strict) {
|
|
80
|
+
let parent = element.parentElement;
|
|
81
|
+
while (parent) {
|
|
82
|
+
if (optionalAriaHidden(parent, strict)) {
|
|
83
|
+
visible = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
parent = parent.parentElement;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return visible;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
isA11yVisible,
|
|
95
|
+
};
|
|
@@ -1,63 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* List of valid ARIA attributes based on WAI-ARIA specification.
|
|
3
|
-
* @type {Set<string>}
|
|
4
|
-
*/
|
|
5
|
-
const validAriaAttributes = new Set([
|
|
6
|
-
// ARIA States and Properties
|
|
7
|
-
'aria-activedescendant',
|
|
8
|
-
'aria-atomic',
|
|
9
|
-
'aria-autocomplete',
|
|
10
|
-
'aria-braillelabel',
|
|
11
|
-
'aria-brailleroledescription',
|
|
12
|
-
'aria-busy',
|
|
13
|
-
'aria-checked',
|
|
14
|
-
'aria-colcount',
|
|
15
|
-
'aria-colindex',
|
|
16
|
-
'aria-colindextext',
|
|
17
|
-
'aria-colspan',
|
|
18
|
-
'aria-controls',
|
|
19
|
-
'aria-current',
|
|
20
|
-
'aria-describedby',
|
|
21
|
-
'aria-description',
|
|
22
|
-
'aria-details',
|
|
23
|
-
'aria-disabled',
|
|
24
|
-
'aria-dropeffect',
|
|
25
|
-
'aria-errormessage',
|
|
26
|
-
'aria-expanded',
|
|
27
|
-
'aria-flowto',
|
|
28
|
-
'aria-grabbed',
|
|
29
|
-
'aria-haspopup',
|
|
30
|
-
'aria-hidden',
|
|
31
|
-
'aria-invalid',
|
|
32
|
-
'aria-keyshortcuts',
|
|
33
|
-
'aria-label',
|
|
34
|
-
'aria-labelledby',
|
|
35
|
-
'aria-level',
|
|
36
|
-
'aria-live',
|
|
37
|
-
'aria-modal',
|
|
38
|
-
'aria-multiline',
|
|
39
|
-
'aria-multiselectable',
|
|
40
|
-
'aria-orientation',
|
|
41
|
-
'aria-owns',
|
|
42
|
-
'aria-placeholder',
|
|
43
|
-
'aria-posinset',
|
|
44
|
-
'aria-pressed',
|
|
45
|
-
'aria-readonly',
|
|
46
|
-
'aria-relevant',
|
|
47
|
-
'aria-required',
|
|
48
|
-
'aria-roledescription',
|
|
49
|
-
'aria-rowcount',
|
|
50
|
-
'aria-rowindex',
|
|
51
|
-
'aria-rowindextext',
|
|
52
|
-
'aria-rowspan',
|
|
53
|
-
'aria-selected',
|
|
54
|
-
'aria-setsize',
|
|
55
|
-
'aria-sort',
|
|
56
|
-
'aria-valuemax',
|
|
57
|
-
'aria-valuemin',
|
|
58
|
-
'aria-valuenow',
|
|
59
|
-
'aria-valuetext'
|
|
60
|
-
]);
|
|
1
|
+
const { VALID_ARIA_ATTRIBUTES } = require('./constants.js');
|
|
61
2
|
|
|
62
3
|
/**
|
|
63
4
|
* Checks if a specific ARIA attribute is valid according to the WAI-ARIA specification.
|
|
@@ -69,10 +10,10 @@ function isAriaAttributeValid(attributeName) {
|
|
|
69
10
|
if (!attributeName || typeof attributeName !== 'string') {
|
|
70
11
|
return false;
|
|
71
12
|
}
|
|
72
|
-
|
|
73
|
-
return
|
|
13
|
+
|
|
14
|
+
return VALID_ARIA_ATTRIBUTES.has(attributeName.toLowerCase());
|
|
74
15
|
}
|
|
75
16
|
|
|
76
17
|
module.exports = {
|
|
77
|
-
isAriaAttributeValid
|
|
78
|
-
};
|
|
18
|
+
isAriaAttributeValid,
|
|
19
|
+
};
|