@afixt/test-utils 1.2.3 → 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.3",
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
 
@@ -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
  });
@@ -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);