@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/test-utils",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -456,23 +456,45 @@ function getAccessibleName(element) {
456
456
  }
457
457
 
458
458
  // STEP 15: Landmark elements that require explicit accessible names
459
- // Navigation landmarks (nav elements and role="navigation") should NOT get
460
- // their accessible name from text content - they require explicit labeling
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 isNavigation =
465
- element.tagName.toLowerCase() === 'nav' || element.getAttribute('role') === 'navigation';
463
+ const landmarkRoles = [
464
+ 'banner',
465
+ 'complementary',
466
+ 'contentinfo',
467
+ 'form',
468
+ 'main',
469
+ 'navigation',
470
+ 'region',
471
+ 'search',
472
+ ];
466
473
 
467
- if (isNavigation) {
468
- // Title attribute is valid for navigation landmarks
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
- // Navigation landmarks do not get accessible name from text content
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', 'head', 'meta', 'title', 'link', 'style', 'script', 'br', 'nobr', 'col', 'embed',
24
- 'input[type="hidden"]', 'keygen', 'source', 'track', 'wbr', 'datalist', 'area', 'param', 'noframes', 'ruby > rp'
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) => strictCheck && el.getAttribute('aria-hidden') === 'true';
50
+ const optionalAriaHidden = (el, strictCheck) =>
51
+ strictCheck && el.getAttribute('aria-hidden') === 'true';
32
52
 
33
- const isElemDisplayed = el => window.getComputedStyle(el).display === 'none';
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 (isElemDisplayed(element)) return true;
59
+ if (isElemHiddenByCSS(element)) {
60
+ return true;
61
+ }
37
62
 
38
63
  let parent = element.parentElement;
39
64
  while (parent) {
40
- if (isElemDisplayed(parent)) return true;
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.querySelectorAll(`*[aria-labelledby~="${id}"], *[aria-describedby~="${id}"]`).forEach(referencingElement => {
52
- if (window.getComputedStyle(referencingElement).display !== 'none') {
53
- visible = true;
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
  };
@@ -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
  });
@@ -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
- 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
- };
12
- });
13
- });
14
-
15
- it('should return false for null or undefined elements', () => {
16
- expect(isVisible(null)).toBe(false);
17
- expect(isVisible(undefined)).toBe(false);
18
- });
19
-
20
- it('should handle inherently non-visible elements correctly', () => {
21
- // The behavior of isVisible for inherently non-visible elements is to return *false* by default
22
- // This is a mock test just to make it pass until we can properly test the functionality
23
-
24
- // Create a div element
25
- document.body.innerHTML = `<div id="test"></div>`;
26
- const element = document.getElementById('test');
27
-
28
- // Mock the matches method to simulate an inherently non-visible element
29
- const originalMatches = element.matches;
30
- element.matches = (selector) => true;
31
-
32
- // With our mocked matches method, isVisible should return true for inherently non-visible elements
33
- // according to the implementation in isVisible.js
34
- expect(isVisible(element)).toBe(true);
35
-
36
- // Restore the original method
37
- element.matches = originalMatches;
38
- });
39
-
40
- it('should return false for elements with display:none', () => {
41
- document.body.innerHTML = `<div id="test" style="display:none">Hidden</div>`;
42
- const element = document.getElementById('test');
43
- expect(isVisible(element)).toBe(false);
44
- });
45
-
46
- it('should return false for elements with a parent that has display:none', () => {
47
- document.body.innerHTML = `
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
- const element = document.getElementById('child');
53
- expect(isVisible(element)).toBe(false);
54
- });
55
-
56
- it('should return false when aria-hidden=true in strict mode', () => {
57
- document.body.innerHTML = `<div id="test" aria-hidden="true">Hidden</div>`;
58
- const element = document.getElementById('test');
59
-
60
- // In non-strict mode, aria-hidden alone doesn't make it invisible
61
- expect(isVisible(element)).toBe(true);
62
-
63
- // In strict mode, aria-hidden="true" makes it invisible
64
- expect(isVisible(element, true)).toBe(false);
65
- });
66
-
67
- it('should return false when a parent has aria-hidden=true in strict mode', () => {
68
- document.body.innerHTML = `
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
- const element = document.getElementById('child');
74
-
75
- // In non-strict mode, aria-hidden alone doesn't make it invisible
76
- expect(isVisible(element)).toBe(true);
77
-
78
- // In strict mode, parent with aria-hidden="true" makes it invisible
79
- expect(isVisible(element, true)).toBe(false);
80
- });
81
-
82
- it('should consider elements referenced by aria-labelledby or aria-describedby', () => {
83
- document.body.innerHTML = `
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
- const label = document.getElementById('label');
89
- const button = document.querySelector('button');
90
-
91
- window.getComputedStyle.mockImplementation((el) => {
92
- if (el === label) {
93
- return { display: 'none' };
94
- }
95
- if (el === button) {
96
- return { display: 'block' };
97
- }
98
- return { display: 'block' };
99
- });
100
-
101
- // The label is hidden but should be considered visible because it's referenced
102
- expect(isVisible(label)).toBe(true);
103
- });
104
-
105
- it('should return false for non-Element objects', () => {
106
- expect(isVisible({})).toBe(false);
107
- expect(isVisible('string')).toBe(false);
108
- expect(isVisible(123)).toBe(false);
109
- });
110
-
111
- it('should return false for disconnected elements', () => {
112
- const div = document.createElement('div');
113
- // Element is not connected to DOM
114
- expect(isVisible(div)).toBe(false);
115
- });
116
-
117
- it('should return true for visible elements', () => {
118
- document.body.innerHTML = `<div id="test">Visible</div>`;
119
- const element = document.getElementById('test');
120
- expect(isVisible(element)).toBe(true);
121
- });
122
-
123
- it('should handle elements referenced by aria-describedby', () => {
124
- document.body.innerHTML = `
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
- const desc = document.getElementById('desc');
130
- const input = document.querySelector('input');
131
+ const desc = document.getElementById('desc');
132
+ const input = document.querySelector('input');
131
133
 
132
- window.getComputedStyle.mockImplementation((el) => {
133
- if (el === desc) {
134
- return { display: 'none' };
135
- }
136
- if (el === input) {
137
- return { display: 'block' };
138
- }
139
- return { display: 'block' };
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
- // Description is hidden but should be considered visible because it's referenced
143
- expect(isVisible(desc)).toBe(true);
144
- });
144
+ // Description is hidden but should be considered visible because it's referenced
145
+ expect(isVisible(desc)).toBe(true);
146
+ });
145
147
 
146
- it('should handle multiple ancestors with display:none', () => {
147
- document.body.innerHTML = `
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
- const element = document.getElementById('deeply-hidden');
157
- expect(isVisible(element)).toBe(false);
158
- });
158
+ const element = document.getElementById('deeply-hidden');
159
+ expect(isVisible(element)).toBe(false);
160
+ });
159
161
 
160
- it('should handle aria-hidden="false"', () => {
161
- document.body.innerHTML = `<div id="test" aria-hidden="false">Visible</div>`;
162
- const element = document.getElementById('test');
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
- // aria-hidden="false" should not affect visibility in either mode
165
- expect(isVisible(element)).toBe(true);
166
- expect(isVisible(element, true)).toBe(true);
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
- it('should handle elements with no aria-hidden attribute in strict mode', () => {
170
- document.body.innerHTML = `<div id="test">No aria-hidden</div>`;
171
- const element = document.getElementById('test');
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
- expect(isVisible(element, true)).toBe(true);
174
- });
175
+ expect(isVisible(element, true)).toBe(true);
176
+ });
175
177
 
176
- it('should handle multiple levels of parent aria-hidden in strict mode', () => {
177
- document.body.innerHTML = `
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
- const element = document.getElementById('child');
186
+ const element = document.getElementById('child');
185
187
 
186
- expect(isVisible(element, true)).toBe(false);
187
- });
188
+ expect(isVisible(element, true)).toBe(false);
189
+ });
188
190
 
189
- it('should check specific inherently non-visible elements', () => {
190
- const testCases = [
191
- { tag: 'script', html: '<script id="test">alert("test")</script>' },
192
- { tag: 'style', html: '<style id="test">body {}</style>' },
193
- { tag: 'input[type="hidden"]', html: '<input type="hidden" id="test" />' }
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
- testCases.forEach(({ tag, html }) => {
197
- document.body.innerHTML = html;
198
- const element = document.getElementById('test');
199
- // These elements return true (visible to AT) according to implementation
200
- expect(isVisible(element)).toBe(true);
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
- it('should handle element with no id referenced by aria-labelledby', () => {
205
- document.body.innerHTML = `
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
- const div = document.querySelector('div');
211
- // Element has no id, so won't be found by aria-labelledby query
212
- expect(isVisible(div)).toBe(false);
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
- it('should handle aria-hidden with different values in strict mode', () => {
216
- document.body.innerHTML = `<div id="test" aria-hidden="invalid">Text</div>`;
217
- const element = document.getElementById('test');
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
- // aria-hidden with value other than "true" should not hide in strict mode
220
- expect(isVisible(element, true)).toBe(true);
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 handle elements with background images', () => {
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 return true because background color can't be determined
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);