@afixt/test-utils 2.1.0 → 2.2.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 +2 -1
- package/package.json +1 -1
- package/src/constants.js +1 -0
- package/src/domUtils.js +37 -4
- package/src/formUtils.js +3 -1
- package/src/getAccessibleName.js +26 -50
- package/src/getAccessibleText.js +3 -3
- package/src/getComputedRole.js +187 -122
- package/src/getImageText.js +6 -2
- package/src/isA11yVisible.js +5 -1
- package/src/isHidden.js +11 -4
- package/src/stringUtils.js +5 -2
- package/src/testContrast.js +42 -1
- package/test/domUtils.test.js +20 -0
- package/test/formUtils.test.js +18 -0
- package/test/getAccessibleName.test.js +44 -0
- package/test/getComputedRole.test.js +248 -176
- package/test/getImageText.test.js +18 -6
- package/test/isA11yVisible.test.js +10 -0
- package/test/isHidden.test.js +18 -0
- package/test/playwright/colon-id-a11y-visible.spec.js +160 -0
- package/test/playwright/fixtures/colon-id-a11y-visible.html +48 -0
- package/test/testContrast.test.js +42 -5
package/src/isA11yVisible.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { NON_VISIBLE_SELECTORS } = require('./constants.js');
|
|
2
2
|
const isHidden = require('./isHidden.js');
|
|
3
|
+
const { cssEscape } = require('./domUtils.js');
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Checks if an element is visible to assistive technologies (AT).
|
|
@@ -67,8 +68,11 @@ function isA11yVisible(element, strict = false) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// Check if element is referenced by aria-labelledby or aria-describedby
|
|
71
|
+
// Use CSS.escape(id) to handle IDs containing special characters like colons
|
|
72
|
+
// (e.g. Radix UI generates IDs like "radix-:rj:-tab-account")
|
|
73
|
+
const escapedId = id ? cssEscape(id) : '';
|
|
70
74
|
document
|
|
71
|
-
.querySelectorAll(`*[aria-labelledby~="${
|
|
75
|
+
.querySelectorAll(`*[aria-labelledby~="${escapedId}"], *[aria-describedby~="${escapedId}"]`)
|
|
72
76
|
.forEach(referencingElement => {
|
|
73
77
|
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
74
78
|
visible = true;
|
package/src/isHidden.js
CHANGED
|
@@ -18,15 +18,22 @@ const isHidden = (element, options = {}) => {
|
|
|
18
18
|
|
|
19
19
|
const { checkAriaHidden = false, checkOpacity = false, checkDimensions = false } = options;
|
|
20
20
|
|
|
21
|
-
// Check the hidden attribute
|
|
22
|
-
|
|
21
|
+
// Check the hidden attribute — but not hidden="until-found", which is
|
|
22
|
+
// visually hidden but still findable by Find-in-Page and accessible to AT.
|
|
23
|
+
// In real browsers, hidden="until-found" uses content-visibility:hidden
|
|
24
|
+
// rather than display:none.
|
|
25
|
+
const hiddenAttr = element.getAttribute('hidden');
|
|
26
|
+
const isUntilFound = hiddenAttr === 'until-found';
|
|
27
|
+
if (hiddenAttr !== null && !isUntilFound) {
|
|
23
28
|
return true;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
// Use computed style to catch CSS class/stylesheet rules
|
|
31
|
+
// Use computed style to catch CSS class/stylesheet rules.
|
|
32
|
+
// Skip display:none check for hidden="until-found" because JSDOM
|
|
33
|
+
// incorrectly applies display:none (real browsers don't).
|
|
27
34
|
const style = window.getComputedStyle(element);
|
|
28
35
|
|
|
29
|
-
if (style.display === 'none') {
|
|
36
|
+
if (style.display === 'none' && !isUntilFound) {
|
|
30
37
|
return true;
|
|
31
38
|
}
|
|
32
39
|
|
package/src/stringUtils.js
CHANGED
|
@@ -2,9 +2,12 @@ const { GENERIC_TITLES, GENERIC_LINK_TEXT } = require('./constants.js');
|
|
|
2
2
|
|
|
3
3
|
const stringUtils = (function () {
|
|
4
4
|
/**
|
|
5
|
-
* Check if a string is empty or only contains whitespace.
|
|
5
|
+
* Check if a string is empty or only contains standard whitespace.
|
|
6
|
+
* NOTE: For accessibility name/label checks, prefer {@link isEmptyOrWhitespace}
|
|
7
|
+
* which also catches non-breaking spaces (\u00A0), zero-width spaces (\u200B),
|
|
8
|
+
* and other invisible Unicode characters that do not constitute meaningful text.
|
|
6
9
|
* @param {string} str - The string to check.
|
|
7
|
-
* @returns {boolean} Whether the string is empty.
|
|
10
|
+
* @returns {boolean} Whether the string is empty or whitespace-only.
|
|
8
11
|
*/
|
|
9
12
|
function isEmpty(str) {
|
|
10
13
|
return !str || str.trim().length === 0;
|
package/src/testContrast.js
CHANGED
|
@@ -45,6 +45,40 @@ function hasBackgroundImage(el) {
|
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Check if an element or any of its ancestors has a CSS filter or mix-blend-mode
|
|
50
|
+
* that would alter effective colors in ways that cannot be computed from CSS values alone.
|
|
51
|
+
* @param {Element} el - The element to check
|
|
52
|
+
* @returns {boolean} True if the element or an ancestor has a color-altering CSS property
|
|
53
|
+
*/
|
|
54
|
+
function hasEffectColorModifier(el) {
|
|
55
|
+
if (!el || el.nodeType === 9 || !window.getComputedStyle) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let current = el;
|
|
60
|
+
while (current && current.nodeType !== 9) {
|
|
61
|
+
const styles = window.getComputedStyle(current);
|
|
62
|
+
if (!styles) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filter = styles.getPropertyValue('filter');
|
|
67
|
+
if (filter && filter !== 'none') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mixBlendMode = styles.getPropertyValue('mix-blend-mode');
|
|
72
|
+
if (mixBlendMode && mixBlendMode !== 'normal') {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
current = current.parentElement;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
/**
|
|
49
83
|
* Get the computed background color for an element.
|
|
50
84
|
* @param {Element} el - the element to be tested
|
|
@@ -290,7 +324,13 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
290
324
|
// Skip elements with a background image on the element itself or a visible ancestor.
|
|
291
325
|
// Contrast cannot be reliably tested against background images.
|
|
292
326
|
if (hasBackgroundImage(el)) {
|
|
293
|
-
return
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Skip elements affected by CSS filters or mix-blend-mode (self or ancestor).
|
|
331
|
+
// These alter effective colors in ways that cannot be computed from CSS values alone.
|
|
332
|
+
if (hasEffectColorModifier(el)) {
|
|
333
|
+
return null;
|
|
294
334
|
}
|
|
295
335
|
|
|
296
336
|
const styles = window.getComputedStyle(el);
|
|
@@ -400,6 +440,7 @@ function testContrast(el, options = { level: 'AA' }) {
|
|
|
400
440
|
module.exports = {
|
|
401
441
|
testContrast,
|
|
402
442
|
hasBackgroundImage,
|
|
443
|
+
hasEffectColorModifier,
|
|
403
444
|
getComputedBackgroundColor,
|
|
404
445
|
luminance,
|
|
405
446
|
parseRGB,
|
package/test/domUtils.test.js
CHANGED
|
@@ -552,6 +552,26 @@ describe('domUtils', () => {
|
|
|
552
552
|
const child = document.getElementById('child');
|
|
553
553
|
expect(domUtils.isHiddenFromAT(child)).toBe(false);
|
|
554
554
|
});
|
|
555
|
+
|
|
556
|
+
it('should return true when element has inert attribute', () => {
|
|
557
|
+
document.body.innerHTML = '<div inert><span id="child">Inert child</span></div>';
|
|
558
|
+
const child = document.getElementById('child');
|
|
559
|
+
expect(domUtils.isHiddenFromAT(child)).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('should return true when element itself is inert', () => {
|
|
563
|
+
const el = document.createElement('div');
|
|
564
|
+
el.setAttribute('inert', '');
|
|
565
|
+
document.body.appendChild(el);
|
|
566
|
+
expect(domUtils.isHiddenFromAT(el)).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should return true when element has content-visibility hidden', () => {
|
|
570
|
+
document.body.innerHTML = '<div id="target">Content</div>';
|
|
571
|
+
const el = document.getElementById('target');
|
|
572
|
+
el.style.contentVisibility = 'hidden';
|
|
573
|
+
expect(domUtils.isHiddenFromAT(el)).toBe(true);
|
|
574
|
+
});
|
|
555
575
|
});
|
|
556
576
|
|
|
557
577
|
describe('isEffectivelyInteractive', () => {
|
package/test/formUtils.test.js
CHANGED
|
@@ -120,6 +120,24 @@ describe('formUtils', () => {
|
|
|
120
120
|
expect(formUtils.hasExplicitAccessibleName(el)).toBe(false);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it('should return true when aria-labelledby points to sr-only element', () => {
|
|
124
|
+
document.body.innerHTML = `
|
|
125
|
+
<span id="lbl" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)">SR-only Label</span>
|
|
126
|
+
<div id="target" aria-labelledby="lbl"></div>
|
|
127
|
+
`;
|
|
128
|
+
const target = document.getElementById('target');
|
|
129
|
+
expect(formUtils.hasExplicitAccessibleName(target)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should return false when aria-labelledby points to aria-hidden="true" element', () => {
|
|
133
|
+
document.body.innerHTML = `
|
|
134
|
+
<span id="lbl" aria-hidden="true">Hidden Label</span>
|
|
135
|
+
<div id="target" aria-labelledby="lbl"></div>
|
|
136
|
+
`;
|
|
137
|
+
const target = document.getElementById('target');
|
|
138
|
+
expect(formUtils.hasExplicitAccessibleName(target)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
123
141
|
it('should handle multiple ids in aria-labelledby', () => {
|
|
124
142
|
document.body.innerHTML = `
|
|
125
143
|
<span id="lbl1"></span>
|
|
@@ -467,4 +467,48 @@ describe('getAccessibleName', () => {
|
|
|
467
467
|
});
|
|
468
468
|
});
|
|
469
469
|
});
|
|
470
|
+
|
|
471
|
+
describe('whitespace-only accessible name handling', () => {
|
|
472
|
+
it('should ignore aria-label with only non-breaking spaces and use text content instead', () => {
|
|
473
|
+
document.body.innerHTML = '<button aria-label="\u00A0\u00A0">Click</button>';
|
|
474
|
+
const button = document.querySelector('button');
|
|
475
|
+
// NBSP-only aria-label is treated as empty; falls through to text content
|
|
476
|
+
expect(getAccessibleName(button)).toBe('Click');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should ignore aria-label with only zero-width spaces and use text content instead', () => {
|
|
480
|
+
document.body.innerHTML = '<button aria-label="\u200B\u200B">Click</button>';
|
|
481
|
+
const button = document.querySelector('button');
|
|
482
|
+
expect(getAccessibleName(button)).toBe('Click');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should return false when aria-label is NBSP-only and element has no other name source', () => {
|
|
486
|
+
document.body.innerHTML = '<div role="button" aria-label="\u00A0"></div>';
|
|
487
|
+
const div = document.querySelector('[role="button"]');
|
|
488
|
+
expect(getAccessibleName(div)).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe('visibility handling for aria-labelledby referenced elements', () => {
|
|
493
|
+
it('should return accessible name for display:none element referenced by aria-labelledby', () => {
|
|
494
|
+
document.body.innerHTML = `
|
|
495
|
+
<li style="display:none">
|
|
496
|
+
<button id="expand-btn">Expand subnavigation</button>
|
|
497
|
+
</li>
|
|
498
|
+
<ul aria-labelledby="expand-btn">Items</ul>
|
|
499
|
+
`;
|
|
500
|
+
const button = document.getElementById('expand-btn');
|
|
501
|
+
expect(getAccessibleName(button)).toBe('Expand subnavigation');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should return false for truly hidden element not referenced by aria-labelledby', () => {
|
|
505
|
+
document.body.innerHTML = `
|
|
506
|
+
<div aria-hidden="true">
|
|
507
|
+
<button id="hidden-btn">Hidden button</button>
|
|
508
|
+
</div>
|
|
509
|
+
`;
|
|
510
|
+
const button = document.getElementById('hidden-btn');
|
|
511
|
+
expect(getAccessibleName(button)).toBe(false);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
470
514
|
});
|
|
@@ -2,179 +2,251 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
2
2
|
import { getComputedRole, roleMapping } from '../src/getComputedRole.js';
|
|
3
3
|
|
|
4
4
|
describe('getComputedRole', () => {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
document.body.innerHTML = '';
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should return undefined for null or undefined element', () => {
|
|
10
|
+
expect(getComputedRole(null)).toBeUndefined();
|
|
11
|
+
expect(getComputedRole(undefined)).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return the role attribute value if present', () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
const element = document.createElement('div');
|
|
17
|
+
element.setAttribute('role', 'button');
|
|
18
|
+
document.body.appendChild(element);
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const result = getComputedRole(element);
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(result).toBe('button');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return the correct role for basic elements with simple string mapping', () => {
|
|
28
|
+
// Test cases for elements with simple string mapping
|
|
29
|
+
const testCases = [
|
|
30
|
+
{ tag: 'div', expectedRole: 'group' },
|
|
31
|
+
{ tag: 'p', expectedRole: 'text' },
|
|
32
|
+
{ tag: 'ul', expectedRole: 'list' },
|
|
33
|
+
{ tag: 'h1', expectedRole: 'heading' },
|
|
34
|
+
{ tag: 'button', expectedRole: 'button' },
|
|
35
|
+
{ tag: 'nav', expectedRole: 'navigation' },
|
|
36
|
+
{ tag: 'header', expectedRole: 'banner' },
|
|
37
|
+
{ tag: 'footer', expectedRole: 'contentinfo' },
|
|
38
|
+
{ tag: 'main', expectedRole: 'main' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
testCases.forEach(({ tag, expectedRole }) => {
|
|
42
|
+
// Arrange
|
|
43
|
+
const element = document.createElement(tag);
|
|
44
|
+
document.body.appendChild(element);
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
const result = getComputedRole(element);
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(result).toBe(expectedRole);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should handle link elements (a) correctly based on href attribute', () => {
|
|
55
|
+
// Arrange
|
|
56
|
+
const linkWithHref = document.createElement('a');
|
|
57
|
+
linkWithHref.setAttribute('href', 'https://example.com');
|
|
58
|
+
|
|
59
|
+
const linkWithoutHref = document.createElement('a');
|
|
60
|
+
|
|
61
|
+
document.body.appendChild(linkWithHref);
|
|
62
|
+
document.body.appendChild(linkWithoutHref);
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
const withHrefResult = getComputedRole(linkWithHref);
|
|
66
|
+
const withoutHrefResult = getComputedRole(linkWithoutHref);
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(withHrefResult).toBe('link');
|
|
70
|
+
expect(withoutHrefResult).toBe('text');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should handle image elements based on alt attribute', () => {
|
|
74
|
+
// Arrange
|
|
75
|
+
const imgWithAlt = document.createElement('img');
|
|
76
|
+
imgWithAlt.setAttribute('alt', 'Description of image');
|
|
77
|
+
|
|
78
|
+
const imgWithEmptyAlt = document.createElement('img');
|
|
79
|
+
imgWithEmptyAlt.setAttribute('alt', '');
|
|
80
|
+
|
|
81
|
+
document.body.appendChild(imgWithAlt);
|
|
82
|
+
document.body.appendChild(imgWithEmptyAlt);
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
const withAltResult = getComputedRole(imgWithAlt);
|
|
86
|
+
const withEmptyAltResult = getComputedRole(imgWithEmptyAlt);
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(withAltResult).toBe('image');
|
|
90
|
+
expect(withEmptyAltResult).toBe('presentation');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle various input types correctly', () => {
|
|
94
|
+
const inputTypes = [
|
|
95
|
+
{ type: 'button', expectedRole: 'button' },
|
|
96
|
+
{ type: 'checkbox', expectedRole: 'checkbox' },
|
|
97
|
+
{ type: 'hidden', expectedRole: 'noRole' },
|
|
98
|
+
{ type: 'image', expectedRole: 'button' },
|
|
99
|
+
{ type: 'number', expectedRole: 'spinbutton' },
|
|
100
|
+
{ type: 'radio', expectedRole: 'radio' },
|
|
101
|
+
{ type: 'range', expectedRole: 'slider' },
|
|
102
|
+
{ type: 'reset', expectedRole: 'button' },
|
|
103
|
+
{ type: 'submit', expectedRole: 'button' },
|
|
104
|
+
{ type: 'password', expectedRole: 'textbox' },
|
|
105
|
+
{ type: 'text', expectedRole: 'textbox' },
|
|
106
|
+
{ type: 'search', expectedRole: 'searchbox' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
inputTypes.forEach(({ type, expectedRole }) => {
|
|
110
|
+
// Arrange
|
|
111
|
+
const input = document.createElement('input');
|
|
112
|
+
input.setAttribute('type', type);
|
|
113
|
+
document.body.appendChild(input);
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
const result = getComputedRole(input);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
expect(result).toBe(expectedRole);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should handle elements with no role mapping', () => {
|
|
124
|
+
// Create a custom element that's not in the roleMapping
|
|
125
|
+
const customElement = document.createElement('custom-element');
|
|
126
|
+
document.body.appendChild(customElement);
|
|
127
|
+
|
|
128
|
+
// Act
|
|
129
|
+
const result = getComputedRole(customElement);
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
expect(result).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should prioritize role attribute over computed role', () => {
|
|
136
|
+
// Arrange
|
|
137
|
+
const element = document.createElement('button'); // Inherent role is 'button'
|
|
138
|
+
element.setAttribute('role', 'menuitem'); // Override with custom role
|
|
139
|
+
document.body.appendChild(element);
|
|
140
|
+
|
|
141
|
+
// Act
|
|
142
|
+
const result = getComputedRole(element);
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
145
|
+
expect(result).toBe('menuitem'); // Should use the explicit role
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle empty or whitespace role attribute', () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
const emptyRoleElement = document.createElement('div');
|
|
151
|
+
emptyRoleElement.setAttribute('role', '');
|
|
152
|
+
|
|
153
|
+
const whitespaceRoleElement = document.createElement('div');
|
|
154
|
+
whitespaceRoleElement.setAttribute('role', ' ');
|
|
155
|
+
|
|
156
|
+
document.body.appendChild(emptyRoleElement);
|
|
157
|
+
document.body.appendChild(whitespaceRoleElement);
|
|
158
|
+
|
|
159
|
+
// Act
|
|
160
|
+
const emptyResult = getComputedRole(emptyRoleElement);
|
|
161
|
+
const whitespaceResult = getComputedRole(whitespaceRoleElement);
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
// The implementation returns the fallback 'group' role for div when the role attribute is empty
|
|
165
|
+
expect(emptyResult).toBe('group');
|
|
166
|
+
expect(whitespaceResult).toBe(' '); // Whitespace is preserved
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should return gridcell for td inside role="grid"', () => {
|
|
170
|
+
document.body.innerHTML = `
|
|
171
|
+
<table role="grid">
|
|
172
|
+
<tr><td id="target">Cell</td></tr>
|
|
173
|
+
</table>
|
|
174
|
+
`;
|
|
175
|
+
const td = document.getElementById('target');
|
|
176
|
+
expect(getComputedRole(td)).toBe('gridcell');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should return cell for td inside regular table', () => {
|
|
180
|
+
document.body.innerHTML = `
|
|
181
|
+
<table>
|
|
182
|
+
<tr><td id="target">Cell</td></tr>
|
|
183
|
+
</table>
|
|
184
|
+
`;
|
|
185
|
+
const td = document.getElementById('target');
|
|
186
|
+
expect(getComputedRole(td)).toBe('cell');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should return gridcell for td inside role="treegrid"', () => {
|
|
190
|
+
document.body.innerHTML = `
|
|
191
|
+
<table role="treegrid">
|
|
192
|
+
<tr><td id="target">Cell</td></tr>
|
|
193
|
+
</table>
|
|
194
|
+
`;
|
|
195
|
+
const td = document.getElementById('target');
|
|
196
|
+
expect(getComputedRole(td)).toBe('gridcell');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should return generic for header inside article', () => {
|
|
200
|
+
document.body.innerHTML = `
|
|
201
|
+
<article>
|
|
202
|
+
<header id="target">Article Header</header>
|
|
203
|
+
</article>
|
|
204
|
+
`;
|
|
205
|
+
const header = document.getElementById('target');
|
|
206
|
+
expect(getComputedRole(header)).toBe('generic');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should return generic for header inside section', () => {
|
|
210
|
+
document.body.innerHTML = `
|
|
211
|
+
<section>
|
|
212
|
+
<header id="target">Section Header</header>
|
|
213
|
+
</section>
|
|
214
|
+
`;
|
|
215
|
+
const header = document.getElementById('target');
|
|
216
|
+
expect(getComputedRole(header)).toBe('generic');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should return banner for header that is direct child of body', () => {
|
|
220
|
+
document.body.innerHTML = '<header id="target">Page Header</header>';
|
|
221
|
+
const header = document.getElementById('target');
|
|
222
|
+
expect(getComputedRole(header)).toBe('banner');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should return generic for footer inside article', () => {
|
|
226
|
+
document.body.innerHTML = `
|
|
227
|
+
<article>
|
|
228
|
+
<footer id="target">Article Footer</footer>
|
|
229
|
+
</article>
|
|
230
|
+
`;
|
|
231
|
+
const footer = document.getElementById('target');
|
|
232
|
+
expect(getComputedRole(footer)).toBe('generic');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should return contentinfo for footer that is direct child of body', () => {
|
|
236
|
+
document.body.innerHTML = '<footer id="target">Page Footer</footer>';
|
|
237
|
+
const footer = document.getElementById('target');
|
|
238
|
+
expect(getComputedRole(footer)).toBe('contentinfo');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should handle case insensitivity for tag names', () => {
|
|
242
|
+
// Arrange - create element with uppercase tag
|
|
243
|
+
const upperElement = document.createElement('DIV');
|
|
244
|
+
document.body.appendChild(upperElement);
|
|
245
|
+
|
|
246
|
+
// Act
|
|
247
|
+
const result = getComputedRole(upperElement);
|
|
248
|
+
|
|
249
|
+
// Assert
|
|
250
|
+
expect(result).toBe('group'); // Should match 'div' in the mapping
|
|
251
|
+
});
|
|
252
|
+
});
|