@afixt/test-utils 1.2.1 → 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/package.json +1 -1
- package/src/getAccessibleText.js +60 -39
- package/test/getAccessibleText.test.js +93 -0
package/package.json
CHANGED
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
|
|
|
@@ -300,6 +300,99 @@ describe('getAccessibleText', () => {
|
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
describe('visibleOnly option', () => {
|
|
304
|
+
it('should skip aria-label when visibleOnly is true', () => {
|
|
305
|
+
const div = document.createElement('div');
|
|
306
|
+
div.setAttribute('aria-label', 'Accessible Label');
|
|
307
|
+
div.textContent = 'Visual Text';
|
|
308
|
+
document.body.appendChild(div);
|
|
309
|
+
|
|
310
|
+
expect(getAccessibleText(div, { visibleOnly: true })).toBe('Visual Text');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should skip img alt text on the element itself when visibleOnly is true', () => {
|
|
314
|
+
const img = document.createElement('img');
|
|
315
|
+
img.setAttribute('alt', 'Image description');
|
|
316
|
+
img.setAttribute('src', 'test.jpg');
|
|
317
|
+
document.body.appendChild(img);
|
|
318
|
+
|
|
319
|
+
expect(getAccessibleText(img, { visibleOnly: true })).toBe('');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should skip input[type="image"] alt when visibleOnly is true', () => {
|
|
323
|
+
const input = document.createElement('input');
|
|
324
|
+
input.setAttribute('type', 'image');
|
|
325
|
+
input.setAttribute('alt', 'Search');
|
|
326
|
+
document.body.appendChild(input);
|
|
327
|
+
|
|
328
|
+
expect(getAccessibleText(input, { visibleOnly: true })).toBe('');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should skip child img alt text when visibleOnly is true', () => {
|
|
332
|
+
const container = document.createElement('div');
|
|
333
|
+
container.innerHTML = 'Click <img src="icon.png" alt="here"> to continue';
|
|
334
|
+
document.body.appendChild(container);
|
|
335
|
+
|
|
336
|
+
const result = getAccessibleText(container, { visibleOnly: true });
|
|
337
|
+
expect(result).toContain('Click');
|
|
338
|
+
expect(result).not.toContain('here');
|
|
339
|
+
expect(result).toContain('to continue');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should skip style element content when visibleOnly is true', () => {
|
|
343
|
+
const container = document.createElement('div');
|
|
344
|
+
container.innerHTML = '<style>.cls-1{fill:#09141d;}</style>Visible text';
|
|
345
|
+
document.body.appendChild(container);
|
|
346
|
+
|
|
347
|
+
expect(getAccessibleText(container, { visibleOnly: true })).toBe('Visible text');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should skip script element content when visibleOnly is true', () => {
|
|
351
|
+
const container = document.createElement('div');
|
|
352
|
+
container.innerHTML = '<script>var x = 1;</script>Visible text';
|
|
353
|
+
document.body.appendChild(container);
|
|
354
|
+
|
|
355
|
+
expect(getAccessibleText(container, { visibleOnly: true })).toBe('Visible text');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should skip aria-hidden="true" elements when visibleOnly is true', () => {
|
|
359
|
+
const container = document.createElement('div');
|
|
360
|
+
container.innerHTML = 'Visible <span aria-hidden="true">Hidden</span> text';
|
|
361
|
+
document.body.appendChild(container);
|
|
362
|
+
|
|
363
|
+
expect(getAccessibleText(container, { visibleOnly: true })).toBe('Visible text');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should return empty for SVG-only element with style when visibleOnly is true', () => {
|
|
367
|
+
const link = document.createElement('a');
|
|
368
|
+
link.setAttribute('aria-label', 'Code Enforcement');
|
|
369
|
+
link.innerHTML =
|
|
370
|
+
'<svg><defs><style>.cls-1{fill:#09141d;}</style></defs><path d="M25,49"></path></svg>';
|
|
371
|
+
document.body.appendChild(link);
|
|
372
|
+
|
|
373
|
+
expect(getAccessibleText(link, { visibleOnly: true })).toBe('');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should still include style/script content when visibleOnly is false (default)', () => {
|
|
377
|
+
const container = document.createElement('div');
|
|
378
|
+
container.innerHTML = '<style>.cls{color:red;}</style>Visible';
|
|
379
|
+
document.body.appendChild(container);
|
|
380
|
+
|
|
381
|
+
const result = getAccessibleText(container);
|
|
382
|
+
expect(result).toContain('.cls{color:red;}');
|
|
383
|
+
expect(result).toContain('Visible');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should behave normally when options are not provided', () => {
|
|
387
|
+
const div = document.createElement('div');
|
|
388
|
+
div.setAttribute('aria-label', 'Label');
|
|
389
|
+
div.textContent = 'Text';
|
|
390
|
+
document.body.appendChild(div);
|
|
391
|
+
|
|
392
|
+
expect(getAccessibleText(div)).toBe('Label');
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
303
396
|
describe('special cases', () => {
|
|
304
397
|
it('should handle elements with only child elements and no text', () => {
|
|
305
398
|
const container = document.createElement('div');
|