From 66e2e9a5924ce0746231f46fda1f3b6df83d4b1f Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:42:14 -0600 Subject: [PATCH 1/5] fix(scrollable-region-focusable): do not fail elements whose contents are fully visible with out scrolling --- .../scrollable-region-focusable-matches.js | 49 ++++++-- .../scrollable-region-focusable.html | 65 +++++++--- .../scrollable-region-focusable.json | 9 +- test/playground.html | 32 ++++- .../scrollable-region-focusable-matches.js | 112 +++++++++++++----- 5 files changed, 208 insertions(+), 59 deletions(-) diff --git a/lib/rules/scrollable-region-focusable-matches.js b/lib/rules/scrollable-region-focusable-matches.js index 34c4b08d77..00e910aca1 100644 --- a/lib/rules/scrollable-region-focusable-matches.js +++ b/lib/rules/scrollable-region-focusable-matches.js @@ -1,21 +1,56 @@ import hasContentVirtual from '../commons/dom/has-content-virtual'; import isComboboxPopup from '../commons/aria/is-combobox-popup'; +import sanitize from '../commons/text/sanitize'; import { querySelectorAll, getScroll } from '../core/utils'; +const buffer = 13; + export default function scrollableRegionFocusableMatches(node, virtualNode) { + const boundingRect = virtualNode.boundingClientRect; return ( // The element scrolls - getScroll(node, 13) !== undefined && + getScroll(node, buffer) !== undefined && // It's not a combobox popup, which commonly has keyboard focus added isComboboxPopup(virtualNode) === false && // And there's something actually worth scrolling to - isNoneEmptyElement(virtualNode) + hasScrollableContent(node, virtualNode, boundingRect) ); } -function isNoneEmptyElement(vNode) { - return querySelectorAll(vNode, '*').some(elm => - // (elm, noRecursion, ignoreAria) - hasContentVirtual(elm, true, true) - ); +function hasScrollableContent(node, virtualNode, boundingRect) { + return querySelectorAll(virtualNode, '*').some(vNode => { + const hasContent = hasContentVirtual(vNode, true, true); + if (!hasContent) { + return false; + } + + return getChildTextRects(vNode).some( + rect => + // part or all of the element is outside the scroll area + rect.left - boundingRect.left + rect.width > + node.clientWidth + buffer || + rect.top - boundingRect.top + rect.height > node.clientHeight + buffer + ); + }); +} + +function getChildTextRects(vNode) { + const boundingRect = vNode.boundingClientRect; + const clientRects = []; + + vNode.actualNode.childNodes.forEach(textNode => { + if (textNode.nodeType !== 3 || sanitize(textNode.nodeValue) === '') { + return; + } + + clientRects.push(...getContentRects(textNode)); + }); + + return clientRects.length ? clientRects : [boundingRect]; +} + +function getContentRects(node) { + const range = document.createRange(); + range.selectNodeContents(node); + return Array.from(range.getClientRects()); } diff --git a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html index 750785c2be..ab58b1f354 100644 --- a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html +++ b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html @@ -3,9 +3,12 @@ -
-
-

Content

+
+
+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium + doloremque laudantium. +

@@ -16,21 +19,48 @@

-
-
-

Content

+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tincidunt + nisi quis elit volutpat dignissim. Vivamus quis bibendum nisl. Duis id + imperdiet quam. Sed cursus elit condimentum lectus viverra, quis molestie + erat ullamcorper. Ut ut elit nulla. Fusce fermentum aliquam augue, vitae + blandit diam dignissim ut. Aliquam feugiat velit tempor molestie tempor. + Nunc placerat et ante id imperdiet. Integer volutpat, tortor ut facilisis + tincidunt, sapien ex molestie metus, vel eleifend tortor sapien vitae + elit. Pellentesque vel tristique odio. Duis ante augue, luctus eget + eleifend ut, malesuada sit amet diam. Duis viverra blandit erat ac ornare. + Quisque ut auctor justo. +

-
+
-
-

Content

+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + tincidunt nisi quis elit volutpat dignissim. Vivamus quis bibendum + nisl. Duis id imperdiet quam. Sed cursus elit condimentum lectus + viverra, quis molestie erat ullamcorper. Ut ut elit nulla. Fusce + fermentum aliquam augue, vitae blandit diam dignissim ut. Aliquam + feugiat velit tempor molestie tempor. Nunc placerat et ante id + imperdiet. Integer volutpat, tortor ut facilisis tincidunt, sapien ex + molestie metus, vel eleifend tortor sapien vitae elit. Pellentesque + vel tristique odio. Duis ante augue, luctus eget eleifend ut, + malesuada sit amet diam. Duis viverra blandit erat ac ornare. Quisque + ut auctor justo. +

@@ -47,9 +77,12 @@
-
-
-

Content

+
+
+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium + doloremque laudantium. +

@@ -144,3 +177,7 @@ test test + +
+

Contents

+
diff --git a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json index 50220b0bd8..5453988ac7 100644 --- a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json +++ b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json @@ -2,12 +2,5 @@ "description": "scrollable-region-focusable tests", "rule": "scrollable-region-focusable", "violations": [["#fail1"], ["#fail2"], ["#fail3"]], - "passes": [ - ["#pass1"], - ["#pass2"], - ["#pass3"], - ["#pass4"], - ["#pass5"], - ["#pass6"] - ] + "passes": [["#pass1"], ["#pass2"], ["#pass3"], ["#pass4"], ["#pass5"]] } diff --git a/test/playground.html b/test/playground.html index 29b6d3837d..bd6e9e2ccd 100644 --- a/test/playground.html +++ b/test/playground.html @@ -4,7 +4,35 @@
-

Hello World

+
+
+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc + tincidunt nisi quis elit volutpat dignissim. Vivamus quis bibendum + nisl. Duis id imperdiet quam. Sed cursus elit condimentum lectus + viverra, quis molestie erat ullamcorper. Ut ut elit nulla. Fusce + fermentum aliquam augue, vitae blandit diam dignissim ut. Aliquam + feugiat velit tempor molestie tempor. Nunc placerat et ante id + imperdiet. Integer volutpat, tortor ut facilisis tincidunt, sapien + ex molestie metus, vel eleifend tortor sapien vitae elit. + Pellentesque vel tristique odio. Duis ante augue, luctus eget + eleifend ut, malesuada sit amet diam. Duis viverra blandit erat ac + ornare. Quisque ut auctor justo. +

+
+
+
+
@@ -15,7 +43,7 @@

Hello World

() => { axe.run( { - runOnly: 'color-contrast', + runOnly: 'scrollable-region-focusable', elementRef: true }, (err, results) => { diff --git a/test/rule-matches/scrollable-region-focusable-matches.js b/test/rule-matches/scrollable-region-focusable-matches.js index 01dfe17710..93ee0a21ca 100644 --- a/test/rule-matches/scrollable-region-focusable-matches.js +++ b/test/rule-matches/scrollable-region-focusable-matches.js @@ -1,16 +1,12 @@ -describe('scrollable-region-focusable-matches', function () { +describe('scrollable-region-focusable-matches', () => { 'use strict'; - const fixture = document.getElementById('fixture'); + const fixture = document.querySelector('#fixture'); const queryFixture = axe.testUtils.queryFixture; const shadowSupported = axe.testUtils.shadowSupport.v1; const rule = axe.utils.getRule('scrollable-region-focusable'); - afterEach(function () { - fixture.innerHTML = ''; - }); - - it('returns false when element is not scrollable', function () { + it('returns false when element is not scrollable', () => { const target = queryFixture( '
This element is not scrollable
' ); @@ -18,7 +14,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element has no visible children', function () { + it('returns false when element has no visible children', () => { const target = queryFixture( '
' + '
' + @@ -30,7 +26,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element does not overflow', function () { + it('returns false when element does not overflow', () => { const target = queryFixture( '
' + '
Content
' + @@ -40,7 +36,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element is not scrollable (overflow=hidden)', function () { + it('returns false when element is not scrollable (overflow=hidden)', () => { const target = queryFixture( '
' + '
' + @@ -52,7 +48,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns true when element is scrollable (overflow=auto)', function () { + it('returns false when element does not have content that needs to be scrolled to', () => { const target = queryFixture( '
' + '
' + @@ -61,10 +57,70 @@ describe('scrollable-region-focusable-matches', function () { '
' ); const actual = rule.matches(target.actualNode, target); + assert.isFalse(actual); + }); + + it('returns true when element has scrollable content (overflow=auto)', () => { + const target = queryFixture( + '
' + + '
' + + '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium

' + + '
' + + '
' + ); + const actual = rule.matches(target.actualNode, target); + assert.isTrue(actual); + }); + + it('returns false when element has content fully inside scroll area', () => { + const target = queryFixture(` +
+
+ +
+
+ `); + const actual = rule.matches(target.actualNode, target); + assert.isFalse(actual); + }); + + it('returns false when element has content fully inside scroll area + buffer', () => { + const target = queryFixture(` +
+
+ +
+
+ `); + const actual = rule.matches(target.actualNode, target); + assert.isFalse(actual); + }); + + it('returns true when element has content partially outside scroll area', () => { + const target = queryFixture(` +
+
+ +
+
+ `); + const actual = rule.matches(target.actualNode, target); + assert.isTrue(actual); + }); + + it('returns true when element has content fully outside scroll area', () => { + const target = queryFixture(` +
+
+ +
+
+ `); + const actual = rule.matches(target.actualNode, target); assert.isTrue(actual); }); - it('returns false when element overflow is visible', function () { + it('returns false when element overflow is visible', () => { const target = queryFixture( '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.

' ); @@ -72,15 +128,15 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns true when element overflow is scroll', function () { + it('returns true when element overflow is scroll', () => { const target = queryFixture( - '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.

' + '

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.

' ); const actual = rule.matches(target.actualNode, target); assert.isTrue(actual); }); - it('returns false when element overflow is scroll but has no content', function () { + it('returns false when element overflow is scroll but has no content', () => { const target = queryFixture( '
' ); @@ -88,7 +144,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element has combobox ancestor', function () { + it('returns false when element has combobox ancestor', () => { const target = queryFixture( '
  • Option
' ); @@ -96,7 +152,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element is owned by combobox', function () { + it('returns false when element is owned by combobox', () => { const target = queryFixture( '
  • Option
' ); @@ -104,7 +160,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false when element is controlled by combobox', function () { + it('returns false when element is controlled by combobox', () => { const target = queryFixture( '
  • Option
' ); @@ -112,7 +168,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false for combobox with tree', function () { + it('returns false for combobox with tree', () => { const target = queryFixture( '
  • Option
' ); @@ -120,7 +176,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false for combobox with grid', function () { + it('returns false for combobox with grid', () => { const target = queryFixture( '
  • Option
' ); @@ -128,7 +184,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns false for combobox with dialog', function () { + it('returns false for combobox with dialog', () => { const target = queryFixture( '
' ); @@ -136,26 +192,26 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns true for combobox with non-valid role', function () { + it('returns true for combobox with non-valid role', () => { const target = queryFixture( - '
  • Option
' + '
  • Option
  • Option
  • Option
' ); const actual = rule.matches(target.actualNode, target); assert.isTrue(actual); }); - describe('shadowDOM - scrollable-region-focusable-matches', function () { - before(function () { + describe('shadowDOM - scrollable-region-focusable-matches', () => { + before(() => { if (!shadowSupported) { this.skip(); } }); - afterEach(function () { + afterEach(() => { axe._tree = undefined; }); - it('returns false when shadowDOM element does not overflow', function () { + it('returns false when shadowDOM element does not overflow', () => { fixture.innerHTML = '
'; const root = fixture.firstChild.attachShadow({ mode: 'open' }); @@ -169,7 +225,7 @@ describe('scrollable-region-focusable-matches', function () { assert.isFalse(actual); }); - it('returns true when shadowDOM element has overflow', function () { + it('returns true when shadowDOM element has overflow', () => { fixture.innerHTML = '
'; const root = fixture.firstChild.attachShadow({ mode: 'open' }); From 8d50a502db372ad75ba33c54677938de06ce77e1 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:46:41 -0600 Subject: [PATCH 2/5] revert playground --- test/playground.html | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/test/playground.html b/test/playground.html index bd6e9e2ccd..29b6d3837d 100644 --- a/test/playground.html +++ b/test/playground.html @@ -4,35 +4,7 @@
-
-
-
-
-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc - tincidunt nisi quis elit volutpat dignissim. Vivamus quis bibendum - nisl. Duis id imperdiet quam. Sed cursus elit condimentum lectus - viverra, quis molestie erat ullamcorper. Ut ut elit nulla. Fusce - fermentum aliquam augue, vitae blandit diam dignissim ut. Aliquam - feugiat velit tempor molestie tempor. Nunc placerat et ante id - imperdiet. Integer volutpat, tortor ut facilisis tincidunt, sapien - ex molestie metus, vel eleifend tortor sapien vitae elit. - Pellentesque vel tristique odio. Duis ante augue, luctus eget - eleifend ut, malesuada sit amet diam. Duis viverra blandit erat ac - ornare. Quisque ut auctor justo. -

-
-
-
-
+

Hello World

@@ -43,7 +15,7 @@ () => { axe.run( { - runOnly: 'scrollable-region-focusable', + runOnly: 'color-contrast', elementRef: true }, (err, results) => { From e78b2df010aed90f94094b7c296a3e0ac04c9540 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:46:59 -0600 Subject: [PATCH 3/5] revert change --- test/rule-matches/scrollable-region-focusable-matches.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/rule-matches/scrollable-region-focusable-matches.js b/test/rule-matches/scrollable-region-focusable-matches.js index 93ee0a21ca..5c9e252454 100644 --- a/test/rule-matches/scrollable-region-focusable-matches.js +++ b/test/rule-matches/scrollable-region-focusable-matches.js @@ -1,7 +1,7 @@ describe('scrollable-region-focusable-matches', () => { 'use strict'; - const fixture = document.querySelector('#fixture'); + const fixture = document.getElementById('fixture'); const queryFixture = axe.testUtils.queryFixture; const shadowSupported = axe.testUtils.shadowSupport.v1; const rule = axe.utils.getRule('scrollable-region-focusable'); From ca30641b3cc2878790d73aa893265764b53f4ef6 Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:04:09 -0600 Subject: [PATCH 4/5] fix --- .../scrollable-region-focusable.html | 10 +++------- .../scrollable-region-focusable.json | 9 ++++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html index ab58b1f354..02f9741c63 100644 --- a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html +++ b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.html @@ -36,19 +36,15 @@
-
+
-

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tincidunt nisi quis elit volutpat dignissim. Vivamus quis bibendum nisl. Duis id imperdiet quam. Sed cursus elit condimentum lectus diff --git a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json index 5453988ac7..50220b0bd8 100644 --- a/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json +++ b/test/integration/rules/scrollable-region-focusable/scrollable-region-focusable.json @@ -2,5 +2,12 @@ "description": "scrollable-region-focusable tests", "rule": "scrollable-region-focusable", "violations": [["#fail1"], ["#fail2"], ["#fail3"]], - "passes": [["#pass1"], ["#pass2"], ["#pass3"], ["#pass4"], ["#pass5"]] + "passes": [ + ["#pass1"], + ["#pass2"], + ["#pass3"], + ["#pass4"], + ["#pass5"], + ["#pass6"] + ] } From 18f5fd9ca2dbb566539c2ceda76c792f57d3de8a Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Tue, 23 Sep 2025 08:12:19 -0600 Subject: [PATCH 5/5] suggestion --- lib/rules/scrollable-region-focusable-matches.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/rules/scrollable-region-focusable-matches.js b/lib/rules/scrollable-region-focusable-matches.js index 00e910aca1..a9fff1f6fa 100644 --- a/lib/rules/scrollable-region-focusable-matches.js +++ b/lib/rules/scrollable-region-focusable-matches.js @@ -3,6 +3,8 @@ import isComboboxPopup from '../commons/aria/is-combobox-popup'; import sanitize from '../commons/text/sanitize'; import { querySelectorAll, getScroll } from '../core/utils'; +// magic number of allowed negligence if element goes outside scrolling area +// set to a ~1em past the scroll area. can modify if needed const buffer = 13; export default function scrollableRegionFocusableMatches(node, virtualNode) {