@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.
@@ -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;
@@ -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
- // Check for aria-label first (highest priority)
20
- if (el.hasAttribute('aria-label')) {
21
- const ariaLabel = el.getAttribute('aria-label').trim();
22
- if (ariaLabel) {
23
- return ariaLabel;
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
- // Check for img alt text when the element itself is an img
28
- if (el.tagName.toLowerCase() === 'img' && el.hasAttribute('alt')) {
29
- return el.getAttribute('alt').trim();
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
- // Check for input[type="image"] alt text when the element itself is one
33
- if (
34
- el.tagName.toLowerCase() === 'input' &&
35
- el.getAttribute('type') === 'image' &&
36
- el.hasAttribute('alt')
37
- ) {
38
- return el.getAttribute('alt').trim();
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 accessible text from the subtree, including text nodes
42
- // and alt text from embedded images
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
- // img with non-empty alt contributes its alt text
66
- if (tag === 'img' && child.hasAttribute('alt')) {
67
- const alt = child.getAttribute('alt').trim();
68
- if (alt) {
69
- parts.push(alt);
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
- // input[type="image"] with non-empty alt contributes its alt text
75
- if (
76
- tag === 'input' &&
77
- child.getAttribute('type') === 'image' &&
78
- child.hasAttribute('alt')
79
- ) {
80
- const alt = child.getAttribute('alt').trim();
81
- if (alt) {
82
- parts.push(alt);
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) return false;
33
+ if (!el) {
34
+ return false;
35
+ }
12
36
 
13
37
  let content = '';
14
38
 
15
- if (pseudoElement === 'before' || pseudoElement === 'both') {
16
- const style = window.getComputedStyle(el, '::before');
17
- const before = style.getPropertyValue('content');
18
- if (before && before !== 'none' && before !== 'normal' && before !== '""' && before !== "''") {
19
- // Remove surrounding quotes if present
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
- if (pseudoElement === 'after' || pseudoElement === 'both') {
28
- const style = window.getComputedStyle(el, '::after');
29
- const after = style.getPropertyValue('content');
30
- if (after && after !== 'none' && after !== 'normal' && after !== '""' && after !== "''") {
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
+ };
@@ -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