Skip to content
Open
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
49 changes: 42 additions & 7 deletions lib/rules/scrollable-region-focusable-matches.js
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Member

Choose a reason for hiding this comment

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

What is this magic number?


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());
}
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