Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(color-contrast): correctly compute color-contrast of truncated children #3203

Merged
merged 12 commits into from
Oct 18, 2021
154 changes: 82 additions & 72 deletions lib/commons/dom/visually-contains.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
import { getNodeFromTree, getScroll } from '../../core/utils';

/**
* Checks whether a parent element visually contains its child, either directly or via scrolling.
* Assumes that |parent| is an ancestor of |node|.
* @method visuallyContains
* @memberof axe.commons.dom
* @instance
* @param {Element} node
* @param {Element} parent
* @return {boolean} True if node is visually contained within parent
*/
export default function visuallyContains(node, parent) {
const parentScrollAncestor = getScrollAncestor(parent);

// if the elements share a common scroll parent, we can check if the
// parent visually contains the node. otherwise we need to check each
// scroll parent in between the node and the parent since if the
// element is off screen due to the scroll, it won't be visually contained
// by the parent
do {
const nextScrollAncestor = getScrollAncestor(node);

if (
nextScrollAncestor === parentScrollAncestor ||
nextScrollAncestor === parent
) {
return contains(node, parent);
}

node = nextScrollAncestor;
} while (node);

return false;
}

/**
* Return the ancestor node that is a scroll region.
* @param {VirtualNode}
Expand All @@ -19,95 +53,71 @@ function getScrollAncestor(node) {
}

/**
* Checks whether a parent element visually contains its child, either directly or via scrolling.
* Checks whether a parent element fully contains its child, either directly or via scrolling.
* Assumes that |parent| is an ancestor of |node|.
* @param {Element} node
* @param {Element} parent
* @return {boolean} True if node is visually contained within parent
*/
function contains(node, parent) {
const rectBound = node.getBoundingClientRect();
const margin = 0.01;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you remove the margin?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why do we need the margin?

Copy link
Contributor

Choose a reason for hiding this comment

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

Rounding errors

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If that's the case, I would rather use Math.ceil and Math.floor to handle rounding issues rather than use a magic number. It makes it way more clear what the intent is.

const rect = {
top: rectBound.top + margin,
bottom: rectBound.bottom - margin,
left: rectBound.left + margin,
right: rectBound.right - margin
};

const parentRect = parent.getBoundingClientRect();
const parentTop = parentRect.top;
const parentLeft = parentRect.left;
const parentScrollArea = {
top: parentTop - parent.scrollTop,
bottom: parentTop - parent.scrollTop + parent.scrollHeight,
left: parentLeft - parent.scrollLeft,
right: parentLeft - parent.scrollLeft + parent.scrollWidth
};

const style = window.getComputedStyle(parent);
const overflow = style.getPropertyValue('overflow');

// if parent element is inline, scrollArea will be too unpredictable
if (style.getPropertyValue('display') === 'inline') {
return true;
}

//In theory, we should just be able to look at the scroll area as a superset of the parentRect,
//but that's not true in Firefox
// use clientRects instead of boundingClientRect to account
// for truncation of text (one of the rects will be the size
// of the truncation)
// @see https://github.com/dequelabs/axe-core/issues/2669
const clientRects = Array.from(node.getClientRects());
// getBoundingClientRect prevents overrides of left/top
// (also can't destructure)
const boundingRect = parent.getBoundingClientRect();
const rect = {
left: boundingRect.left,
top: boundingRect.top,
width: boundingRect.width,
height: boundingRect.height
};

if (
(rect.left < parentScrollArea.left && rect.left < parentRect.left) ||
(rect.top < parentScrollArea.top && rect.top < parentRect.top) ||
(rect.right > parentScrollArea.right && rect.right > parentRect.right) ||
(rect.bottom > parentScrollArea.bottom && rect.bottom > parentRect.bottom)
['scroll', 'auto'].includes(overflow) ||
parent instanceof window.HTMLHtmlElement
) {
return false;
rect.width = parent.scrollWidth;
rect.height = parent.scrollHeight;
}

if (rect.right > parentRect.right || rect.bottom > parentRect.bottom) {
return (
style.overflow === 'scroll' ||
style.overflow === 'auto' ||
style.overflow === 'hidden' ||
parent instanceof window.HTMLBodyElement ||
parent instanceof window.HTMLHtmlElement
);
// in Chrome text truncation on the parent will cause the
// child to have multiple client rects (one for the bounding
// rect of the element and one more for the bounding rect of
// the truncation). however this doesn't happen for other
// browsers so we'll make it so that if we detect text
// truncation and there's only one client rect, we'll use
// the bounding rect of the parent as the client rect of
// the child
if (
clientRects.length === 1 &&
overflow === 'hidden' &&
style.getPropertyValue('white-space') === 'nowrap'
) {
clientRects[0] = rect;
}

return true;
// check if any client rect is fully inside the parent rect
// @see https://gist.github.com/Daniel-Hug/d7984d82b58d6d2679a087d896ca3d2b
return clientRects.some(
clientRect =>
!(
Math.ceil(clientRect.left) < Math.floor(rect.left) ||
Math.ceil(clientRect.top) < Math.floor(rect.top) ||
Math.floor(clientRect.left + clientRect.width) >
Math.ceil(rect.left + rect.width) ||
Math.floor(clientRect.top + clientRect.height) >
Math.ceil(rect.top + rect.height)
)
);
}

/**
* Checks whether a parent element visually contains its child, either directly or via scrolling.
* Assumes that |parent| is an ancestor of |node|.
* @method visuallyContains
* @memberof axe.commons.dom
* @instance
* @param {Element} node
* @param {Element} parent
* @return {boolean} True if node is visually contained within parent
*/
function visuallyContains(node, parent) {
const parentScrollAncestor = getScrollAncestor(parent);

// if the elements share a common scroll parent, we can check if the
// parent visually contains the node. otherwise we need to check each
// scroll parent in between the node and the parent since if the
// element is off screen due to the scroll, it won't be visually contained
// by the parent
do {
const nextScrollAncestor = getScrollAncestor(node);

if (
nextScrollAncestor === parentScrollAncestor ||
nextScrollAncestor === parent
) {
return contains(node, parent);
}

node = nextScrollAncestor;
} while (node);

return false;
}

export default visuallyContains;
37 changes: 37 additions & 0 deletions test/commons/dom/visually-contains.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,43 @@ describe('dom.visuallyContains', function() {
assert.isTrue(result);
});

it('should return true for child with truncated text', function() {
var target = queryFixture(
'<p style="max-width: 200px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">' +
'<span id="target">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et sollicitudin quam. Fuscemi odio, egestas pulvinar erat eget, vehicula tempus est. Proin vitae ullamcorper velit. Donec sagittis est justo, mattis iaculis arcu facilisis id. Proin pulvinar ornare arcu a fermentum. Quisque et dignissim nulla,sit amet consectetur ipsum. Donec in libero porttitor, dapibus neque imperdiet, aliquam est. Vivamus blandit volutpat fringilla. In mi magna, mollis sit amet imperdiet eu, rutrum ut tellus. Mauris vel condimentum nibh, quis ultricies nisi. Vivamus accumsan quam mauris, id iaculis quam fringilla ac. Curabitur pulvinar dolor ac magna vehicula, non auctor ligula dignissim. Nam ac nibh porttitor, malesuada tortor varius, feugiat turpis. Mauris dapibus, tellus ut viverra porta, ipsum turpis bibendum ligula, at tempor felis ante non libero.</span>' +
'</p>'
);
var result = axe.commons.dom.visuallyContains(
target.actualNode,
target.parent.actualNode
);
assert.isTrue(result);
});

it('should return false if element is outside overflow hidden', function() {
var target = queryFixture(
'<div id="parent" style="width: 200px; height: 200px; overflow: hidden;">' +
'<div id="target" style="margin-top: 300px;">Some text</div>' +
'</div>'
);

var parent = fixture.querySelector('#parent');
var result = axe.commons.dom.visuallyContains(target.actualNode, parent);
assert.isFalse(result);
});

it('should allow subpixel contains due to rounding', function() {
var target = queryFixture(
'<div id="parent" style="width: 200px; height: 200px;">' +
'<div id="target" style="margin-left: -0.1px; margin-top: -0.9px; width: 200.5px; height: 200.9px">Some text</div>' +
'</div>'
);

var parent = fixture.querySelector('#parent');
var result = axe.commons.dom.visuallyContains(target.actualNode, parent);
assert.isTrue(result);
});

(shadowSupported ? it : xit)(
'should return true when element is visually contained across shadow boundary',
function() {
Expand Down
19 changes: 19 additions & 0 deletions test/integration/rules/color-contrast/color-contrast.html
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,22 @@
Hello World
</div>
</div>

<p
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap; max-width: 200px; background-color: #FFF"
>
<span id="pass9" style="color: #000">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et sollicitudin
quam. Fusce mi odio, egestas pulvinar erat eget, vehicula tempus est. Proin
vitae ullamcorper velit. Donec sagittis est justo, mattis iaculis arcu
facilisis id. Proin pulvinar ornare arcu a fermentum. Quisque et dignissim
nulla, sit amet consectetur ipsum. Donec in libero porttitor, dapibus neque
imperdiet, aliquam est. Vivamus blandit volutpat fringilla. In mi magna,
mollis sit amet imperdiet eu, rutrum ut tellus. Mauris vel condimentum nibh,
quis ultricies nisi. Vivamus accumsan quam mauris, id iaculis quam fringilla
ac. Curabitur pulvinar dolor ac magna vehicula, non auctor ligula dignissim.
Nam ac nibh porttitor, malesuada tortor varius, feugiat turpis. Mauris
dapibus, tellus ut viverra porta, ipsum turpis bibendum ligula, at tempor
felis ante non libero.
</span>
</p>
3 changes: 2 additions & 1 deletion test/integration/rules/color-contrast/color-contrast.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
["#pass7"],
["#pass7 > input"],
["#pass8"],
["#text-shadow-fg-pass"]
["#text-shadow-fg-pass"],
["#pass9"]
],
"incomplete": [
["#canttell1"],
Expand Down