Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions lib/rules/scrollable-region-focusable-matches.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,58 @@
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';

// 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) {
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you passing boundingRect in? You're also giving it virtualNode, which has that as a prop. Doesn't seem necessary.

);
}

function isNoneEmptyElement(vNode) {
return querySelectorAll(vNode, '*').some(elm =>
// (elm, noRecursion, ignoreAria)
hasContentVirtual(elm, true, true)
);
function hasScrollableContent(node, virtualNode, boundingRect) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can use the grid to do this. That seems like it'd be more efficient. Each scrollable element should have a subgrid, right? Why not look at the subgrid, find each element that actually extends beyond the visible boundary, and then check if A. that element has text that extends the boundary, or B. that element is a graphical object with a non-empty accessible text (i.e. it's not decorative).

Copy link
Contributor Author

@straker straker Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this would work easily. The grid was designed to do bottom-up lookups from a cell of a grid really easily, but it doesn't do top-down from a container very well. To do this we'd have to loop over every element within the grid, which requires looping over every cell of the grid, and then check if that element is itself a scroll container (checking elm._subgrid), and then start again and loop over every cell / element within the subgrid in order to find all elements to see if they have a text node or is a graphical object.

return querySelectorAll(virtualNode, '*').some(vNode => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to create a performance problem? I think its worth considering if there are other ways to do this. This may end up running a * query hundreds of times on a page.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it's the last part of the if statement, I think this should be fine. It'll only run if the other conditions are met first, plus this isn't doing anything it wasn't doing before.

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
Comment on lines +32 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only checking the right and bottom sides, which might be ok if we knew the scroll container was in its leftmost/topmost scroll position, but we aren't checking that. I think this should either:

  • check all 4 directions, or
  • identify which direction(s) the user could still scroll further in, and check only those directions

I could live with either. The latter might be better at ignoring the case where someone uses an element-hiding technique based on a negative x position, but it'd be more complicated and I could imagine it missing cases with nested different-direction scroll containers that we'd prefer it to match.

);
});
}

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];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is quite right:

  • I think there are situations where you'd want to include the whole bounding client rect even if text nodes are present (eg, visual content elements, and maybe elements that have CSS properties like background-image that might give meaning to the whole client rect)
  • I think there are situations where either a text node rect or the whole element might be hidden for reasons other than the scroll container, where we'd probably want to ignore it for the purposes of this matches

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, only looking at text here is going to miss problems.

}

function getContentRects(node) {
const range = document.createRange();
range.selectNodeContents(node);
return Array.from(range.getClientRects());
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
<input type="text" />
</div>

<div id="pass2" style="height: 200px; overflow-y: auto" tabindex="0">
<div style="height: 2000px">
<p>Content</p>
<div id="pass2" style="width: 200px; overflow-y: auto" tabindex="0">
<div>
<p style="width: 2000px">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium
doloremque laudantium.
</p>
</div>
</div>

Expand All @@ -16,21 +19,44 @@
<p style="height: 200px" tabindex="0"></p>
</div>

<div id="pass4" style="height: 200px; overflow-y: auto" contenteditable="true">
<div style="height: 2000px">
<p>Content</p>
<div id="pass4" style="height: 100px; overflow-y: auto" contenteditable="true">
<div style="width: 200px">
<p>
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.
</p>
</div>
</div>

<div id="pass5" style="height: 200px; overflow-y: auto" contenteditable="true">
<div style="height: 2000px">
<div
id="pass6"
style="height: 200px; overflow-y: auto"
style="height: 100px; overflow-y: auto"
contenteditable="invalid"
>
<div style="height: 2000px">
<p>Content</p>
<div>
<p style="width: 200px">
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.
</p>
</div>
</div>
</div>
Expand All @@ -47,9 +73,12 @@
<textarea tabindex="-1"></textarea>
</div>

<div id="fail3" style="height: 200px; overflow-y: auto" contenteditable="false">
<div style="height: 2000px">
<p>Content</p>
<div id="fail3" style="width: 100px; overflow-y: auto" contenteditable="false">
<div>
<p style="width: 2000px">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium
doloremque laudantium.
</p>
</div>
</div>

Expand Down Expand Up @@ -144,3 +173,7 @@
test
test
</textarea>

<div id="inapplicable14" style="width: 300px; overflow-y: auto">
<p style="width: 600px">Contents</p>
</div>
110 changes: 83 additions & 27 deletions test/rule-matches/scrollable-region-focusable-matches.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
describe('scrollable-region-focusable-matches', function () {
describe('scrollable-region-focusable-matches', () => {
'use strict';

const fixture = document.getElementById('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(
'<section id="target">This element is not scrollable</section>'
);
const actual = rule.matches(target.actualNode, target);
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(
'<div id="target" style="height: 200px; width: 200px;">' +
'<div style="display:none; height: 2000px; width: 100px;">' +
Expand All @@ -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(
'<div id="target" style="height: 200px; width: 200px; overflow: auto;">' +
'<div style="height: 10px; width: 100x;">Content</div>' +
Expand All @@ -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(
'<div id="target" style="height: 200px; width: 200px; overflow: hidden">' +
'<div style="height: 2000px; width: 100px; background-color: pink;">' +
Expand All @@ -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(
'<div id="target" style="height: 200px; width: 200px; overflow: auto">' +
'<div style="height: 10px; width: 2000px; background-color: red;">' +
Expand All @@ -61,101 +57,161 @@ describe('scrollable-region-focusable-matches', function () {
'</div>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns true when element has scrollable content (overflow=auto)', () => {
const target = queryFixture(
'<div id="target" style="height: 200px; width: 200px; overflow: auto">' +
'<div>' +
'<p style="width: 600px"> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium </p>' +
'</div>' +
'</div>'
);
const actual = rule.matches(target.actualNode, target);
assert.isTrue(actual);
});

it('returns false when element has content fully inside scroll area', () => {
const target = queryFixture(`
<div id="target" style="height: 200px; width: 200px; overflow: auto">
<div>
<img src="img.png" style="display: inline-block; width: 20px; height: 20px;">
</div>
</div>
`);
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(`
<div id="target" style="height: 200px; width: 200px; overflow: auto">
<div>
<img src="img.png" style="display: inline-block; width: 20px; height: 20px; margin-top: 185px;">
</div>
</div>
`);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns true when element has content partially outside scroll area', () => {
const target = queryFixture(`
<div id="target" style="height: 200px; width: 200px; overflow: auto">
<div>
<img src="img.png" style="display: inline-block; width: 20px; height: 20px; margin-top: 199px;">
</div>
</div>
`);
const actual = rule.matches(target.actualNode, target);
assert.isTrue(actual);
});

it('returns true when element has content fully outside scroll area', () => {
const target = queryFixture(`
<div id="target" style="height: 200px; width: 200px; overflow: auto">
<div>
<img src="img.png" style="display: inline-block; width: 20px; height: 20px; margin-top: 300px;">
</div>
</div>
`);
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(
'<p id="target" style="width: 12em; height: 2em; border: dotted; overflow: visible;">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns true when element overflow is scroll', function () {
it('returns true when element overflow is scroll', () => {
const target = queryFixture(
'<p id="target" style="width: 12em; height: 2em; border: dotted; overflow: scroll;">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>'
'<p id="target" style="width: 100px; height: 2em; border: dotted; overflow: scroll;">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>'
);
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(
'<div id="target" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><div style="height: 15rem"></div></div>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns false when element has combobox ancestor', function () {
it('returns false when element has combobox ancestor', () => {
const target = queryFixture(
'<div role="combobox"><ul id="target" role="listbox" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul></div>'
);
const actual = rule.matches(target.actualNode, target);
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(
'<input role="combobox" aria-owns="foo target"/><ul id="target" role="listbox" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul>'
);
const actual = rule.matches(target.actualNode, target);
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(
'<input role="combobox" aria-controls="foo target"/><ul id="target" role="listbox" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns false for combobox with tree', function () {
it('returns false for combobox with tree', () => {
const target = queryFixture(
'<div role="combobox"><ul id="target" role="tree" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul></div>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns false for combobox with grid', function () {
it('returns false for combobox with grid', () => {
const target = queryFixture(
'<div role="combobox"><ul id="target" role="grid" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul></div>'
);
const actual = rule.matches(target.actualNode, target);
assert.isFalse(actual);
});

it('returns false for combobox with dialog', function () {
it('returns false for combobox with dialog', () => {
const target = queryFixture(
'<div role="combobox"><ul id="target" role="dialog" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul></div>'
);
const actual = rule.matches(target.actualNode, target);
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(
'<div role="combobox"><ul id="target" role="section" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option" style="height: 15rem">Option</li></ul></div>'
'<div role="combobox"><ul id="target" role="section" style="width: 12em; height: 2em; border: dotted; overflow: scroll;"><li role="option">Option</li><li role="option">Option</li><li role="option">Option</li></ul></div>'
);
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 = '<div></div>';

const root = fixture.firstChild.attachShadow({ mode: 'open' });
Expand All @@ -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 = '<div></div>';

const root = fixture.firstChild.attachShadow({ mode: 'open' });
Expand Down