Skip to content

Commit 1999584

Browse files
Merge pull request #1690 from equalizedigital/steve/pro-802-elementor-icon-screen-reader-text-or-something-false
Fix false positive: skip screen-reader-only elements in text_size_too_small check
2 parents 61fed6a + af1861c commit 1999584

3 files changed

Lines changed: 160 additions & 2 deletions

File tree

src/pageScanner/checks/text-size-too-small.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* @return {boolean} True if the text size is equal to or below the minimum size. False otherwise.
66
*/
77

8-
import { fontSizeInPx } from '../helpers/helpers';
8+
import { fontSizeInPx, isScreenReaderOnly } from '../helpers/helpers';
99

1010
const SMALL_FONT_SIZE_THRESHOLD = 10;
1111

@@ -22,7 +22,21 @@ export default {
2222
// handles both leaf nodes and container elements with mixed content.
2323
const hasTextChild = Array.from( node.childNodes ).some( ( child ) => child.nodeType === Node.TEXT_NODE );
2424
if ( ! node.childNodes.length || hasTextChild ) {
25-
return fontSizeInPx( node ) <= SMALL_FONT_SIZE_THRESHOLD;
25+
// Screen-reader-only elements are visually hidden intentionally — font size
26+
// is irrelevant for text that sighted users never see.
27+
if ( isScreenReaderOnly( node ) ) {
28+
return false;
29+
}
30+
31+
const fontSize = fontSizeInPx( node );
32+
33+
// font-size: 0 means the text isn't rendering at all (commonly used as a
34+
// layout technique on icon widget containers). That's not "too small to read".
35+
if ( fontSize === 0 ) {
36+
return false;
37+
}
38+
39+
return fontSize <= SMALL_FONT_SIZE_THRESHOLD;
2640
}
2741

2842
// No text nodes were found in direct children, and this is not a leaf node,

src/pageScanner/helpers/helpers.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,33 @@ export const isElementVisible = ( element ) => {
5252
return element.parentElement ? isElementVisible( element.parentElement ) : true;
5353
};
5454

55+
/**
56+
* Check if an element (or any ancestor) is hidden using the screen-reader-only
57+
* technique — visually clipped to nothing but still in the accessibility tree.
58+
* Font-size checks are irrelevant for such elements.
59+
*
60+
* @param {HTMLElement} element The element to check.
61+
* @return {boolean} True if the element is screen-reader-only.
62+
*/
63+
export const isScreenReaderOnly = ( element ) => {
64+
let el = element;
65+
while ( el && el.nodeType === Node.ELEMENT_NODE ) {
66+
const style = window.getComputedStyle( el );
67+
const isPositional = style.position === 'absolute' || style.position === 'fixed';
68+
if ( ( isPositional && style.clip === 'rect(0px, 0px, 0px, 0px)' ) || style.clipPath === 'inset(50%)' ) {
69+
return true;
70+
}
71+
// Catch sr-only patterns that rely on 1px dimensions rather than clip values
72+
// (e.g. Elementor's .elementor-screen-only uses width/height:1px + overflow:hidden).
73+
if ( isPositional && style.overflow === 'hidden' &&
74+
parseFloat( style.width ) <= 1 && parseFloat( style.height ) <= 1 ) {
75+
return true;
76+
}
77+
el = el.parentElement;
78+
}
79+
return false;
80+
};
81+
5582
/**
5683
* A Map that normalizes all keys to lowercase
5784
*/

tests/jest/rules/textSmall.test.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import axe from 'axe-core';
2+
3+
beforeAll( async () => {
4+
const textSmallRuleModule = await import( '../../../src/pageScanner/rules/text-small.js' );
5+
const textSizeTooSmallCheckModule = await import( '../../../src/pageScanner/checks/text-size-too-small.js' );
6+
const textSmallRule = textSmallRuleModule.default;
7+
const textSizeTooSmallCheck = textSizeTooSmallCheckModule.default;
8+
9+
axe.configure( {
10+
rules: [ textSmallRule ],
11+
checks: [ textSizeTooSmallCheck ],
12+
} );
13+
} );
14+
15+
beforeEach( () => {
16+
document.body.innerHTML = '';
17+
} );
18+
19+
describe( 'Text Small Validation', () => {
20+
const testCases = [
21+
// PASSING CASES
22+
{
23+
name: 'should pass for text above the size threshold',
24+
html: '<p style="font-size: 14px;">Normal sized text</p>',
25+
shouldPass: true,
26+
},
27+
{
28+
name: 'should pass for text exactly above the threshold',
29+
html: '<span style="font-size: 11px;">Slightly above threshold</span>',
30+
shouldPass: true,
31+
},
32+
{
33+
name: 'should pass for element with no text content',
34+
html: '<p style="font-size: 8px;"></p>',
35+
shouldPass: true,
36+
},
37+
{
38+
name: 'should pass for container element with no direct text children',
39+
html: '<div style="font-size: 8px;"><span>Child text</span></div>',
40+
shouldPass: true,
41+
},
42+
{
43+
name: 'should pass for screen-reader-only text using classic clip pattern',
44+
html: '<span style="position: absolute; width: 1px; height: 1px; clip: rect(0px, 0px, 0px, 0px); overflow: hidden; font-size: 8px;">Screen reader only</span>',
45+
shouldPass: true,
46+
},
47+
{
48+
name: 'should pass for screen-reader-only text using clip-path pattern',
49+
html: '<span style="clip-path: inset(50%); font-size: 8px;">Screen reader only</span>',
50+
shouldPass: true,
51+
},
52+
{
53+
name: 'should pass for screen-reader-only text using dimension-based pattern (no clip)',
54+
html: '<span style="position: absolute; width: 1px; height: 1px; overflow: hidden; font-size: 8px;">Screen reader only</span>',
55+
shouldPass: true,
56+
},
57+
{
58+
name: 'should pass for screen-reader-only text nested inside a visible element (Elementor social icon pattern)',
59+
html: '<a href="#" style="font-size: 8px;"><span style="position: absolute; width: 1px; height: 1px; overflow: hidden;">Facebook</span><svg aria-hidden="true"></svg></a>',
60+
shouldPass: true,
61+
},
62+
{
63+
name: 'should pass for text with font-size: 0 inherited from a layout container (Elementor social icons pattern)',
64+
html: '<div style="font-size: 0;"><span>Screen reader label</span></div>',
65+
shouldPass: true,
66+
},
67+
68+
// FAILING CASES
69+
{
70+
name: 'should fail for clip without absolute/fixed position (clip has no visual effect)',
71+
html: '<span style="position: static; clip: rect(0px, 0px, 0px, 0px); font-size: 8px;">Clip but not positioned</span>',
72+
shouldPass: false,
73+
},
74+
{
75+
name: 'should fail for text at the threshold boundary (10px)',
76+
html: '<span style="font-size: 10px;">Threshold text</span>',
77+
shouldPass: false,
78+
},
79+
{
80+
name: 'should fail for text below the threshold',
81+
html: '<p style="font-size: 8px;">Small text</p>',
82+
shouldPass: false,
83+
},
84+
{
85+
name: 'should fail for small text in a heading',
86+
html: '<h2 style="font-size: 9px;">Small heading</h2>',
87+
shouldPass: false,
88+
},
89+
{
90+
name: 'should fail for small text in a link',
91+
html: '<a href="#" style="font-size: 7px;">Tiny link</a>',
92+
shouldPass: false,
93+
},
94+
{
95+
name: 'should fail for small text in a list item',
96+
html: '<ul><li style="font-size: 8px;">Small list item</li></ul>',
97+
shouldPass: false,
98+
},
99+
];
100+
101+
testCases.forEach( ( testCase ) => {
102+
it( testCase.name, async () => {
103+
document.body.innerHTML = testCase.html;
104+
105+
const results = await axe.run( document, {
106+
runOnly: [ 'text_small' ],
107+
} );
108+
109+
if ( testCase.shouldPass ) {
110+
expect( results.violations.length ).toBe( 0 );
111+
} else {
112+
expect( results.violations.length ).toBeGreaterThan( 0 );
113+
expect( results.violations[ 0 ].id ).toBe( 'text_small' );
114+
}
115+
} );
116+
} );
117+
} );

0 commit comments

Comments
 (0)