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.
+
+ 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(
'
'
);
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(
'
Option
'
);
@@ -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.
-