Fix sr-only text overflow false positive (#197)

This commit is contained in:
Abdul Wahab
2026-06-05 15:25:34 -07:00
committed by GitHub
parent 6788085015
commit 4e251061b8
31 changed files with 2076 additions and 3 deletions
+12 -3
View File
@@ -154,8 +154,8 @@ describe('detectUrl — browser-only fixtures', () => {
// Browser-only: needs scrollWidth vs clientWidth from real layout.
// Flag column: a nowrap line and an unbreakable token spilling past a
// fixed-width box (overflow visible). Pass column: a genuine
// overflow-x:auto scroll region, a <pre>, normally wrapping text, and a
// long line living inside a scroll ancestor.
// overflow-x:auto scroll region, a <pre>, normally wrapping text, a long
// line living inside a scroll ancestor, and sr-only accessible text.
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/text-overflow.html`);
const hits = f.filter(r => r.antipattern === 'text-overflow');
const flagged = new Set();
@@ -165,7 +165,16 @@ describe('detectUrl — browser-only fixtures', () => {
}
assert.ok(flagged.has('flag-nowrap'), 'expected the nowrap overflow case to flag');
assert.ok(flagged.has('flag-longword'), 'expected the unbreakable-token overflow case to flag');
for (const cls of ['pass-scroll', 'pass-pre', 'pass-wrap', 'pass-inside-scroll']) {
for (const cls of [
'pass-scroll',
'pass-pre',
'pass-wrap',
'pass-inside-scroll',
'pass-sr-only-clip-path',
'pass-sr-only-legacy',
'pass-sr-only-tiny-hidden',
'pass-sr-only-clipped-wide',
]) {
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
}
assert.equal(hits.length, 2, `expected exactly 2 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
+149
View File
@@ -10,6 +10,10 @@ import {
buildImportGraph, resolveImport,
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
} from '../cli/engine/detect-antipatterns.mjs';
import {
checkElementTextOverflowDOM,
isScreenReaderOnlyTextStyle,
} from '../cli/engine/rules/checks.mjs';
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
@@ -254,6 +258,151 @@ describe('detectHtml — layout', () => {
});
// ---------------------------------------------------------------------------
// Text overflow screen-reader-only handling
// ---------------------------------------------------------------------------
describe('checkElementTextOverflowDOM', () => {
function baseTextStyle(overrides = {}) {
return {
position: 'static',
width: '160px',
height: '20px',
overflow: 'visible',
overflowX: 'visible',
overflowY: 'visible',
clipPath: 'none',
clip: 'auto',
...overrides,
};
}
function mockTextElement({
className = 'flag-overflow',
style = baseTextStyle(),
clientWidth = 24,
clientHeight = 20,
scrollWidth = 80,
rectWidth = clientWidth,
rectHeight = clientHeight,
} = {}) {
return {
tagName: 'DIV',
className,
childNodes: [{ nodeType: 3, textContent: 'A long accessible label that overflows its box' }],
parentElement: null,
clientWidth,
clientHeight,
scrollWidth,
__style: style,
getAttribute(name) {
return name === 'class' ? className : null;
},
getBoundingClientRect() {
return { width: rectWidth, height: rectHeight };
},
};
}
function withMockComputedStyle(callback) {
const original = globalThis.getComputedStyle;
globalThis.getComputedStyle = (el) => el.__style;
try {
return callback();
} finally {
if (original === undefined) delete globalThis.getComputedStyle;
else globalThis.getComputedStyle = original;
}
}
test('classifies clip-path sr-only text as visually hidden', () => {
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
clipPath: 'inset(50%)',
}), { width: 1, height: 1 })).toBe(true);
});
test('classifies legacy clip rect sr-only text as visually hidden', () => {
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
clip: 'rect(0, 0, 0, 0)',
}), { width: 1, height: 1 })).toBe(true);
});
test('classifies tiny absolute overflow-hidden text as visually hidden without clip', () => {
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
}), { width: 1, height: 1 })).toBe(true);
});
test('classifies fully clipped text as visually hidden without tiny sizing', () => {
expect(isScreenReaderOnlyTextStyle(baseTextStyle({
position: 'absolute',
width: '160px',
height: '20px',
overflow: 'visible',
clipPath: 'inset(50%)',
}), { width: 160, height: 20 })).toBe(true);
});
test('flags visible overflowing text', () => {
const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(mockTextElement()));
expect(findings).toHaveLength(1);
expect(findings[0].id).toBe('text-overflow');
expect(findings[0].snippet).toContain('.flag-overflow');
});
test('skips overflowing sr-only text', () => {
const srOnly = mockTextElement({
className: 'pass-sr-only-clip-path',
style: baseTextStyle({
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
overflowX: 'hidden',
overflowY: 'hidden',
clipPath: 'inset(50%)',
}),
clientWidth: 1,
clientHeight: 1,
scrollWidth: 240,
rectWidth: 1,
rectHeight: 1,
});
const findings = withMockComputedStyle(() => checkElementTextOverflowDOM(srOnly));
expect(findings).toHaveLength(0);
});
test('does not classify tiny visible text as sr-only', () => {
const style = baseTextStyle({
position: 'absolute',
width: '1px',
height: '1px',
});
expect(isScreenReaderOnlyTextStyle(style, { width: 1, height: 1 })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Motion anti-patterns
// ---------------------------------------------------------------------------
+25
View File
@@ -8,6 +8,27 @@
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; padding: 24px; align-items: start; }
.col { padding: 16px; }
.box { width: 160px; border: 1px solid #ddd; margin: 0 0 24px; padding: 8px; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
border-width: 0;
}
.sr-only-clip-path { clip-path: inset(50%); }
.sr-only-legacy-clip { clip: rect(0, 0, 0, 0); }
.sr-only-tiny-hidden { clip: auto; }
.sr-only-clipped-wide {
position: absolute;
width: 160px;
height: 20px;
overflow: visible;
white-space: nowrap;
clip-path: inset(50%);
}
</style>
</head>
<body>
@@ -24,6 +45,10 @@
<pre class="box pass-pre" style="overflow: visible">a-very-long-preformatted-code-line-that-intentionally-extends-beyond-the-box-width</pre>
<div class="box pass-wrap">Ordinary wrapping paragraph text that stays comfortably within its container.</div>
<div class="box" style="overflow-x: auto"><span class="pass-inside-scroll" style="white-space: nowrap; display: inline-block">A long line living inside a scroll ancestor, which must not be flagged.</span></div>
<span class="pass-sr-only-clip-path sr-only sr-only-clip-path">A screen-reader-only label with long text that should never draw a visible overflow finding.</span>
<span class="pass-sr-only-legacy sr-only sr-only-legacy-clip">A legacy clipped screen-reader-only label with long text that should not be flagged.</span>
<span class="pass-sr-only-tiny-hidden sr-only sr-only-tiny-hidden">A tiny overflow-hidden screen-reader-only label without a clip declaration should not be flagged.</span>
<span class="pass-sr-only-clipped-wide sr-only-clipped-wide">A fully clipped label with a normal-sized box and overflowing text should not be flagged.</span>
</section>
</div>
</body>