@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/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;
|
package/src/getAccessibleText.js
CHANGED
|
@@ -5,9 +5,16 @@ const { isEmpty } = require('./stringUtils.js');
|
|
|
5
5
|
* Traverses the DOM subtree collecting text from text nodes, img alt attributes,
|
|
6
6
|
* and input[type="image"] alt attributes.
|
|
7
7
|
* @param {Element} el - The DOM element.
|
|
8
|
+
* @param {Object} [options] - Configuration options.
|
|
9
|
+
* @param {boolean} [options.visibleOnly=false] - If true, return only visually rendered text,
|
|
10
|
+
* skipping aria-label, img alt, input[type="image"] alt, style, script, and
|
|
11
|
+
* aria-hidden="true" elements. Useful for WCAG 2.5.3 Label in Name comparisons.
|
|
8
12
|
* @returns {string} The accessible text.
|
|
9
13
|
*/
|
|
10
|
-
function getAccessibleText(el) {
|
|
14
|
+
function getAccessibleText(el, options) {
|
|
15
|
+
const opts = options || {};
|
|
16
|
+
const visibleOnly = opts.visibleOnly || false;
|
|
17
|
+
|
|
11
18
|
if (!el || !(el instanceof Element)) {
|
|
12
19
|
return '';
|
|
13
20
|
}
|
|
@@ -16,31 +23,32 @@ function getAccessibleText(el) {
|
|
|
16
23
|
return '';
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
if (!visibleOnly) {
|
|
27
|
+
// Check for aria-label first (highest priority)
|
|
28
|
+
if (el.hasAttribute('aria-label')) {
|
|
29
|
+
const ariaLabel = el.getAttribute('aria-label').trim();
|
|
30
|
+
if (ariaLabel) {
|
|
31
|
+
return ariaLabel;
|
|
32
|
+
}
|
|
24
33
|
}
|
|
25
|
-
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
// Check for img alt text when the element itself is an img
|
|
36
|
+
if (el.tagName.toLowerCase() === 'img' && el.hasAttribute('alt')) {
|
|
37
|
+
return el.getAttribute('alt').trim();
|
|
38
|
+
}
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// Check for input[type="image"] alt text when the element itself is one
|
|
41
|
+
if (
|
|
42
|
+
el.tagName.toLowerCase() === 'input' &&
|
|
43
|
+
el.getAttribute('type') === 'image' &&
|
|
44
|
+
el.hasAttribute('alt')
|
|
45
|
+
) {
|
|
46
|
+
return el.getAttribute('alt').trim();
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
// Collect
|
|
42
|
-
|
|
43
|
-
const parts = collectSubtreeText(el);
|
|
50
|
+
// Collect text from the subtree
|
|
51
|
+
const parts = collectSubtreeText(el, visibleOnly);
|
|
44
52
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
45
53
|
}
|
|
46
54
|
|
|
@@ -48,9 +56,10 @@ function getAccessibleText(el) {
|
|
|
48
56
|
* Recursively collect accessible text parts from an element's subtree.
|
|
49
57
|
* Handles text nodes, img alt text, and input[type="image"] alt text.
|
|
50
58
|
* @param {Node} node - The DOM node to traverse.
|
|
59
|
+
* @param {boolean} [visibleOnly=false] - If true, skip non-visible content.
|
|
51
60
|
* @returns {string[]} Array of text parts found in the subtree.
|
|
52
61
|
*/
|
|
53
|
-
function collectSubtreeText(node) {
|
|
62
|
+
function collectSubtreeText(node, visibleOnly) {
|
|
54
63
|
const parts = [];
|
|
55
64
|
|
|
56
65
|
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
@@ -62,30 +71,42 @@ function collectSubtreeText(node) {
|
|
|
62
71
|
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
63
72
|
const tag = child.tagName.toLowerCase();
|
|
64
73
|
|
|
65
|
-
//
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
// In visibleOnly mode, skip non-rendered content
|
|
75
|
+
if (visibleOnly) {
|
|
76
|
+
if (tag === 'style' || tag === 'script') {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (child.getAttribute('aria-hidden') === 'true') {
|
|
80
|
+
continue;
|
|
70
81
|
}
|
|
71
|
-
continue;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
tag === '
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
if (!visibleOnly) {
|
|
85
|
+
// img with non-empty alt contributes its alt text
|
|
86
|
+
if (tag === 'img' && child.hasAttribute('alt')) {
|
|
87
|
+
const alt = child.getAttribute('alt').trim();
|
|
88
|
+
if (alt) {
|
|
89
|
+
parts.push(alt);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// input[type="image"] with non-empty alt contributes its alt text
|
|
95
|
+
if (
|
|
96
|
+
tag === 'input' &&
|
|
97
|
+
child.getAttribute('type') === 'image' &&
|
|
98
|
+
child.hasAttribute('alt')
|
|
99
|
+
) {
|
|
100
|
+
const alt = child.getAttribute('alt').trim();
|
|
101
|
+
if (alt) {
|
|
102
|
+
parts.push(alt);
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
83
105
|
}
|
|
84
|
-
continue;
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
// Recurse into other element children
|
|
88
|
-
parts.push(...collectSubtreeText(child));
|
|
109
|
+
parts.push(...collectSubtreeText(child, visibleOnly));
|
|
89
110
|
}
|
|
90
111
|
}
|
|
91
112
|
|
|
@@ -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
|
|