@afixt/test-utils 1.2.0 → 1.2.1
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/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/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/src/formUtils.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Form-related accessibility utilities
|
|
3
|
+
* @module formUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const formUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if an element is a labellable form control per HTML spec.
|
|
9
|
+
* @param {Element} element - The element to check
|
|
10
|
+
* @returns {boolean} True if the element is labellable
|
|
11
|
+
*/
|
|
12
|
+
isLabellable(element) {
|
|
13
|
+
if (!element) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const labellable = ['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress'];
|
|
17
|
+
const tagName = element.tagName.toLowerCase();
|
|
18
|
+
return labellable.includes(tagName);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if an element is a hidden input.
|
|
23
|
+
* @param {Element} element - The element to check
|
|
24
|
+
* @returns {boolean} True if the element is a hidden input
|
|
25
|
+
*/
|
|
26
|
+
isHiddenInput(element) {
|
|
27
|
+
if (!element) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
element.tagName.toLowerCase() === 'input' &&
|
|
32
|
+
(element.getAttribute('type') || '').toLowerCase() === 'hidden'
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Checks if an element has an explicit accessible name via aria-labelledby, aria-label, or title.
|
|
38
|
+
* Text content is NOT checked (useful for containers like radiogroup/group where
|
|
39
|
+
* text content is not a valid accessible name source).
|
|
40
|
+
* @param {Element} element - The element to check
|
|
41
|
+
* @returns {boolean} True if element has an explicit accessible name
|
|
42
|
+
*/
|
|
43
|
+
hasExplicitAccessibleName(element) {
|
|
44
|
+
if (element.hasAttribute('aria-labelledby')) {
|
|
45
|
+
const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
|
|
46
|
+
for (let i = 0; i < ids.length; i++) {
|
|
47
|
+
const labelEl = document.getElementById(ids[i]);
|
|
48
|
+
if (labelEl && labelEl.textContent.trim()) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (element.hasAttribute('aria-label')) {
|
|
55
|
+
const ariaLabel = element.getAttribute('aria-label').trim();
|
|
56
|
+
if (ariaLabel) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (element.hasAttribute('title')) {
|
|
62
|
+
const title = element.getAttribute('title').trim();
|
|
63
|
+
if (title) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Checks if a form control is properly grouped with an accessible label.
|
|
73
|
+
* Proper grouping means inside a fieldset with a legend, or inside an element
|
|
74
|
+
* with role="radiogroup" or role="group" that has an explicit accessible name.
|
|
75
|
+
* @param {HTMLElement} control - The form control to check
|
|
76
|
+
* @param {string} [groupRole='radiogroup'] - The ARIA group role to look for
|
|
77
|
+
* @returns {boolean} True if properly grouped
|
|
78
|
+
*/
|
|
79
|
+
isProperlyGrouped(control, groupRole) {
|
|
80
|
+
groupRole = groupRole || 'radiogroup';
|
|
81
|
+
|
|
82
|
+
const fieldset = control.closest('fieldset');
|
|
83
|
+
if (fieldset) {
|
|
84
|
+
const legend = fieldset.querySelector('legend');
|
|
85
|
+
if (legend && legend.textContent.trim()) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const group = control.closest('[role="' + groupRole + '"]');
|
|
92
|
+
if (group) {
|
|
93
|
+
if (formUtils.hasExplicitAccessibleName(group)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Finds the grouping ancestor for an element (fieldset, form, or ARIA group).
|
|
104
|
+
* @param {Element} element - The element to start from
|
|
105
|
+
* @returns {Element} The grouping ancestor or document.body if none found
|
|
106
|
+
*/
|
|
107
|
+
findGroupingAncestor(element) {
|
|
108
|
+
let current = element.parentElement;
|
|
109
|
+
|
|
110
|
+
while (current && current !== document.body) {
|
|
111
|
+
const tagName = current.tagName;
|
|
112
|
+
const role = current.getAttribute('role');
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
tagName === 'FIELDSET' ||
|
|
116
|
+
tagName === 'FORM' ||
|
|
117
|
+
role === 'radiogroup' ||
|
|
118
|
+
role === 'group'
|
|
119
|
+
) {
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
current = current.parentElement;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return document.body;
|
|
127
|
+
},
|
|
128
|
+
|
|
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
|
+
/**
|
|
152
|
+
* Get the text content of an element, excluding form control children.
|
|
153
|
+
* Useful for wrapped labels like <label><input type="checkbox"> Remember me</label>
|
|
154
|
+
* where the input element should not contribute to label text.
|
|
155
|
+
* @param {Element} element - The element to get text from
|
|
156
|
+
* @returns {string} The text content excluding form controls
|
|
157
|
+
*/
|
|
158
|
+
getTextContentExcludingControls(element) {
|
|
159
|
+
let text = '';
|
|
160
|
+
for (let i = 0; i < element.childNodes.length; i++) {
|
|
161
|
+
const node = element.childNodes[i];
|
|
162
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
163
|
+
text += node.textContent;
|
|
164
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
165
|
+
const tagName = node.tagName.toLowerCase();
|
|
166
|
+
if (!['input', 'select', 'textarea', 'button'].includes(tagName)) {
|
|
167
|
+
text += formUtils.getTextContentExcludingControls(node);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return text;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
module.exports = formUtils;
|
|
@@ -1,44 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a CSS content value is meaningful (non-empty, non-whitespace).
|
|
3
|
+
* Strips surrounding quotes and checks for actual visible content.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} rawValue - The raw CSS content value from getComputedStyle
|
|
6
|
+
* @returns {string|false} The cleaned content string or false if empty/whitespace
|
|
7
|
+
*/
|
|
8
|
+
function extractMeaningfulContent(rawValue) {
|
|
9
|
+
if (!rawValue || rawValue === 'none' || rawValue === 'normal') {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Remove surrounding quotes (single or double)
|
|
14
|
+
let cleaned = rawValue.replace(/^["'](.*)["']$/, '$1');
|
|
15
|
+
|
|
16
|
+
// Trim whitespace - content that is only whitespace is not meaningful
|
|
17
|
+
cleaned = cleaned.trim();
|
|
18
|
+
|
|
19
|
+
return cleaned.length > 0 ? cleaned : false;
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
/**
|
|
2
23
|
* Gets the CSS generated content for an element's ::before or ::after pseudo-elements.
|
|
3
24
|
* This function only checks for content added via the CSS `content` property,
|
|
4
|
-
* not the element's own text content.
|
|
25
|
+
* not the element's own text content. Empty, whitespace-only, and blank content
|
|
26
|
+
* values are filtered out as they do not convey meaningful information.
|
|
5
27
|
*
|
|
6
28
|
* @param {Element} el - The DOM element to check
|
|
7
29
|
* @param {string} [pseudoElement='both'] - Which pseudo-element to check ('before', 'after', or 'both')
|
|
8
30
|
* @returns {string|boolean} The generated content as a string or false if none exists
|
|
9
31
|
*/
|
|
10
32
|
function getCSSGeneratedContent(el, pseudoElement = 'both') {
|
|
11
|
-
if (!el)
|
|
33
|
+
if (!el) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
12
36
|
|
|
13
37
|
let content = '';
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const cleanBefore = before.replace(/^["'](.*)["']$/, '$1');
|
|
39
|
+
try {
|
|
40
|
+
if (pseudoElement === 'before' || pseudoElement === 'both') {
|
|
41
|
+
const style = window.getComputedStyle(el, '::before');
|
|
42
|
+
const before = style.getPropertyValue('content');
|
|
43
|
+
const cleanBefore = extractMeaningfulContent(before);
|
|
21
44
|
if (cleanBefore) {
|
|
22
45
|
content += cleanBefore;
|
|
23
46
|
}
|
|
24
47
|
}
|
|
25
|
-
}
|
|
26
48
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
// Remove surrounding quotes if present
|
|
32
|
-
const cleanAfter = after.replace(/^["'](.*)["']$/, '$1');
|
|
49
|
+
if (pseudoElement === 'after' || pseudoElement === 'both') {
|
|
50
|
+
const style = window.getComputedStyle(el, '::after');
|
|
51
|
+
const after = style.getPropertyValue('content');
|
|
52
|
+
const cleanAfter = extractMeaningfulContent(after);
|
|
33
53
|
if (cleanAfter) {
|
|
34
54
|
content += (content ? ' ' : '') + cleanAfter;
|
|
35
55
|
}
|
|
36
56
|
}
|
|
57
|
+
} catch (_e) {
|
|
58
|
+
return false;
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
return content ? content.trim() : false;
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
module.exports = {
|
|
43
|
-
getCSSGeneratedContent
|
|
44
|
-
};
|
|
65
|
+
getCSSGeneratedContent,
|
|
66
|
+
};
|
package/src/index.js
CHANGED
|
@@ -63,6 +63,18 @@ const isValidUrl = require('./isValidUrl.js');
|
|
|
63
63
|
// String utilities
|
|
64
64
|
const stringUtils = require('./stringUtils.js');
|
|
65
65
|
|
|
66
|
+
// Constants
|
|
67
|
+
const constants = require('./constants.js');
|
|
68
|
+
|
|
69
|
+
// CSS utilities
|
|
70
|
+
const cssUtils = require('./cssUtils.js');
|
|
71
|
+
|
|
72
|
+
// Form utilities
|
|
73
|
+
const formUtils = require('./formUtils.js');
|
|
74
|
+
|
|
75
|
+
// Table utilities
|
|
76
|
+
const tableUtils = require('./tableUtils.js');
|
|
77
|
+
|
|
66
78
|
// Query cache utilities
|
|
67
79
|
const queryCache = require('./queryCache.js');
|
|
68
80
|
|
|
@@ -102,6 +114,10 @@ module.exports = {
|
|
|
102
114
|
...testOrder,
|
|
103
115
|
...isValidUrl,
|
|
104
116
|
...stringUtils,
|
|
117
|
+
...constants,
|
|
118
|
+
...cssUtils,
|
|
119
|
+
...formUtils,
|
|
120
|
+
...tableUtils,
|
|
105
121
|
...queryCache,
|
|
106
|
-
...listEventListeners
|
|
107
|
-
};
|
|
122
|
+
...listEventListeners,
|
|
123
|
+
};
|
package/src/stringUtils.js
CHANGED
|
@@ -132,6 +132,149 @@ const stringUtils = (function () {
|
|
|
132
132
|
return getAllText(element).trim() !== '';
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Checks if a string is empty or contains only whitespace/invisible characters,
|
|
137
|
+
* including zero-width spaces, no-break spaces, and other Unicode whitespace.
|
|
138
|
+
* @param {string} str - The string to check
|
|
139
|
+
* @returns {boolean} True if empty or whitespace/invisible only
|
|
140
|
+
*/
|
|
141
|
+
function isEmptyOrWhitespace(str) {
|
|
142
|
+
if (!str) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// eslint-disable-next-line no-misleading-character-class
|
|
146
|
+
const cleaned = str.replace(/[\s\u00A0\u200B\u200C\u200D\u2060\uFEFF]/g, '');
|
|
147
|
+
return cleaned.length === 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks if a title is generic, meaningless, or placeholder-like.
|
|
152
|
+
* @param {string} title - The title to check
|
|
153
|
+
* @returns {boolean} True if the title is generic/meaningless
|
|
154
|
+
*/
|
|
155
|
+
function isGenericTitle(title) {
|
|
156
|
+
if (!title) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const genericTitles = ['iframe', 'frame', 'untitled', 'title', 'content', 'main', 'page'];
|
|
160
|
+
const normalized = title.toLowerCase().trim();
|
|
161
|
+
|
|
162
|
+
if (genericTitles.includes(normalized)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (/^(frame|iframe|untitled|title)\d*$/i.test(normalized)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks if the given text is generic/meaningless link text.
|
|
175
|
+
* @param {string} text - The accessible name to check
|
|
176
|
+
* @param {string[]} [genericList] - Optional custom list of generic link text
|
|
177
|
+
* @returns {boolean} True if the text is generic
|
|
178
|
+
*/
|
|
179
|
+
function isGenericLinkText(text, genericList) {
|
|
180
|
+
if (!text) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const defaults = [
|
|
184
|
+
'click here',
|
|
185
|
+
'here',
|
|
186
|
+
'more',
|
|
187
|
+
'read more',
|
|
188
|
+
'learn more',
|
|
189
|
+
'click',
|
|
190
|
+
'link',
|
|
191
|
+
'this',
|
|
192
|
+
'page',
|
|
193
|
+
'article',
|
|
194
|
+
'continue',
|
|
195
|
+
'go',
|
|
196
|
+
'see more',
|
|
197
|
+
'view',
|
|
198
|
+
'download',
|
|
199
|
+
'pdf',
|
|
200
|
+
'document',
|
|
201
|
+
'form',
|
|
202
|
+
'submit',
|
|
203
|
+
'button',
|
|
204
|
+
'press',
|
|
205
|
+
'select',
|
|
206
|
+
'choose',
|
|
207
|
+
];
|
|
208
|
+
const list = genericList || defaults;
|
|
209
|
+
const lowerText = text.toLowerCase().trim();
|
|
210
|
+
return list.some(function (generic) {
|
|
211
|
+
return lowerText === generic;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the actual visible text content of an element, ignoring aria-label.
|
|
217
|
+
* @param {Element} element - The DOM element
|
|
218
|
+
* @returns {string} The visible text content
|
|
219
|
+
*/
|
|
220
|
+
function getActualVisibleText(element) {
|
|
221
|
+
if (!element) {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
return (element.textContent || '').trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Checks if text contains a warning about new window/tab behavior.
|
|
229
|
+
* @param {string} text - The text to check
|
|
230
|
+
* @returns {boolean} True if the text contains a warning
|
|
231
|
+
*/
|
|
232
|
+
function hasNewWindowWarning(text) {
|
|
233
|
+
if (!text) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const warnings = [
|
|
237
|
+
'new window',
|
|
238
|
+
'new tab',
|
|
239
|
+
'opens in new',
|
|
240
|
+
'opens in a new',
|
|
241
|
+
'opens new',
|
|
242
|
+
'external link',
|
|
243
|
+
'external site',
|
|
244
|
+
];
|
|
245
|
+
const lowerText = text.toLowerCase();
|
|
246
|
+
return warnings.some(function (warning) {
|
|
247
|
+
return lowerText.includes(warning);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Extracts DOM text content from an element, including img alt text,
|
|
253
|
+
* but excluding ARIA attributes (aria-label, aria-labelledby).
|
|
254
|
+
* Useful for detecting actual visible/DOM text that may conflict with ARIA labels.
|
|
255
|
+
*
|
|
256
|
+
* @param {Element} root - The DOM element from which to extract text.
|
|
257
|
+
* @returns {string} Concatenated text from text nodes and img alt attributes.
|
|
258
|
+
*/
|
|
259
|
+
function textIncludingImgAlt(root) {
|
|
260
|
+
let out = '';
|
|
261
|
+
const walker = document.createTreeWalker(
|
|
262
|
+
root,
|
|
263
|
+
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT // eslint-disable-line no-bitwise
|
|
264
|
+
);
|
|
265
|
+
let n = walker.currentNode;
|
|
266
|
+
while (n) {
|
|
267
|
+
if (n.nodeType === Node.TEXT_NODE) {
|
|
268
|
+
out += n.nodeValue;
|
|
269
|
+
}
|
|
270
|
+
if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'IMG') {
|
|
271
|
+
out += n.getAttribute('alt') || '';
|
|
272
|
+
}
|
|
273
|
+
n = walker.nextNode();
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
135
278
|
return {
|
|
136
279
|
isEmpty,
|
|
137
280
|
isString,
|
|
@@ -142,6 +285,12 @@ const stringUtils = (function () {
|
|
|
142
285
|
getPathFromUrl,
|
|
143
286
|
getAllText,
|
|
144
287
|
hasText,
|
|
288
|
+
textIncludingImgAlt,
|
|
289
|
+
isEmptyOrWhitespace,
|
|
290
|
+
isGenericTitle,
|
|
291
|
+
isGenericLinkText,
|
|
292
|
+
getActualVisibleText,
|
|
293
|
+
hasNewWindowWarning,
|
|
145
294
|
};
|
|
146
295
|
})();
|
|
147
296
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Table-related accessibility utilities
|
|
3
|
+
* @module tableUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const tableUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if table has multiple header rows (rows containing th elements).
|
|
9
|
+
* @param {HTMLElement} table - The table element
|
|
10
|
+
* @returns {boolean} True if table has multiple header rows
|
|
11
|
+
*/
|
|
12
|
+
hasMultipleHeaderRows(table) {
|
|
13
|
+
const rows = table.querySelectorAll('tr');
|
|
14
|
+
let headerRowCount = 0;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < rows.length; i++) {
|
|
17
|
+
const thCells = rows[i].querySelectorAll('th');
|
|
18
|
+
const tdCells = rows[i].querySelectorAll('td');
|
|
19
|
+
|
|
20
|
+
if (thCells.length > 0 && thCells.length >= tdCells.length) {
|
|
21
|
+
headerRowCount++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return headerRowCount >= 3;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if table has header cells with large colspan/rowspan values.
|
|
30
|
+
* @param {HTMLElement} table - The table element
|
|
31
|
+
* @param {number} [maxColspan=4] - Maximum acceptable colspan
|
|
32
|
+
* @param {number} [maxRowspan=3] - Maximum acceptable rowspan
|
|
33
|
+
* @returns {boolean} True if table has complex header spans
|
|
34
|
+
*/
|
|
35
|
+
hasComplexHeaderSpans(table, maxColspan, maxRowspan) {
|
|
36
|
+
maxColspan = maxColspan || 4;
|
|
37
|
+
maxRowspan = maxRowspan || 3;
|
|
38
|
+
|
|
39
|
+
const headerCells = table.querySelectorAll('th[colspan], th[rowspan]');
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < headerCells.length; i++) {
|
|
42
|
+
const colspan = parseInt(headerCells[i].getAttribute('colspan'), 10) || 1;
|
|
43
|
+
const rowspan = parseInt(headerCells[i].getAttribute('rowspan'), 10) || 1;
|
|
44
|
+
|
|
45
|
+
if (colspan > maxColspan || rowspan > maxRowspan) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Count effective columns in a table (accounting for colspan).
|
|
55
|
+
* @param {HTMLElement} table - The table element
|
|
56
|
+
* @returns {number} Maximum column count
|
|
57
|
+
*/
|
|
58
|
+
getMaxColumnCount(table) {
|
|
59
|
+
const rows = table.querySelectorAll('tr');
|
|
60
|
+
let maxCols = 0;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < rows.length; i++) {
|
|
63
|
+
let colCount = 0;
|
|
64
|
+
const cells = rows[i].querySelectorAll('td, th');
|
|
65
|
+
|
|
66
|
+
for (let j = 0; j < cells.length; j++) {
|
|
67
|
+
const colspan = parseInt(cells[j].getAttribute('colspan'), 10) || 1;
|
|
68
|
+
colCount += colspan;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (colCount > maxCols) {
|
|
72
|
+
maxCols = colCount;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return maxCols;
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if table has multiple rows with colspan/rowspan on header cells,
|
|
81
|
+
* indicating multi-level grouped headers.
|
|
82
|
+
* @param {HTMLElement} table - The table element
|
|
83
|
+
* @returns {boolean} True if table has complex header groupings
|
|
84
|
+
*/
|
|
85
|
+
hasMultiLevelHeaderGroupings(table) {
|
|
86
|
+
const rows = table.querySelectorAll('tr');
|
|
87
|
+
let rowsWithHeaderSpans = 0;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < rows.length; i++) {
|
|
90
|
+
const spannedHeaders = rows[i].querySelectorAll('th[colspan], th[rowspan]');
|
|
91
|
+
if (spannedHeaders.length > 0) {
|
|
92
|
+
rowsWithHeaderSpans++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return rowsWithHeaderSpans >= 2;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks if an HTML table has only header cells (th) and no data cells (td).
|
|
101
|
+
* Indicates headers potentially separated from their data.
|
|
102
|
+
* @param {HTMLTableElement} table - The table element to check
|
|
103
|
+
* @returns {boolean} True if table has only headers, no data
|
|
104
|
+
*/
|
|
105
|
+
isHeaderOnlyTable(table) {
|
|
106
|
+
const hasHeaders = table.querySelectorAll('th').length > 0;
|
|
107
|
+
const hasDataCells = table.querySelectorAll('td').length > 0;
|
|
108
|
+
return hasHeaders && !hasDataCells;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Checks if an ARIA table has only header roles and no cell roles.
|
|
113
|
+
* @param {HTMLElement} ariaTable - The element with role="table"
|
|
114
|
+
* @returns {boolean} True if ARIA table has only headers, no data cells
|
|
115
|
+
*/
|
|
116
|
+
isHeaderOnlyAriaTable(ariaTable) {
|
|
117
|
+
const hasHeaders =
|
|
118
|
+
ariaTable.querySelectorAll('[role="columnheader"], [role="rowheader"]').length > 0;
|
|
119
|
+
const hasDataCells =
|
|
120
|
+
ariaTable.querySelectorAll('[role="cell"], [role="gridcell"]').length > 0;
|
|
121
|
+
return hasHeaders && !hasDataCells;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Checks if a string contains only punctuation characters.
|
|
126
|
+
* @param {string} str - The string to check
|
|
127
|
+
* @returns {boolean} True if the string contains only punctuation
|
|
128
|
+
*/
|
|
129
|
+
isPunctuationOnly(str) {
|
|
130
|
+
return /^[\p{P}\p{S}]+$/u.test(str);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Checks if a summary is generic/useless.
|
|
135
|
+
* @param {string} summary - The summary text to check
|
|
136
|
+
* @param {string[]} [genericList] - Optional custom list of generic summaries
|
|
137
|
+
* @returns {boolean} True if the summary is generic
|
|
138
|
+
*/
|
|
139
|
+
isGenericSummary(summary, genericList) {
|
|
140
|
+
const GENERIC_SUMMARIES = genericList || [
|
|
141
|
+
'table',
|
|
142
|
+
'data',
|
|
143
|
+
'data table',
|
|
144
|
+
'information',
|
|
145
|
+
'content',
|
|
146
|
+
'main content',
|
|
147
|
+
'layout',
|
|
148
|
+
'layout table',
|
|
149
|
+
'for layout',
|
|
150
|
+
'table for layout purposes',
|
|
151
|
+
'structural table',
|
|
152
|
+
'this table is used for page layout',
|
|
153
|
+
'header',
|
|
154
|
+
'footer',
|
|
155
|
+
'navigation',
|
|
156
|
+
'nav',
|
|
157
|
+
'body',
|
|
158
|
+
'combobox',
|
|
159
|
+
'links design table',
|
|
160
|
+
'title and navigation',
|
|
161
|
+
'main heading',
|
|
162
|
+
'spacer',
|
|
163
|
+
'spacer table',
|
|
164
|
+
'menu',
|
|
165
|
+
'n/a',
|
|
166
|
+
'na',
|
|
167
|
+
'none',
|
|
168
|
+
'null',
|
|
169
|
+
'empty',
|
|
170
|
+
'blank',
|
|
171
|
+
'undefined',
|
|
172
|
+
'table summary',
|
|
173
|
+
'summary',
|
|
174
|
+
];
|
|
175
|
+
const normalized = summary.toLowerCase().trim();
|
|
176
|
+
return GENERIC_SUMMARIES.includes(normalized);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
module.exports = tableUtils;
|
package/src/testContrast.js
CHANGED
|
@@ -25,13 +25,47 @@ function getComputedBackgroundColor(el, options) {
|
|
|
25
25
|
if (bgImage === 'none') {
|
|
26
26
|
const bgColor = styles.getPropertyValue('background-color');
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
// Check for any fully transparent background (not just rgba(0,0,0,0))
|
|
29
|
+
const parsed = parseRGB(bgColor);
|
|
30
|
+
if (
|
|
31
|
+
bgColor === 'rgba(0, 0, 0, 0)' ||
|
|
32
|
+
bgColor === 'transparent' ||
|
|
33
|
+
(parsed && parsed[4] !== undefined && parseFloat(parsed[4]) === 0)
|
|
34
|
+
) {
|
|
29
35
|
if (el.parentElement) {
|
|
30
36
|
return getComputedBackgroundColor(el.parentElement, options);
|
|
31
37
|
}
|
|
32
38
|
return opts.fallbackColor !== undefined ? fallbackColor : false;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
// Handle semi-transparent backgrounds by blending with parent
|
|
42
|
+
if (parsed && parsed[4] !== undefined && parseFloat(parsed[4]) < 1) {
|
|
43
|
+
const alpha = parseFloat(parsed[4]);
|
|
44
|
+
const parentBg = el.parentElement
|
|
45
|
+
? getComputedBackgroundColor(el.parentElement, options)
|
|
46
|
+
: fallbackColor;
|
|
47
|
+
|
|
48
|
+
if (parentBg && parentBg !== false) {
|
|
49
|
+
const parentParsed = parseRGB(parentBg);
|
|
50
|
+
if (parentParsed) {
|
|
51
|
+
// Alpha composite: result = fg * alpha + bg * (1 - alpha)
|
|
52
|
+
const r = Math.round(
|
|
53
|
+
parseInt(parsed[1], 10) * alpha +
|
|
54
|
+
parseInt(parentParsed[1], 10) * (1 - alpha)
|
|
55
|
+
);
|
|
56
|
+
const g = Math.round(
|
|
57
|
+
parseInt(parsed[2], 10) * alpha +
|
|
58
|
+
parseInt(parentParsed[2], 10) * (1 - alpha)
|
|
59
|
+
);
|
|
60
|
+
const b = Math.round(
|
|
61
|
+
parseInt(parsed[3], 10) * alpha +
|
|
62
|
+
parseInt(parentParsed[3], 10) * (1 - alpha)
|
|
63
|
+
);
|
|
64
|
+
return 'rgb(' + r + ', ' + g + ', ' + b + ')';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
return bgColor;
|
|
36
70
|
} else {
|
|
37
71
|
if (!skipBackgroundImages) {
|