@afixt/test-utils 2.1.1 → 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 +4 -1
- 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/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/test/isHidden.test.js
CHANGED
|
@@ -240,6 +240,24 @@ describe('isHidden', () => {
|
|
|
240
240
|
expect(isHidden(element, { checkDimensions: true })).toBe(true);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
it('should return false for elements with hidden="until-found"', () => {
|
|
244
|
+
const element = document.createElement('div');
|
|
245
|
+
element.setAttribute('hidden', 'until-found');
|
|
246
|
+
document.body.appendChild(element);
|
|
247
|
+
|
|
248
|
+
// hidden="until-found" is NOT fully hidden — content is findable by
|
|
249
|
+
// browser Find-in-Page and may be accessible to AT
|
|
250
|
+
expect(isHidden(element)).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should return true for elements with plain hidden attribute (empty string)', () => {
|
|
254
|
+
const element = document.createElement('div');
|
|
255
|
+
element.setAttribute('hidden', '');
|
|
256
|
+
document.body.appendChild(element);
|
|
257
|
+
|
|
258
|
+
expect(isHidden(element)).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
243
261
|
it('should detect visibility:hidden applied via CSS class', () => {
|
|
244
262
|
// Arrange
|
|
245
263
|
const style = document.createElement('style');
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright tests for isA11yVisible with colon-containing IDs
|
|
3
|
+
*
|
|
4
|
+
* Issue #61: CSS.escape is needed when building attribute selectors from
|
|
5
|
+
* element IDs. Colons in IDs (e.g. Radix UI "radix-:rj:-tab-account")
|
|
6
|
+
* break unescaped CSS selectors.
|
|
7
|
+
*
|
|
8
|
+
* Run with: npm run test:playwright:css
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { test, expect } = require('@playwright/test');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
test.describe('isA11yVisible with colon-containing IDs', () => {
|
|
15
|
+
test.beforeEach(async ({ page }) => {
|
|
16
|
+
const fixturePath = path.join(__dirname, 'fixtures', 'colon-id-a11y-visible.html');
|
|
17
|
+
await page.goto(`file://${fixturePath}`);
|
|
18
|
+
|
|
19
|
+
// Inject browser-compatible isA11yVisible (mirrors src/isA11yVisible.js)
|
|
20
|
+
await page.addScriptTag({
|
|
21
|
+
content: `
|
|
22
|
+
const NON_VISIBLE_SELECTORS = [
|
|
23
|
+
'base','head','meta','title','link','style','script','br','nobr',
|
|
24
|
+
'col','embed','input[type="hidden"]','keygen','source','track',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function isHidden(element, options) {
|
|
28
|
+
if (!element || !(element instanceof Element)) return false;
|
|
29
|
+
options = options || {};
|
|
30
|
+
if (element.hasAttribute('hidden')) return true;
|
|
31
|
+
const style = window.getComputedStyle(element);
|
|
32
|
+
if (style.display === 'none') return true;
|
|
33
|
+
if (style.visibility === 'hidden') return true;
|
|
34
|
+
if (options.checkOpacity && style.opacity === '0') return true;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isA11yVisible(element, strict) {
|
|
39
|
+
if (strict === undefined) strict = false;
|
|
40
|
+
if (!element || !(element instanceof Element)) return false;
|
|
41
|
+
if (!element.isConnected) return false;
|
|
42
|
+
|
|
43
|
+
const id = element.id;
|
|
44
|
+
let visible = true;
|
|
45
|
+
|
|
46
|
+
if (NON_VISIBLE_SELECTORS.some(function(s) { return element.matches(s); })) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function optionalAriaHidden(el, strictCheck) {
|
|
51
|
+
return strictCheck && el.getAttribute('aria-hidden') === 'true';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isHidden(element, { checkOpacity: true })) {
|
|
55
|
+
visible = false;
|
|
56
|
+
} else {
|
|
57
|
+
let parent = element.parentElement;
|
|
58
|
+
while (parent) {
|
|
59
|
+
const style = window.getComputedStyle(parent);
|
|
60
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
61
|
+
visible = false;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
parent = parent.parentElement;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (visible && optionalAriaHidden(element, strict)) {
|
|
69
|
+
visible = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (id) {
|
|
73
|
+
document
|
|
74
|
+
.querySelectorAll(
|
|
75
|
+
'*[aria-labelledby~="' + CSS.escape(id) + '"], *[aria-describedby~="' + CSS.escape(id) + '"]'
|
|
76
|
+
)
|
|
77
|
+
.forEach(function(referencingElement) {
|
|
78
|
+
if (window.getComputedStyle(referencingElement).display !== 'none') {
|
|
79
|
+
visible = true;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (visible && strict) {
|
|
85
|
+
let parent = element.parentElement;
|
|
86
|
+
while (parent) {
|
|
87
|
+
if (optionalAriaHidden(parent, strict)) {
|
|
88
|
+
visible = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
parent = parent.parentElement;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return visible;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
window.isA11yVisible = isA11yVisible;
|
|
99
|
+
`,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('baseline: hidden element with simple ID referenced by aria-labelledby is AT-visible', async ({
|
|
104
|
+
page,
|
|
105
|
+
}) => {
|
|
106
|
+
const result = await page.evaluate(() => {
|
|
107
|
+
const el = document.getElementById('simple-label');
|
|
108
|
+
return window.isA11yVisible(el);
|
|
109
|
+
});
|
|
110
|
+
expect(result).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('hidden element with colon ID referenced by aria-labelledby is AT-visible', async ({
|
|
114
|
+
page,
|
|
115
|
+
}) => {
|
|
116
|
+
const result = await page.evaluate(() => {
|
|
117
|
+
const el = document.getElementById('radix-:rj:-tab-account');
|
|
118
|
+
return window.isA11yVisible(el);
|
|
119
|
+
});
|
|
120
|
+
expect(result).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('hidden element with colon ID referenced by aria-describedby is AT-visible', async ({
|
|
124
|
+
page,
|
|
125
|
+
}) => {
|
|
126
|
+
const result = await page.evaluate(() => {
|
|
127
|
+
const el = document.getElementById('desc-:r1:-help');
|
|
128
|
+
return window.isA11yVisible(el);
|
|
129
|
+
});
|
|
130
|
+
expect(result).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('hidden element with multiple colons in ID referenced by aria-labelledby is AT-visible', async ({
|
|
134
|
+
page,
|
|
135
|
+
}) => {
|
|
136
|
+
const result = await page.evaluate(() => {
|
|
137
|
+
const el = document.getElementById('ns:component:instance:42');
|
|
138
|
+
return window.isA11yVisible(el);
|
|
139
|
+
});
|
|
140
|
+
expect(result).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('visible element with colon ID (no aria reference) is AT-visible', async ({ page }) => {
|
|
144
|
+
const result = await page.evaluate(() => {
|
|
145
|
+
const el = document.getElementById('colon:visible');
|
|
146
|
+
return window.isA11yVisible(el);
|
|
147
|
+
});
|
|
148
|
+
expect(result).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('hidden element with colon ID and no aria reference is NOT AT-visible', async ({
|
|
152
|
+
page,
|
|
153
|
+
}) => {
|
|
154
|
+
const result = await page.evaluate(() => {
|
|
155
|
+
const el = document.getElementById('colon:hidden');
|
|
156
|
+
return window.isA11yVisible(el);
|
|
157
|
+
});
|
|
158
|
+
expect(result).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>isA11yVisible - Colon ID Test Fixtures</title>
|
|
7
|
+
<style>
|
|
8
|
+
.sr-only {
|
|
9
|
+
position: absolute;
|
|
10
|
+
width: 1px;
|
|
11
|
+
height: 1px;
|
|
12
|
+
padding: 0;
|
|
13
|
+
margin: -1px;
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
clip: rect(0, 0, 0, 0);
|
|
16
|
+
white-space: nowrap;
|
|
17
|
+
border: 0;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<!-- Standard ID: baseline reference -->
|
|
23
|
+
<span id="simple-label" class="sr-only" style="display:none">Account settings</span>
|
|
24
|
+
<button aria-labelledby="simple-label">Settings</button>
|
|
25
|
+
|
|
26
|
+
<!-- Colon-containing ID: Radix UI style -->
|
|
27
|
+
<span id="radix-:rj:-tab-account" class="sr-only" style="display:none">Account tab</span>
|
|
28
|
+
<button aria-labelledby="radix-:rj:-tab-account">Account</button>
|
|
29
|
+
|
|
30
|
+
<!-- Colon-containing ID: aria-describedby -->
|
|
31
|
+
<span id="desc-:r1:-help" class="sr-only" style="display:none">Help text for input</span>
|
|
32
|
+
<input aria-describedby="desc-:r1:-help" type="text" />
|
|
33
|
+
|
|
34
|
+
<!-- Multiple colons -->
|
|
35
|
+
<span id="ns:component:instance:42" class="sr-only" style="display:none">Namespaced label</span>
|
|
36
|
+
<div aria-labelledby="ns:component:instance:42">Namespaced widget</div>
|
|
37
|
+
|
|
38
|
+
<!-- Visible element with colon ID (no aria reference, just visible) -->
|
|
39
|
+
<div id="colon:visible">Visible colon ID</div>
|
|
40
|
+
|
|
41
|
+
<!-- Hidden element with colon ID, no aria reference (should be hidden) -->
|
|
42
|
+
<div id="colon:hidden" style="display:none">Hidden colon ID</div>
|
|
43
|
+
|
|
44
|
+
<script>
|
|
45
|
+
window.testReady = true;
|
|
46
|
+
</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
@@ -95,18 +95,18 @@ describe('testContrast', () => {
|
|
|
95
95
|
expect(testContrast(div)).toBe(true);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it('should
|
|
98
|
+
it('should return null for elements with a background image (cannot determine)', () => {
|
|
99
99
|
const div = document.createElement('div');
|
|
100
100
|
div.textContent = 'Text with background image';
|
|
101
101
|
div.style.color = 'rgb(0, 0, 0)';
|
|
102
102
|
div.style.backgroundImage = 'url(image.jpg)';
|
|
103
103
|
document.body.appendChild(div);
|
|
104
104
|
|
|
105
|
-
// Should
|
|
106
|
-
expect(testContrast(div)).toBe(
|
|
105
|
+
// Should return null because contrast can't be reliably tested against background images
|
|
106
|
+
expect(testContrast(div)).toBe(null);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
it('should
|
|
109
|
+
it('should return null for elements whose ancestor has a background image (cannot determine)', () => {
|
|
110
110
|
const parent = document.createElement('div');
|
|
111
111
|
parent.style.backgroundImage = 'url(bg.jpg)';
|
|
112
112
|
|
|
@@ -118,7 +118,7 @@ describe('testContrast', () => {
|
|
|
118
118
|
parent.appendChild(child);
|
|
119
119
|
document.body.appendChild(parent);
|
|
120
120
|
|
|
121
|
-
expect(testContrast(child)).toBe(
|
|
121
|
+
expect(testContrast(child)).toBe(null);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
it('should not skip elements with opaque background over ancestor background image', () => {
|
|
@@ -138,6 +138,43 @@ describe('testContrast', () => {
|
|
|
138
138
|
expect(testContrast(child)).toBe(true);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it('should return null for elements with CSS filter (cannot determine)', () => {
|
|
142
|
+
const div = document.createElement('div');
|
|
143
|
+
div.textContent = 'Filtered text';
|
|
144
|
+
div.style.color = 'rgb(0, 0, 0)';
|
|
145
|
+
div.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
146
|
+
div.style.filter = 'brightness(0.5)';
|
|
147
|
+
document.body.appendChild(div);
|
|
148
|
+
|
|
149
|
+
expect(testContrast(div)).toBe(null);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should return null for elements with mix-blend-mode (cannot determine)', () => {
|
|
153
|
+
const div = document.createElement('div');
|
|
154
|
+
div.textContent = 'Blended text';
|
|
155
|
+
div.style.color = 'rgb(0, 0, 0)';
|
|
156
|
+
div.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
157
|
+
div.style.mixBlendMode = 'multiply';
|
|
158
|
+
document.body.appendChild(div);
|
|
159
|
+
|
|
160
|
+
expect(testContrast(div)).toBe(null);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should return null when ancestor has CSS filter (cannot determine)', () => {
|
|
164
|
+
const parent = document.createElement('div');
|
|
165
|
+
parent.style.filter = 'invert(1)';
|
|
166
|
+
|
|
167
|
+
const child = document.createElement('div');
|
|
168
|
+
child.textContent = 'Child of filtered parent';
|
|
169
|
+
child.style.color = 'rgb(0, 0, 0)';
|
|
170
|
+
child.style.backgroundColor = 'rgb(255, 255, 255)';
|
|
171
|
+
|
|
172
|
+
parent.appendChild(child);
|
|
173
|
+
document.body.appendChild(parent);
|
|
174
|
+
|
|
175
|
+
expect(testContrast(child)).toBe(null);
|
|
176
|
+
});
|
|
177
|
+
|
|
141
178
|
it('should handle invisible elements', () => {
|
|
142
179
|
const div = document.createElement('div');
|
|
143
180
|
div.textContent = 'Hidden text';
|