diff --git a/lib/commons/dom/visually-contains.js b/lib/commons/dom/visually-contains.js index c980ec1cb6..c46f8b0fdc 100644 --- a/lib/commons/dom/visually-contains.js +++ b/lib/commons/dom/visually-contains.js @@ -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} @@ -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; - 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; diff --git a/test/commons/dom/visually-contains.js b/test/commons/dom/visually-contains.js index 482dbf5764..a43f7bc3b8 100644 --- a/test/commons/dom/visually-contains.js +++ b/test/commons/dom/visually-contains.js @@ -129,6 +129,43 @@ describe('dom.visuallyContains', function() { assert.isTrue(result); }); + it('should return true for child with truncated text', function() { + var target = queryFixture( + '

' + + '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.' + + '

' + ); + 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( + '
' + + '
Some text
' + + '
' + ); + + 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( + '
' + + '
Some text
' + + '
' + ); + + 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() { diff --git a/test/integration/rules/color-contrast/color-contrast.html b/test/integration/rules/color-contrast/color-contrast.html index 2a0d3ed153..8d3c1d8ece 100644 --- a/test/integration/rules/color-contrast/color-contrast.html +++ b/test/integration/rules/color-contrast/color-contrast.html @@ -254,3 +254,22 @@ Hello World + +

+ + 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. + +

diff --git a/test/integration/rules/color-contrast/color-contrast.json b/test/integration/rules/color-contrast/color-contrast.json index a9724b8ea9..c661e1881c 100644 --- a/test/integration/rules/color-contrast/color-contrast.json +++ b/test/integration/rules/color-contrast/color-contrast.json @@ -11,7 +11,8 @@ ["#pass7"], ["#pass7 > input"], ["#pass8"], - ["#text-shadow-fg-pass"] + ["#text-shadow-fg-pass"], + ["#pass9"] ], "incomplete": [ ["#canttell1"],