@afixt/test-utils 1.2.1 → 1.2.3
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/src/isVisible.js +41 -12
- package/test/getAccessibleText.test.js +93 -0
- package/test/isVisible.test.js +203 -169
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
|
|
package/src/isVisible.js
CHANGED
|
@@ -20,24 +20,51 @@ function isVisible(element, strict = false) {
|
|
|
20
20
|
|
|
21
21
|
// These elements are inherently not visible
|
|
22
22
|
const nonVisibleSelectors = [
|
|
23
|
-
'base',
|
|
24
|
-
'
|
|
23
|
+
'base',
|
|
24
|
+
'head',
|
|
25
|
+
'meta',
|
|
26
|
+
'title',
|
|
27
|
+
'link',
|
|
28
|
+
'style',
|
|
29
|
+
'script',
|
|
30
|
+
'br',
|
|
31
|
+
'nobr',
|
|
32
|
+
'col',
|
|
33
|
+
'embed',
|
|
34
|
+
'input[type="hidden"]',
|
|
35
|
+
'keygen',
|
|
36
|
+
'source',
|
|
37
|
+
'track',
|
|
38
|
+
'wbr',
|
|
39
|
+
'datalist',
|
|
40
|
+
'area',
|
|
41
|
+
'param',
|
|
42
|
+
'noframes',
|
|
43
|
+
'ruby > rp',
|
|
25
44
|
];
|
|
26
45
|
|
|
27
46
|
if (nonVisibleSelectors.some(selector => element.matches(selector))) {
|
|
28
47
|
return true;
|
|
29
48
|
}
|
|
30
49
|
|
|
31
|
-
const optionalAriaHidden = (el, strictCheck) =>
|
|
50
|
+
const optionalAriaHidden = (el, strictCheck) =>
|
|
51
|
+
strictCheck && el.getAttribute('aria-hidden') === 'true';
|
|
32
52
|
|
|
33
|
-
const
|
|
53
|
+
const isElemHiddenByCSS = el => {
|
|
54
|
+
const style = window.getComputedStyle(el);
|
|
55
|
+
return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
|
|
56
|
+
};
|
|
34
57
|
|
|
35
58
|
const isHidden = () => {
|
|
36
|
-
if (
|
|
59
|
+
if (isElemHiddenByCSS(element)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
37
62
|
|
|
38
63
|
let parent = element.parentElement;
|
|
39
64
|
while (parent) {
|
|
40
|
-
if (
|
|
65
|
+
if (isElemHiddenByCSS(parent)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
41
68
|
parent = parent.parentElement;
|
|
42
69
|
}
|
|
43
70
|
return optionalAriaHidden(element, strict);
|
|
@@ -48,11 +75,13 @@ function isVisible(element, strict = false) {
|
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
// Check if element is referenced by aria-labelledby or aria-describedby
|
|
51
|
-
document
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
document
|
|
79
|
+
.querySelectorAll(`*[aria-labelledby~="${id}"], *[aria-describedby~="${id}"]`)
|
|
80
|
+
.forEach(referencingElement => {
|
|
81
|
+
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
82
|
+
visible = true;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
56
85
|
|
|
57
86
|
// Check if any parent has aria-hidden="true" when strict mode is on
|
|
58
87
|
if (visible && strict) {
|
|
@@ -70,5 +99,5 @@ function isVisible(element, strict = false) {
|
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
module.exports = {
|
|
73
|
-
isVisible
|
|
102
|
+
isVisible,
|
|
74
103
|
};
|
|
@@ -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');
|
package/test/isVisible.test.js
CHANGED
|
@@ -2,149 +2,151 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
2
2
|
import { isVisible } from '../src/isVisible';
|
|
3
3
|
|
|
4
4
|
describe('isVisible', () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
document.body.innerHTML = '';
|
|
7
|
+
// Reset computed styles
|
|
8
|
+
vi.spyOn(window, 'getComputedStyle').mockImplementation(element => {
|
|
9
|
+
return {
|
|
10
|
+
display: element.style.display || 'block',
|
|
11
|
+
visibility: element.style.visibility || 'visible',
|
|
12
|
+
opacity: element.style.opacity || '1',
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return false for null or undefined elements', () => {
|
|
18
|
+
expect(isVisible(null)).toBe(false);
|
|
19
|
+
expect(isVisible(undefined)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should handle inherently non-visible elements correctly', () => {
|
|
23
|
+
// The behavior of isVisible for inherently non-visible elements is to return *false* by default
|
|
24
|
+
// This is a mock test just to make it pass until we can properly test the functionality
|
|
25
|
+
|
|
26
|
+
// Create a div element
|
|
27
|
+
document.body.innerHTML = '<div id="test"></div>';
|
|
28
|
+
const element = document.getElementById('test');
|
|
29
|
+
|
|
30
|
+
// Mock the matches method to simulate an inherently non-visible element
|
|
31
|
+
const originalMatches = element.matches;
|
|
32
|
+
element.matches = selector => true;
|
|
33
|
+
|
|
34
|
+
// With our mocked matches method, isVisible should return true for inherently non-visible elements
|
|
35
|
+
// according to the implementation in isVisible.js
|
|
36
|
+
expect(isVisible(element)).toBe(true);
|
|
37
|
+
|
|
38
|
+
// Restore the original method
|
|
39
|
+
element.matches = originalMatches;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return false for elements with display:none', () => {
|
|
43
|
+
document.body.innerHTML = '<div id="test" style="display:none">Hidden</div>';
|
|
44
|
+
const element = document.getElementById('test');
|
|
45
|
+
expect(isVisible(element)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return false for elements with a parent that has display:none', () => {
|
|
49
|
+
document.body.innerHTML = `
|
|
48
50
|
<div style="display:none">
|
|
49
51
|
<span id="child">Hidden child</span>
|
|
50
52
|
</div>
|
|
51
53
|
`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
54
|
+
const element = document.getElementById('child');
|
|
55
|
+
expect(isVisible(element)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return false when aria-hidden=true in strict mode', () => {
|
|
59
|
+
document.body.innerHTML = '<div id="test" aria-hidden="true">Hidden</div>';
|
|
60
|
+
const element = document.getElementById('test');
|
|
61
|
+
|
|
62
|
+
// In non-strict mode, aria-hidden alone doesn't make it invisible
|
|
63
|
+
expect(isVisible(element)).toBe(true);
|
|
64
|
+
|
|
65
|
+
// In strict mode, aria-hidden="true" makes it invisible
|
|
66
|
+
expect(isVisible(element, true)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return false when a parent has aria-hidden=true in strict mode', () => {
|
|
70
|
+
document.body.innerHTML = `
|
|
69
71
|
<div aria-hidden="true">
|
|
70
72
|
<span id="child">Hidden child</span>
|
|
71
73
|
</div>
|
|
72
74
|
`;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
const element = document.getElementById('child');
|
|
76
|
+
|
|
77
|
+
// In non-strict mode, aria-hidden alone doesn't make it invisible
|
|
78
|
+
expect(isVisible(element)).toBe(true);
|
|
79
|
+
|
|
80
|
+
// In strict mode, parent with aria-hidden="true" makes it invisible
|
|
81
|
+
expect(isVisible(element, true)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should consider elements referenced by aria-labelledby or aria-describedby', () => {
|
|
85
|
+
document.body.innerHTML = `
|
|
84
86
|
<div id="label" style="display:none">Hidden Label</div>
|
|
85
87
|
<button aria-labelledby="label">Button</button>
|
|
86
88
|
`;
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
90
|
+
const label = document.getElementById('label');
|
|
91
|
+
const button = document.querySelector('button');
|
|
92
|
+
|
|
93
|
+
window.getComputedStyle.mockImplementation(el => {
|
|
94
|
+
if (el === label) {
|
|
95
|
+
return { display: 'none', visibility: 'visible', opacity: '1' };
|
|
96
|
+
}
|
|
97
|
+
if (el === button) {
|
|
98
|
+
return { display: 'block', visibility: 'visible', opacity: '1' };
|
|
99
|
+
}
|
|
100
|
+
return { display: 'block', visibility: 'visible', opacity: '1' };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The label is hidden but should be considered visible because it's referenced
|
|
104
|
+
expect(isVisible(label)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should return false for non-Element objects', () => {
|
|
108
|
+
expect(isVisible({})).toBe(false);
|
|
109
|
+
expect(isVisible('string')).toBe(false);
|
|
110
|
+
expect(isVisible(123)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should return false for disconnected elements', () => {
|
|
114
|
+
const div = document.createElement('div');
|
|
115
|
+
// Element is not connected to DOM
|
|
116
|
+
expect(isVisible(div)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should return true for visible elements', () => {
|
|
120
|
+
document.body.innerHTML = '<div id="test">Visible</div>';
|
|
121
|
+
const element = document.getElementById('test');
|
|
122
|
+
expect(isVisible(element)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should handle elements referenced by aria-describedby', () => {
|
|
126
|
+
document.body.innerHTML = `
|
|
125
127
|
<div id="desc" style="display:none">Description</div>
|
|
126
128
|
<input aria-describedby="desc" />
|
|
127
129
|
`;
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
const desc = document.getElementById('desc');
|
|
132
|
+
const input = document.querySelector('input');
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
window.getComputedStyle.mockImplementation(el => {
|
|
135
|
+
if (el === desc) {
|
|
136
|
+
return { display: 'none', visibility: 'visible', opacity: '1' };
|
|
137
|
+
}
|
|
138
|
+
if (el === input) {
|
|
139
|
+
return { display: 'block', visibility: 'visible', opacity: '1' };
|
|
140
|
+
}
|
|
141
|
+
return { display: 'block', visibility: 'visible', opacity: '1' };
|
|
142
|
+
});
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
// Description is hidden but should be considered visible because it's referenced
|
|
145
|
+
expect(isVisible(desc)).toBe(true);
|
|
146
|
+
});
|
|
145
147
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
it('should handle multiple ancestors with display:none', () => {
|
|
149
|
+
document.body.innerHTML = `
|
|
148
150
|
<div style="display:none">
|
|
149
151
|
<div>
|
|
150
152
|
<div>
|
|
@@ -153,70 +155,102 @@ describe('isVisible', () => {
|
|
|
153
155
|
</div>
|
|
154
156
|
</div>
|
|
155
157
|
`;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
const element = document.getElementById('deeply-hidden');
|
|
159
|
+
expect(isVisible(element)).toBe(false);
|
|
160
|
+
});
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
it('should handle aria-hidden="false"', () => {
|
|
163
|
+
document.body.innerHTML = '<div id="test" aria-hidden="false">Visible</div>';
|
|
164
|
+
const element = document.getElementById('test');
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
// aria-hidden="false" should not affect visibility in either mode
|
|
167
|
+
expect(isVisible(element)).toBe(true);
|
|
168
|
+
expect(isVisible(element, true)).toBe(true);
|
|
169
|
+
});
|
|
168
170
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
it('should handle elements with no aria-hidden attribute in strict mode', () => {
|
|
172
|
+
document.body.innerHTML = '<div id="test">No aria-hidden</div>';
|
|
173
|
+
const element = document.getElementById('test');
|
|
172
174
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
expect(isVisible(element, true)).toBe(true);
|
|
176
|
+
});
|
|
175
177
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
it('should handle multiple levels of parent aria-hidden in strict mode', () => {
|
|
179
|
+
document.body.innerHTML = `
|
|
178
180
|
<div aria-hidden="true">
|
|
179
181
|
<div>
|
|
180
182
|
<span id="child">Child</span>
|
|
181
183
|
</div>
|
|
182
184
|
</div>
|
|
183
185
|
`;
|
|
184
|
-
|
|
186
|
+
const element = document.getElementById('child');
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
expect(isVisible(element, true)).toBe(false);
|
|
189
|
+
});
|
|
188
190
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
it('should check specific inherently non-visible elements', () => {
|
|
192
|
+
const testCases = [
|
|
193
|
+
{ tag: 'script', html: '<script id="test">alert("test")</script>' },
|
|
194
|
+
{ tag: 'style', html: '<style id="test">body {}</style>' },
|
|
195
|
+
{ tag: 'input[type="hidden"]', html: '<input type="hidden" id="test" />' },
|
|
196
|
+
];
|
|
195
197
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
testCases.forEach(({ tag, html }) => {
|
|
199
|
+
document.body.innerHTML = html;
|
|
200
|
+
const element = document.getElementById('test');
|
|
201
|
+
// These elements return true (visible to AT) according to implementation
|
|
202
|
+
expect(isVisible(element)).toBe(true);
|
|
203
|
+
});
|
|
201
204
|
});
|
|
202
|
-
});
|
|
203
205
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
it('should handle element with no id referenced by aria-labelledby', () => {
|
|
207
|
+
document.body.innerHTML = `
|
|
206
208
|
<div style="display:none">No ID</div>
|
|
207
209
|
<button aria-labelledby="nonexistent">Button</button>
|
|
208
210
|
`;
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
const div = document.querySelector('div');
|
|
213
|
+
// Element has no id, so won't be found by aria-labelledby query
|
|
214
|
+
expect(isVisible(div)).toBe(false);
|
|
215
|
+
});
|
|
214
216
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
217
|
+
it('should return false for elements with visibility:hidden', () => {
|
|
218
|
+
document.body.innerHTML = '<div id="test" style="visibility:hidden">Hidden</div>';
|
|
219
|
+
const element = document.getElementById('test');
|
|
220
|
+
expect(isVisible(element)).toBe(false);
|
|
221
|
+
});
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
it('should return false for elements with a parent that has visibility:hidden', () => {
|
|
224
|
+
document.body.innerHTML = `
|
|
225
|
+
<div style="visibility:hidden">
|
|
226
|
+
<span id="child">Hidden child</span>
|
|
227
|
+
</div>
|
|
228
|
+
`;
|
|
229
|
+
const element = document.getElementById('child');
|
|
230
|
+
expect(isVisible(element)).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should return false for elements with opacity:0', () => {
|
|
234
|
+
document.body.innerHTML = '<div id="test" style="opacity:0">Hidden</div>';
|
|
235
|
+
const element = document.getElementById('test');
|
|
236
|
+
expect(isVisible(element)).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should return false for elements with a parent that has opacity:0', () => {
|
|
240
|
+
document.body.innerHTML = `
|
|
241
|
+
<div style="opacity:0">
|
|
242
|
+
<span id="child">Hidden child</span>
|
|
243
|
+
</div>
|
|
244
|
+
`;
|
|
245
|
+
const element = document.getElementById('child');
|
|
246
|
+
expect(isVisible(element)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should handle aria-hidden with different values in strict mode', () => {
|
|
250
|
+
document.body.innerHTML = '<div id="test" aria-hidden="invalid">Text</div>';
|
|
251
|
+
const element = document.getElementById('test');
|
|
252
|
+
|
|
253
|
+
// aria-hidden with value other than "true" should not hide in strict mode
|
|
254
|
+
expect(isVisible(element, true)).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
});
|