@afixt/test-utils 1.2.0 → 1.2.2
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/eslint.config.mjs +1 -1
- package/package.json +2 -1
- package/src/constants.js +231 -0
- package/src/cssUtils.js +77 -0
- package/src/domUtils.js +268 -12
- package/src/formUtils.js +175 -0
- package/src/getAccessibleText.js +60 -39
- package/src/getCSSGeneratedContent.js +39 -17
- package/src/index.js +18 -2
- package/src/stringUtils.js +149 -0
- package/src/tableUtils.js +180 -0
- package/src/testContrast.js +35 -1
- package/src/testLang.js +514 -444
- package/test/cssUtils.test.js +248 -0
- package/test/domUtils.test.js +815 -297
- package/test/formUtils.test.js +389 -0
- package/test/getAccessibleText.test.js +93 -0
- package/test/getCSSGeneratedContent.test.js +187 -232
- package/test/hasCSSGeneratedContent.test.js +37 -147
- package/test/playwright/css-pseudo-elements.spec.js +224 -91
- package/test/playwright/fixtures/css-pseudo-elements.html +6 -0
- package/test/stringUtils.test.js +222 -0
- package/test/tableUtils.test.js +340 -0
- package/vitest.config.js +28 -28
- package/test/getCSSGeneratedContent.browser.test.js +0 -125
package/eslint.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afixt/test-utils",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Various utilities for accessibility testing",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "^9.39.2",
|
|
55
55
|
"@playwright/test": "^1.57.0",
|
|
56
|
+
"@vitest/coverage-istanbul": "^3.2.4",
|
|
56
57
|
"@vitest/coverage-v8": "^3.0.9",
|
|
57
58
|
"clean-jsdoc-theme": "^4.3.0",
|
|
58
59
|
"eslint": "^9.39.2",
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Shared constants for accessibility testing
|
|
3
|
+
* @module constants
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CSS selector string for interactive elements.
|
|
8
|
+
* Matches native HTML interactive elements and ARIA widget roles.
|
|
9
|
+
* @type {string}
|
|
10
|
+
*/
|
|
11
|
+
const INTERACTIVE_SELECTOR = [
|
|
12
|
+
'a[href]',
|
|
13
|
+
'button',
|
|
14
|
+
'input:not([type="hidden"])',
|
|
15
|
+
'select',
|
|
16
|
+
'textarea',
|
|
17
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
18
|
+
'[role="button"]',
|
|
19
|
+
'[role="link"]',
|
|
20
|
+
'[role="checkbox"]',
|
|
21
|
+
'[role="menuitem"]',
|
|
22
|
+
'[role="menuitemcheckbox"]',
|
|
23
|
+
'[role="menuitemradio"]',
|
|
24
|
+
'[role="tab"]',
|
|
25
|
+
'[role="switch"]',
|
|
26
|
+
'[role="option"]',
|
|
27
|
+
'[role="radio"]',
|
|
28
|
+
'[role="slider"]',
|
|
29
|
+
'[role="spinbutton"]',
|
|
30
|
+
'[role="textbox"]',
|
|
31
|
+
'[role="combobox"]',
|
|
32
|
+
'[role="listbox"]',
|
|
33
|
+
'[role="searchbox"]',
|
|
34
|
+
'[role="gridcell"]',
|
|
35
|
+
'[role="treeitem"]',
|
|
36
|
+
].join(', ');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* CSS selector string for naturally focusable elements.
|
|
40
|
+
* These elements are focusable by default (unless disabled).
|
|
41
|
+
* @type {string}
|
|
42
|
+
*/
|
|
43
|
+
const FOCUSABLE_SELECTOR = [
|
|
44
|
+
'a[href]',
|
|
45
|
+
'button:not([disabled])',
|
|
46
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
47
|
+
'select:not([disabled])',
|
|
48
|
+
'textarea:not([disabled])',
|
|
49
|
+
'[tabindex]',
|
|
50
|
+
'audio[controls]',
|
|
51
|
+
'video[controls]',
|
|
52
|
+
'embed',
|
|
53
|
+
'iframe',
|
|
54
|
+
'object',
|
|
55
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
56
|
+
].join(', ');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Labellable elements per HTML spec that can be associated with a <label>.
|
|
60
|
+
* @type {string[]}
|
|
61
|
+
*/
|
|
62
|
+
const LABELLABLE_ELEMENTS = [
|
|
63
|
+
'input',
|
|
64
|
+
'select',
|
|
65
|
+
'textarea',
|
|
66
|
+
'button',
|
|
67
|
+
'meter',
|
|
68
|
+
'output',
|
|
69
|
+
'progress',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Elements that validly support the alt attribute per HTML spec.
|
|
74
|
+
* @type {string[]}
|
|
75
|
+
*/
|
|
76
|
+
const VALID_ALT_ELEMENTS = ['IMG', 'AREA', 'INPUT', 'APPLET'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ISO 639-1 codes for right-to-left languages.
|
|
80
|
+
* @type {string[]}
|
|
81
|
+
*/
|
|
82
|
+
const RTL_LANGUAGES = [
|
|
83
|
+
'ar', // Arabic
|
|
84
|
+
'he', // Hebrew
|
|
85
|
+
'fa', // Persian/Farsi
|
|
86
|
+
'ur', // Urdu
|
|
87
|
+
'sd', // Sindhi
|
|
88
|
+
'yi', // Yiddish
|
|
89
|
+
'ps', // Pashto
|
|
90
|
+
'ku', // Kurdish (some dialects)
|
|
91
|
+
'ug', // Uyghur
|
|
92
|
+
'dv', // Divehi/Maldivian
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generic, non-descriptive, or meaningless frame/iframe titles.
|
|
97
|
+
* @type {string[]}
|
|
98
|
+
*/
|
|
99
|
+
const GENERIC_TITLES = ['iframe', 'frame', 'untitled', 'title', 'content', 'main', 'page'];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generic or useless table summaries commonly found in the wild.
|
|
103
|
+
* Merged from multiple detection contexts.
|
|
104
|
+
* @type {string[]}
|
|
105
|
+
*/
|
|
106
|
+
const GENERIC_SUMMARIES = [
|
|
107
|
+
'table',
|
|
108
|
+
'data',
|
|
109
|
+
'data table',
|
|
110
|
+
'information',
|
|
111
|
+
'content',
|
|
112
|
+
'main content',
|
|
113
|
+
'layout',
|
|
114
|
+
'layout table',
|
|
115
|
+
'for layout',
|
|
116
|
+
'table for layout purposes',
|
|
117
|
+
'structural table',
|
|
118
|
+
'this table is used for page layout',
|
|
119
|
+
'header',
|
|
120
|
+
'footer',
|
|
121
|
+
'navigation',
|
|
122
|
+
'nav',
|
|
123
|
+
'body',
|
|
124
|
+
'combobox',
|
|
125
|
+
'links design table',
|
|
126
|
+
'title and navigation',
|
|
127
|
+
'main heading',
|
|
128
|
+
'spacer',
|
|
129
|
+
'spacer table',
|
|
130
|
+
'menu',
|
|
131
|
+
'n/a',
|
|
132
|
+
'na',
|
|
133
|
+
'none',
|
|
134
|
+
'null',
|
|
135
|
+
'empty',
|
|
136
|
+
'blank',
|
|
137
|
+
'undefined',
|
|
138
|
+
'table summary',
|
|
139
|
+
'summary',
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Phrases indicating structure-focused rather than content-focused table summary.
|
|
144
|
+
* @type {string[]}
|
|
145
|
+
*/
|
|
146
|
+
const STRUCTURE_PHRASES = [
|
|
147
|
+
'rows and columns',
|
|
148
|
+
'grid format',
|
|
149
|
+
'for display purposes',
|
|
150
|
+
'arranged in a grid',
|
|
151
|
+
'the following table shows',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Phrases indicating vague/rambling table summary content.
|
|
156
|
+
* @type {string[]}
|
|
157
|
+
*/
|
|
158
|
+
const VAGUE_PHRASES = [
|
|
159
|
+
'might find interesting',
|
|
160
|
+
'depending on their needs',
|
|
161
|
+
'users might find',
|
|
162
|
+
'may be useful',
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Valid ARIA parent-child interactive role combinations per the WAI-ARIA spec.
|
|
167
|
+
* These composite widget patterns require interactive children by design.
|
|
168
|
+
* Key = parent role, Value = array of valid child roles.
|
|
169
|
+
* @type {Object.<string, string[]>}
|
|
170
|
+
*/
|
|
171
|
+
const VALID_ARIA_NESTING = {
|
|
172
|
+
listbox: ['option'],
|
|
173
|
+
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
174
|
+
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
175
|
+
menuitem: ['menu', 'menubar'],
|
|
176
|
+
tablist: ['tab'],
|
|
177
|
+
tree: ['treeitem', 'group'],
|
|
178
|
+
treeitem: ['group', 'tree'],
|
|
179
|
+
grid: ['gridcell', 'row', 'rowgroup'],
|
|
180
|
+
row: ['gridcell', 'columnheader', 'rowheader', 'cell'],
|
|
181
|
+
rowgroup: ['row'],
|
|
182
|
+
radiogroup: ['radio'],
|
|
183
|
+
combobox: ['listbox', 'textbox', 'tree', 'grid', 'dialog'],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Thresholds for determining table complexity.
|
|
188
|
+
* @type {Object}
|
|
189
|
+
*/
|
|
190
|
+
const COMPLEXITY_THRESHOLDS = {
|
|
191
|
+
MAX_COLUMNS: 10,
|
|
192
|
+
MAX_HEADER_COLSPAN: 4,
|
|
193
|
+
MAX_ROWSPAN: 3,
|
|
194
|
+
MIN_HEADER_ROWS_FOR_COMPLEX: 3,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* WCAG 2.5.5 minimum target size in CSS pixels.
|
|
199
|
+
* @type {number}
|
|
200
|
+
*/
|
|
201
|
+
const MIN_TARGET_SIZE = 44;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Phrases that indicate a warning about new window/tab behavior.
|
|
205
|
+
* @type {string[]}
|
|
206
|
+
*/
|
|
207
|
+
const NEW_WINDOW_WARNINGS = [
|
|
208
|
+
'new window',
|
|
209
|
+
'new tab',
|
|
210
|
+
'opens in new',
|
|
211
|
+
'opens in a new',
|
|
212
|
+
'opens new',
|
|
213
|
+
'external link',
|
|
214
|
+
'external site',
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
INTERACTIVE_SELECTOR,
|
|
219
|
+
FOCUSABLE_SELECTOR,
|
|
220
|
+
LABELLABLE_ELEMENTS,
|
|
221
|
+
VALID_ALT_ELEMENTS,
|
|
222
|
+
RTL_LANGUAGES,
|
|
223
|
+
GENERIC_TITLES,
|
|
224
|
+
GENERIC_SUMMARIES,
|
|
225
|
+
STRUCTURE_PHRASES,
|
|
226
|
+
VAGUE_PHRASES,
|
|
227
|
+
VALID_ARIA_NESTING,
|
|
228
|
+
COMPLEXITY_THRESHOLDS,
|
|
229
|
+
MIN_TARGET_SIZE,
|
|
230
|
+
NEW_WINDOW_WARNINGS,
|
|
231
|
+
};
|
package/src/cssUtils.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CSS styling inspection utilities
|
|
3
|
+
* @module cssUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cssUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if an element's background is transparent/none.
|
|
9
|
+
* @param {CSSStyleDeclaration} style - The computed style
|
|
10
|
+
* @returns {boolean} True if background is transparent
|
|
11
|
+
*/
|
|
12
|
+
hasTransparentBackground(style) {
|
|
13
|
+
const bg = style.backgroundColor;
|
|
14
|
+
const bgImage = style.backgroundImage;
|
|
15
|
+
|
|
16
|
+
if (bgImage && bgImage !== 'none') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' || bg === '';
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if an element has no visible border.
|
|
25
|
+
* @param {CSSStyleDeclaration} style - The computed style
|
|
26
|
+
* @returns {boolean} True if no border
|
|
27
|
+
*/
|
|
28
|
+
hasNoBorder(style) {
|
|
29
|
+
return style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an element looks like regular text (no underline, background, or border).
|
|
34
|
+
* @param {HTMLElement} element - The element to check
|
|
35
|
+
* @returns {boolean} True if element looks like regular text
|
|
36
|
+
*/
|
|
37
|
+
looksLikeText(element) {
|
|
38
|
+
const style = window.getComputedStyle(element);
|
|
39
|
+
|
|
40
|
+
const noUnderline = !style.textDecoration.toLowerCase().includes('underline');
|
|
41
|
+
const noBackground = cssUtils.hasTransparentBackground(style);
|
|
42
|
+
const noBorder = cssUtils.hasNoBorder(style);
|
|
43
|
+
|
|
44
|
+
return noUnderline && noBackground && noBorder;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if an element has link-like styling (underline).
|
|
49
|
+
* @param {HTMLElement} element - The element to check
|
|
50
|
+
* @returns {boolean} True if element has link-like styling
|
|
51
|
+
*/
|
|
52
|
+
hasLinkStyling(element) {
|
|
53
|
+
const style = window.getComputedStyle(element);
|
|
54
|
+
return style.textDecoration.toLowerCase().includes('underline');
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if an element has button-like styling (background/border with padding).
|
|
59
|
+
* @param {HTMLElement} element - The element to check
|
|
60
|
+
* @returns {boolean} True if element has button-like styling
|
|
61
|
+
*/
|
|
62
|
+
hasButtonStyling(element) {
|
|
63
|
+
const style = window.getComputedStyle(element);
|
|
64
|
+
|
|
65
|
+
const hasBackground = !cssUtils.hasTransparentBackground(style);
|
|
66
|
+
const hasBorder = !cssUtils.hasNoBorder(style);
|
|
67
|
+
const hasPadding =
|
|
68
|
+
parseFloat(style.paddingTop) > 2 ||
|
|
69
|
+
parseFloat(style.paddingBottom) > 2 ||
|
|
70
|
+
parseFloat(style.paddingLeft) > 4 ||
|
|
71
|
+
parseFloat(style.paddingRight) > 4;
|
|
72
|
+
|
|
73
|
+
return (hasBackground || hasBorder) && hasPadding;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
module.exports = cssUtils;
|
package/src/domUtils.js
CHANGED
|
@@ -18,10 +18,8 @@ const domUtils = {
|
|
|
18
18
|
* @returns {Array} - An array of elements that have at least one attribute starting with the specified prefix.
|
|
19
19
|
*/
|
|
20
20
|
attrBegins(elements, prefix) {
|
|
21
|
-
return Array.from(elements).filter(
|
|
22
|
-
return Array.from(element.attributes).some(
|
|
23
|
-
attr.name.startsWith(prefix)
|
|
24
|
-
);
|
|
21
|
+
return Array.from(elements).filter(element => {
|
|
22
|
+
return Array.from(element.attributes).some(attr => attr.name.startsWith(prefix));
|
|
25
23
|
});
|
|
26
24
|
},
|
|
27
25
|
|
|
@@ -43,7 +41,9 @@ const domUtils = {
|
|
|
43
41
|
* @returns {Object} An object containing the element's attributes as key-value pairs.
|
|
44
42
|
*/
|
|
45
43
|
getAttributes(element) {
|
|
46
|
-
if (!element)
|
|
44
|
+
if (!element) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
47
|
return [...element.attributes].reduce((attrs, attr) => {
|
|
48
48
|
attrs[attr.name] = attr.value;
|
|
49
49
|
return attrs;
|
|
@@ -80,7 +80,7 @@ const domUtils = {
|
|
|
80
80
|
* @returns {number} The length of the document's HTML content without whitespace.
|
|
81
81
|
*/
|
|
82
82
|
getDocumentSize() {
|
|
83
|
-
return document.documentElement.outerHTML.replace(/\s+/g,
|
|
83
|
+
return document.documentElement.outerHTML.replace(/\s+/g, '').length;
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -89,17 +89,17 @@ const domUtils = {
|
|
|
89
89
|
* @returns {Element[]} An array of elements that have duplicate IDs.
|
|
90
90
|
*/
|
|
91
91
|
getElementsWithDuplicateIds() {
|
|
92
|
-
const nodes = document.querySelectorAll(
|
|
92
|
+
const nodes = document.querySelectorAll('[id]');
|
|
93
93
|
const ids = {};
|
|
94
94
|
const duplicates = [];
|
|
95
|
-
nodes.forEach(
|
|
95
|
+
nodes.forEach(node => {
|
|
96
96
|
const id = node.id.trim();
|
|
97
97
|
ids[id] = (ids[id] || 0) + 1;
|
|
98
98
|
if (ids[id] > 1 && !duplicates.includes(id)) {
|
|
99
99
|
duplicates.push(id);
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
|
-
return duplicates.map(
|
|
102
|
+
return duplicates.map(id => document.querySelector(`#${id}`));
|
|
103
103
|
},
|
|
104
104
|
|
|
105
105
|
/**
|
|
@@ -119,13 +119,17 @@ const domUtils = {
|
|
|
119
119
|
* @returns {string} The XPath string representing the element's location in the DOM.
|
|
120
120
|
*/
|
|
121
121
|
getXPath(element) {
|
|
122
|
-
if (!element)
|
|
123
|
-
|
|
122
|
+
if (!element) {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
let path = '';
|
|
124
126
|
while (element && element.nodeType === Node.ELEMENT_NODE) {
|
|
125
127
|
let index = 1;
|
|
126
128
|
let sibling = element.previousElementSibling;
|
|
127
129
|
while (sibling) {
|
|
128
|
-
if (sibling.nodeName === element.nodeName)
|
|
130
|
+
if (sibling.nodeName === element.nodeName) {
|
|
131
|
+
index++;
|
|
132
|
+
}
|
|
129
133
|
sibling = sibling.previousElementSibling;
|
|
130
134
|
}
|
|
131
135
|
path = `/${element.nodeName.toLowerCase()}[${index}]` + path;
|
|
@@ -134,6 +138,258 @@ const domUtils = {
|
|
|
134
138
|
return path;
|
|
135
139
|
},
|
|
136
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Checks if a given ID is referenced by other elements in the document.
|
|
143
|
+
* References include label[for], aria-labelledby, aria-describedby,
|
|
144
|
+
* aria-controls, aria-owns, aria-activedescendant, aria-flowto,
|
|
145
|
+
* aria-errormessage, href="#id", headers, and list attributes.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} id - The ID value to check for references.
|
|
148
|
+
* @returns {boolean} True if the ID is referenced by another element.
|
|
149
|
+
*/
|
|
150
|
+
isIdReferenced(id) {
|
|
151
|
+
if (!id) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// CSS-escape the ID for use in attribute selectors
|
|
156
|
+
const escaped = CSS.escape(id);
|
|
157
|
+
|
|
158
|
+
// Direct attribute references
|
|
159
|
+
if (document.querySelector('[for="' + escaped + '"]')) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ARIA token-list attributes that reference IDs
|
|
164
|
+
const ariaTokenAttrs = [
|
|
165
|
+
'aria-labelledby',
|
|
166
|
+
'aria-describedby',
|
|
167
|
+
'aria-controls',
|
|
168
|
+
'aria-owns',
|
|
169
|
+
'aria-flowto',
|
|
170
|
+
];
|
|
171
|
+
for (const attr of ariaTokenAttrs) {
|
|
172
|
+
const elements = document.querySelectorAll('[' + attr + ']');
|
|
173
|
+
for (const el of elements) {
|
|
174
|
+
const ids = el.getAttribute(attr).trim().split(/\s+/);
|
|
175
|
+
if (ids.includes(id)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Single-ID ARIA references
|
|
182
|
+
const ariaSingleAttrs = ['aria-activedescendant', 'aria-errormessage'];
|
|
183
|
+
for (const attr of ariaSingleAttrs) {
|
|
184
|
+
if (document.querySelector('[' + attr + '="' + escaped + '"]')) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Fragment references in href
|
|
190
|
+
if (document.querySelector('a[href="#' + escaped + '"]')) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Table headers attribute (space-separated list of IDs)
|
|
195
|
+
const headerElements = document.querySelectorAll('[headers]');
|
|
196
|
+
for (const el of headerElements) {
|
|
197
|
+
const ids = el.getAttribute('headers').trim().split(/\s+/);
|
|
198
|
+
if (ids.includes(id)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// list attribute on input elements
|
|
204
|
+
if (document.querySelector('input[list="' + escaped + '"]')) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if an element is hidden from assistive technology.
|
|
213
|
+
* @param {HTMLElement} element - The element to check
|
|
214
|
+
* @returns {boolean} True if element or ancestor has aria-hidden="true"
|
|
215
|
+
*/
|
|
216
|
+
isHiddenFromAT(element) {
|
|
217
|
+
if (!element) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
element.getAttribute('aria-hidden') === 'true' ||
|
|
222
|
+
(element.closest && !!element.closest('[aria-hidden="true"]'))
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if an element is effectively interactive (not disabled, not hidden from AT,
|
|
228
|
+
* not role=presentation/none).
|
|
229
|
+
* @param {HTMLElement} element - The element to check
|
|
230
|
+
* @returns {boolean} True if the element is interactive
|
|
231
|
+
*/
|
|
232
|
+
isEffectivelyInteractive(element) {
|
|
233
|
+
if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
if (element.getAttribute('aria-hidden') === 'true') {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const role = element.getAttribute('role');
|
|
240
|
+
if (role === 'presentation' || role === 'none') {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Check if a parent-child pair constitutes a valid ARIA composite widget nesting.
|
|
248
|
+
* @param {HTMLElement} parent - The parent interactive element
|
|
249
|
+
* @param {HTMLElement} child - The nested interactive element
|
|
250
|
+
* @returns {boolean} True if this is a valid ARIA nesting pattern
|
|
251
|
+
*/
|
|
252
|
+
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
|
+
const parentRole = (parent.getAttribute('role') || '').toLowerCase();
|
|
269
|
+
const childRole = (child.getAttribute('role') || '').toLowerCase();
|
|
270
|
+
|
|
271
|
+
if (parentRole && VALID_ARIA_NESTING[parentRole]) {
|
|
272
|
+
if (VALID_ARIA_NESTING[parentRole].includes(childRole)) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (parent.tagName === 'LABEL' && child.closest('label') === parent) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return false;
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if element has event handlers that suggest interactivity.
|
|
286
|
+
* @param {HTMLElement} element - The element to check
|
|
287
|
+
* @returns {boolean} True if element has interactive handlers
|
|
288
|
+
*/
|
|
289
|
+
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
|
+
);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check if an element is within a navigation context (nav, menu, menubar).
|
|
302
|
+
* @param {HTMLElement} element - The element to check
|
|
303
|
+
* @returns {boolean} True if element is within a navigation context
|
|
304
|
+
*/
|
|
305
|
+
isWithinNavContext(element) {
|
|
306
|
+
let ancestor = element.parentElement;
|
|
307
|
+
while (ancestor) {
|
|
308
|
+
const tag = ancestor.tagName;
|
|
309
|
+
const role = (ancestor.getAttribute('role') || '').toLowerCase();
|
|
310
|
+
if (tag === 'NAV' || role === 'navigation' || role === 'menu' || role === 'menubar') {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
ancestor = ancestor.parentElement;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Check if an element is a landmark element.
|
|
320
|
+
* @param {HTMLElement} element - The element to check
|
|
321
|
+
* @returns {boolean} True if element is a landmark
|
|
322
|
+
*/
|
|
323
|
+
isLandmark(element) {
|
|
324
|
+
const landmarkTags = ['NAV', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'SECTION'];
|
|
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)) {
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
const role = (element.getAttribute('role') || '').toLowerCase();
|
|
339
|
+
return landmarkRoles.includes(role);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get the nearest semantic container for an element.
|
|
344
|
+
* @param {HTMLElement} el - The element
|
|
345
|
+
* @returns {HTMLElement|null} The nearest semantic container
|
|
346
|
+
*/
|
|
347
|
+
getSemanticContainer(el) {
|
|
348
|
+
const containerTags = ['ARTICLE', 'SECTION', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'FIGURE'];
|
|
349
|
+
let ancestor = el.parentElement;
|
|
350
|
+
while (ancestor && ancestor !== document.body) {
|
|
351
|
+
if (containerTags.includes(ancestor.tagName) || ancestor.hasAttribute('role')) {
|
|
352
|
+
return ancestor;
|
|
353
|
+
}
|
|
354
|
+
ancestor = ancestor.parentElement;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Gets the heading level from an element.
|
|
361
|
+
* Supports native heading elements (h1-h6) and ARIA headings (role="heading" with aria-level).
|
|
362
|
+
* @param {HTMLElement} element - The element to check
|
|
363
|
+
* @returns {number|null} The heading level (1-6+) or null if not a valid heading
|
|
364
|
+
*/
|
|
365
|
+
getHeadingLevel(element) {
|
|
366
|
+
const tagName = element.tagName.toUpperCase();
|
|
367
|
+
const role = element.getAttribute('role');
|
|
368
|
+
const ariaLevel = element.getAttribute('aria-level');
|
|
369
|
+
|
|
370
|
+
if (role === 'heading') {
|
|
371
|
+
if (ariaLevel) {
|
|
372
|
+
const level = parseInt(ariaLevel, 10);
|
|
373
|
+
if (!isNaN(level) && level >= 1) {
|
|
374
|
+
return level;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (/^H[1-6]$/.test(tagName)) {
|
|
381
|
+
if (ariaLevel) {
|
|
382
|
+
const lvl = parseInt(ariaLevel, 10);
|
|
383
|
+
if (!isNaN(lvl) && lvl >= 1) {
|
|
384
|
+
return lvl;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return parseInt(tagName.charAt(1), 10);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
},
|
|
392
|
+
|
|
137
393
|
/**
|
|
138
394
|
* Checks if the given element has focus.
|
|
139
395
|
*
|