@afixt/test-utils 1.2.2 → 1.3.0
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/.claude/settings.local.json +1 -6
- package/package.json +1 -1
- package/src/getAccessibleName.js +30 -8
- package/src/isVisible.js +41 -12
- package/src/testContrast.js +54 -0
- package/test/getAccessibleName.test.js +39 -0
- package/test/isVisible.test.js +203 -169
- package/test/testContrast.test.js +104 -2
|
@@ -18,12 +18,7 @@
|
|
|
18
18
|
"Bash(node:*)",
|
|
19
19
|
"Bash(npx vitest run:*)",
|
|
20
20
|
"Bash(1)",
|
|
21
|
-
"Bash(npm run test:playwright:css:*)"
|
|
22
|
-
"Bash(gh run list:*)",
|
|
23
|
-
"Bash(gh pr create:*)",
|
|
24
|
-
"Bash(git checkout:*)",
|
|
25
|
-
"Bash(git pull:*)",
|
|
26
|
-
"Bash(git merge:*)"
|
|
21
|
+
"Bash(npm run test:playwright:css:*)"
|
|
27
22
|
]
|
|
28
23
|
},
|
|
29
24
|
"enableAllProjectMcpServers": false
|
package/package.json
CHANGED
package/src/getAccessibleName.js
CHANGED
|
@@ -456,23 +456,45 @@ function getAccessibleName(element) {
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
// STEP 15: Landmark elements that require explicit accessible names
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// via aria-labelledby, aria-label, or title attribute.
|
|
459
|
+
// Landmark roles do NOT support "Name From: contents" per WAI-ARIA spec.
|
|
460
|
+
// They only support "Name From: author" (aria-labelledby, aria-label, title).
|
|
462
461
|
// Since steps 1 & 2 already checked aria-labelledby and aria-label,
|
|
463
462
|
// we only need to check title here, then return false.
|
|
464
|
-
const
|
|
465
|
-
|
|
463
|
+
const landmarkRoles = [
|
|
464
|
+
'banner',
|
|
465
|
+
'complementary',
|
|
466
|
+
'contentinfo',
|
|
467
|
+
'form',
|
|
468
|
+
'main',
|
|
469
|
+
'navigation',
|
|
470
|
+
'region',
|
|
471
|
+
'search',
|
|
472
|
+
];
|
|
466
473
|
|
|
467
|
-
|
|
468
|
-
|
|
474
|
+
const landmarkElementMap = {
|
|
475
|
+
nav: 'navigation',
|
|
476
|
+
main: 'main',
|
|
477
|
+
aside: 'complementary',
|
|
478
|
+
header: 'banner',
|
|
479
|
+
footer: 'contentinfo',
|
|
480
|
+
section: 'region',
|
|
481
|
+
form: 'form',
|
|
482
|
+
search: 'search',
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const tagName = element.tagName.toLowerCase();
|
|
486
|
+
const role = element.getAttribute('role');
|
|
487
|
+
const isLandmark = landmarkRoles.includes(role) || tagName in landmarkElementMap;
|
|
488
|
+
|
|
489
|
+
if (isLandmark) {
|
|
490
|
+
// Title attribute is valid for landmark elements
|
|
469
491
|
if (element.hasAttribute('title')) {
|
|
470
492
|
const titleValue = element.getAttribute('title');
|
|
471
493
|
if (strlen(titleValue) > 0) {
|
|
472
494
|
return titleValue;
|
|
473
495
|
}
|
|
474
496
|
}
|
|
475
|
-
//
|
|
497
|
+
// Landmark elements do not get accessible name from text content
|
|
476
498
|
return false;
|
|
477
499
|
}
|
|
478
500
|
|
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
|
};
|
package/src/testContrast.js
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if an element or any of its ancestors has a background image set.
|
|
3
|
+
* Walks up the DOM tree until it finds a background image or an opaque background color.
|
|
4
|
+
* @param {Element} el - The element to check
|
|
5
|
+
* @returns {boolean} True if the element or a visible ancestor has a background image
|
|
6
|
+
*/
|
|
7
|
+
function hasBackgroundImage(el) {
|
|
8
|
+
if (!el || el.nodeType === 9 || !window.getComputedStyle) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let current = el;
|
|
13
|
+
while (current && current.nodeType !== 9) {
|
|
14
|
+
const styles = window.getComputedStyle(current);
|
|
15
|
+
if (!styles) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const bgImage = styles.getPropertyValue('background-image');
|
|
20
|
+
if (bgImage && bgImage !== 'none') {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// If this element has an opaque background color, stop traversal
|
|
25
|
+
// since no ancestor background image would be visible through it
|
|
26
|
+
const bgColor = styles.getPropertyValue('background-color');
|
|
27
|
+
const parsed = parseRGB(bgColor);
|
|
28
|
+
if (parsed) {
|
|
29
|
+
const isTransparent =
|
|
30
|
+
bgColor === 'rgba(0, 0, 0, 0)' ||
|
|
31
|
+
bgColor === 'transparent' ||
|
|
32
|
+
(parsed[4] !== undefined && parseFloat(parsed[4]) === 0);
|
|
33
|
+
const isSemiTransparent =
|
|
34
|
+
parsed[4] !== undefined && parseFloat(parsed[4]) > 0 && parseFloat(parsed[4]) < 1;
|
|
35
|
+
|
|
36
|
+
if (!isTransparent && !isSemiTransparent) {
|
|
37
|
+
// Opaque background color, no ancestor background image visible
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
current = current.parentElement;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
1
48
|
/**
|
|
2
49
|
* Get the computed background color for an element.
|
|
3
50
|
* @param {Element} el - the element to be tested
|
|
@@ -240,6 +287,12 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
240
287
|
return false;
|
|
241
288
|
}
|
|
242
289
|
|
|
290
|
+
// Skip elements with a background image on the element itself or a visible ancestor.
|
|
291
|
+
// Contrast cannot be reliably tested against background images.
|
|
292
|
+
if (hasBackgroundImage(el)) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
243
296
|
const styles = window.getComputedStyle(el);
|
|
244
297
|
const selfFG = styles.getPropertyValue('color');
|
|
245
298
|
const selfBG = getComputedBackgroundColor(el);
|
|
@@ -346,6 +399,7 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
346
399
|
|
|
347
400
|
module.exports = {
|
|
348
401
|
testContrast,
|
|
402
|
+
hasBackgroundImage,
|
|
349
403
|
getComputedBackgroundColor,
|
|
350
404
|
luminance,
|
|
351
405
|
parseRGB,
|
|
@@ -428,4 +428,43 @@ describe('getAccessibleName', () => {
|
|
|
428
428
|
const meter = document.querySelector('[role="meter"]');
|
|
429
429
|
expect(getAccessibleName(meter)).toBe('Signal strength');
|
|
430
430
|
});
|
|
431
|
+
|
|
432
|
+
describe('landmark elements should not get name from contents', () => {
|
|
433
|
+
const landmarkElements = [
|
|
434
|
+
{ tag: 'nav', role: 'navigation' },
|
|
435
|
+
{ tag: 'main', role: 'main' },
|
|
436
|
+
{ tag: 'aside', role: 'complementary' },
|
|
437
|
+
{ tag: 'header', role: 'banner' },
|
|
438
|
+
{ tag: 'footer', role: 'contentinfo' },
|
|
439
|
+
{ tag: 'section', role: 'region' },
|
|
440
|
+
{ tag: 'form', role: 'form' },
|
|
441
|
+
{ tag: 'search', role: 'search' },
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
landmarkElements.forEach(({ tag, role }) => {
|
|
445
|
+
it(`should return false for <${tag}> without naming attributes`, () => {
|
|
446
|
+
document.body.innerHTML = `<${tag}><p>Some inner text content</p></${tag}>`;
|
|
447
|
+
const el = document.querySelector(tag);
|
|
448
|
+
expect(getAccessibleName(el)).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it(`should return title for <${tag}> with title attribute`, () => {
|
|
452
|
+
document.body.innerHTML = `<${tag} title="My ${tag} landmark"><p>Some inner text</p></${tag}>`;
|
|
453
|
+
const el = document.querySelector(tag);
|
|
454
|
+
expect(getAccessibleName(el)).toBe(`My ${tag} landmark`);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it(`should return false for div with role="${role}" without naming attributes`, () => {
|
|
458
|
+
document.body.innerHTML = `<div role="${role}"><p>Some inner text content</p></div>`;
|
|
459
|
+
const el = document.querySelector(`[role="${role}"]`);
|
|
460
|
+
expect(getAccessibleName(el)).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it(`should return title for div with role="${role}" and title attribute`, () => {
|
|
464
|
+
document.body.innerHTML = `<div role="${role}" title="My ${role} landmark"><p>Some inner text</p></div>`;
|
|
465
|
+
const el = document.querySelector(`[role="${role}"]`);
|
|
466
|
+
expect(getAccessibleName(el)).toBe(`My ${role} landmark`);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
431
470
|
});
|
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
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
testContrast,
|
|
4
|
+
hasBackgroundImage,
|
|
4
5
|
getComputedBackgroundColor,
|
|
5
6
|
luminance,
|
|
6
7
|
parseRGB,
|
|
@@ -94,17 +95,49 @@ describe('testContrast', () => {
|
|
|
94
95
|
expect(testContrast(div)).toBe(true);
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
it('should
|
|
98
|
+
it('should skip elements with a background image', () => {
|
|
98
99
|
const div = document.createElement('div');
|
|
99
100
|
div.textContent = 'Text with background image';
|
|
100
101
|
div.style.color = 'rgb(0, 0, 0)';
|
|
101
102
|
div.style.backgroundImage = 'url(image.jpg)';
|
|
102
103
|
document.body.appendChild(div);
|
|
103
104
|
|
|
104
|
-
// Should
|
|
105
|
+
// Should skip because contrast can't be reliably tested against background images
|
|
105
106
|
expect(testContrast(div)).toBe(true);
|
|
106
107
|
});
|
|
107
108
|
|
|
109
|
+
it('should skip elements whose ancestor has a background image', () => {
|
|
110
|
+
const parent = document.createElement('div');
|
|
111
|
+
parent.style.backgroundImage = 'url(bg.jpg)';
|
|
112
|
+
|
|
113
|
+
const child = document.createElement('div');
|
|
114
|
+
child.textContent = 'Text over parent background image';
|
|
115
|
+
child.style.color = 'rgb(0, 0, 0)';
|
|
116
|
+
child.style.backgroundColor = 'rgba(0, 0, 0, 0)';
|
|
117
|
+
|
|
118
|
+
parent.appendChild(child);
|
|
119
|
+
document.body.appendChild(parent);
|
|
120
|
+
|
|
121
|
+
expect(testContrast(child)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should not skip elements with opaque background over ancestor background image', () => {
|
|
125
|
+
const parent = document.createElement('div');
|
|
126
|
+
parent.style.backgroundImage = 'url(bg.jpg)';
|
|
127
|
+
|
|
128
|
+
const child = document.createElement('div');
|
|
129
|
+
child.textContent = 'Text with opaque background';
|
|
130
|
+
child.style.color = 'rgb(0, 0, 0)';
|
|
131
|
+
child.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
132
|
+
|
|
133
|
+
parent.appendChild(child);
|
|
134
|
+
document.body.appendChild(parent);
|
|
135
|
+
|
|
136
|
+
// The child has an opaque background, so the parent's background image
|
|
137
|
+
// is not visible through it. Contrast should be tested normally.
|
|
138
|
+
expect(testContrast(child)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
108
141
|
it('should handle invisible elements', () => {
|
|
109
142
|
const div = document.createElement('div');
|
|
110
143
|
div.textContent = 'Hidden text';
|
|
@@ -499,6 +532,75 @@ describe('getComputedBackgroundColor', () => {
|
|
|
499
532
|
});
|
|
500
533
|
});
|
|
501
534
|
|
|
535
|
+
describe('hasBackgroundImage', () => {
|
|
536
|
+
beforeEach(() => {
|
|
537
|
+
document.body.innerHTML = '';
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('should return false for null element', () => {
|
|
541
|
+
expect(hasBackgroundImage(null)).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should return false for document node', () => {
|
|
545
|
+
expect(hasBackgroundImage(document)).toBe(false);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('should return true for element with background-image', () => {
|
|
549
|
+
const div = document.createElement('div');
|
|
550
|
+
div.style.backgroundImage = 'url(test.jpg)';
|
|
551
|
+
document.body.appendChild(div);
|
|
552
|
+
|
|
553
|
+
expect(hasBackgroundImage(div)).toBe(true);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('should return false for element without background-image', () => {
|
|
557
|
+
const div = document.createElement('div');
|
|
558
|
+
div.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
559
|
+
document.body.appendChild(div);
|
|
560
|
+
|
|
561
|
+
expect(hasBackgroundImage(div)).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should return true when ancestor has a background image and element is transparent', () => {
|
|
565
|
+
const parent = document.createElement('div');
|
|
566
|
+
parent.style.backgroundImage = 'url(bg.jpg)';
|
|
567
|
+
|
|
568
|
+
const child = document.createElement('div');
|
|
569
|
+
child.style.backgroundColor = 'rgba(0, 0, 0, 0)';
|
|
570
|
+
|
|
571
|
+
parent.appendChild(child);
|
|
572
|
+
document.body.appendChild(parent);
|
|
573
|
+
|
|
574
|
+
expect(hasBackgroundImage(child)).toBe(true);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('should return false when ancestor has background image but element has opaque background', () => {
|
|
578
|
+
const parent = document.createElement('div');
|
|
579
|
+
parent.style.backgroundImage = 'url(bg.jpg)';
|
|
580
|
+
|
|
581
|
+
const child = document.createElement('div');
|
|
582
|
+
child.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
583
|
+
|
|
584
|
+
parent.appendChild(child);
|
|
585
|
+
document.body.appendChild(parent);
|
|
586
|
+
|
|
587
|
+
expect(hasBackgroundImage(child)).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should return false if getComputedStyle is not available', () => {
|
|
591
|
+
const div = document.createElement('div');
|
|
592
|
+
div.style.backgroundImage = 'url(test.jpg)';
|
|
593
|
+
document.body.appendChild(div);
|
|
594
|
+
|
|
595
|
+
const originalGetComputedStyle = window.getComputedStyle;
|
|
596
|
+
window.getComputedStyle = null;
|
|
597
|
+
|
|
598
|
+
expect(hasBackgroundImage(div)).toBe(false);
|
|
599
|
+
|
|
600
|
+
window.getComputedStyle = originalGetComputedStyle;
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
502
604
|
describe('luminance', () => {
|
|
503
605
|
it('should calculate luminance for pure black (0, 0, 0)', () => {
|
|
504
606
|
const result = luminance(0, 0, 0);
|