diff --git a/lib/checks/color/color-contrast.json b/lib/checks/color/color-contrast.json index d552736531..7787be990f 100644 --- a/lib/checks/color/color-contrast.json +++ b/lib/checks/color/color-contrast.json @@ -13,6 +13,8 @@ "bgOverlap": "Element's background color could not be determined because it is overlapped by another element", "fgAlpha" : "Element's foreground color could not be determined because of alpha transparency", "elmPartiallyObscured": "Element's background color could not be determined because it's partially obscured by another element", + "elmPartiallyObscuring": "Element's background color could not be determined because it partially overlaps other elements", + "outsideViewport": "Element's background color could not be determined because it's outside the viewport", "equalRatio": "Element has a 1:1 contrast ratio with the background", "default": "Unable to determine contrast ratio" } diff --git a/lib/commons/color/get-background-color.js b/lib/commons/color/get-background-color.js index e63140c085..a4a9593712 100644 --- a/lib/commons/color/get-background-color.js +++ b/lib/commons/color/get-background-color.js @@ -178,17 +178,15 @@ function sortPageBackground(elmStack) { } return bgNodes; } - /** - * Get all elements rendered underneath the current element, In the order they are displayed (front to back) - * @method getBackgroundStack + * Get coordinates for an element's client rects or bounding client rect + * @method getCoords * @memberof axe.commons.color * @instance - * @param {Element} elm - * @return {Array} + * @param {DOMRect} rect + * @return {Object} */ -color.getBackgroundStack = function(elm) { - let rect = elm.getBoundingClientRect(); +color.getCoords = function(rect) { let x, y; if (rect.left > window.innerWidth) { return; @@ -203,7 +201,94 @@ color.getBackgroundStack = function(elm) { Math.ceil(rect.top + (rect.height / 2)), window.innerHeight - 1); - let elmStack = Array.from(document.elementsFromPoint(x, y)); + return {x, y}; +}; +/** + * Get elements from point for block and inline elements, excluding line breaks + * @method getRectStack + * @memberof axe.commons.color + * @instance + * @param {Element} elm + * @return {Array} + */ +color.getRectStack = function(elm) { + let boundingCoords = color.getCoords(elm.getBoundingClientRect()); + if (boundingCoords) { + // allows inline elements spanning multiple lines to be evaluated + let rects = Array.from(elm.getClientRects()); + let boundingStack = Array.from(document.elementsFromPoint(boundingCoords.x, boundingCoords.y)); + if (rects && rects.length > 1) { + let filteredArr = rects.filter((rect) => { + // exclude manual line breaks in Chrome/Safari + return rect.width && rect.width > 0; + }) + .map((rect) => { + let coords = color.getCoords(rect); + if (coords) { + return Array.from(document.elementsFromPoint(coords.x, coords.y)); + } + }); + // add bounding client rect stack for comparison later + filteredArr.splice(0, 0, boundingStack); + return filteredArr; + } else { + return [boundingStack]; + } + } + return null; +}; +/** + * Get filtered stack of block and inline elements, excluding line breaks + * @method filteredRectStack + * @memberof axe.commons.color + * @instance + * @param {Element} elm + * @return {Array} + */ +color.filteredRectStack = function(elm) { + let rectStack = color.getRectStack(elm); + if (rectStack && rectStack.length === 1) { + // default case, elm.getBoundingClientRect() + return rectStack[0]; + } else if (rectStack && rectStack.length > 1) { + let boundingStack = rectStack.shift(); + let isSame; + // iterating over arrays of DOMRects + rectStack.forEach((rectList, index) => { + if (index === 0) { return; } + // if the stacks are the same, use the first one. otherwise, return null. + let rectA = rectStack[index - 1], + rectB = rectStack[index]; + + // if elements in clientRects are the same + // or the boundingClientRect contains the differing element, pass it + isSame = rectA.every(function(element, elementIndex) { + return element === rectB[elementIndex]; + }) || boundingStack.includes(elm); + }); + if (!isSame) { + axe.commons.color.incompleteData.set('bgColor', 'elmPartiallyObscuring'); + return null; + } + // pass the first stack if it wasn't partially covered + return rectStack[0]; + } else { + // rect outside of viewport + axe.commons.color.incompleteData.set('bgColor', 'outsideViewport'); + return null; + } +}; +/** + * Get all elements rendered underneath the current element, In the order they are displayed (front to back) + * @method getBackgroundStack + * @memberof axe.commons.color + * @instance + * @param {Element} elm + * @return {Array} + */ +color.getBackgroundStack = function(elm) { + let elmStack = color.filteredRectStack(elm); + if (elmStack === null) { return null; } elmStack = includeMissingElements(elmStack, elm); elmStack = dom.reduceToElementsBelowFloating(elmStack, elm); elmStack = sortPageBackground(elmStack); diff --git a/lib/rules/color-contrast-matches.js b/lib/rules/color-contrast-matches.js index 6377556636..7fbf47be76 100644 --- a/lib/rules/color-contrast-matches.js +++ b/lib/rules/color-contrast-matches.js @@ -35,8 +35,12 @@ if (nodeName === 'FIELDSET' && node.disabled || axe.commons.dom.findUp(node, 'fi var nodeParentLabel = axe.commons.dom.findUp(node, 'label'); if (nodeName === 'LABEL' || nodeParentLabel) { var relevantNode = node; + var relevantVirtualNode = virtualNode; + if (nodeParentLabel) { relevantNode = nodeParentLabel; + // we need an input candidate from a parent to account for label children + relevantVirtualNode = axe.utils.getNodeFromTree(axe._tree[0], nodeParentLabel); } // explicit label of disabled input let doc = axe.commons.dom.getRootNode(relevantNode); @@ -45,7 +49,7 @@ if (nodeName === 'LABEL' || nodeParentLabel) { return false; } - var candidate = axe.utils.querySelectorAll(virtualNode, 'input:not([type="hidden"]):not([type="image"])' + + var candidate = axe.utils.querySelectorAll(relevantVirtualNode, 'input:not([type="hidden"]):not([type="image"])' + ':not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea'); if (candidate.length && candidate[0].actualNode.disabled) { return false; diff --git a/test/checks/color/color-contrast.js b/test/checks/color/color-contrast.js index 0e03b6e7b0..fc848c3c3c 100644 --- a/test/checks/color/color-contrast.js +++ b/test/checks/color/color-contrast.js @@ -95,6 +95,33 @@ describe('color-contrast', function () { assert.deepEqual(checkContext._relatedNodes, []); }); + it('should return true for inline elements with sufficient contrast spanning multiple lines', function () { + fixture.innerHTML = '

Text oh heyyyy and here\'s
a link

'; + var target = fixture.querySelector('#target'); + if (window.PHANTOMJS) { + assert.ok('PhantomJS is a liar'); + } else { + assert.isTrue(checks['color-contrast'].evaluate.call(checkContext, target)); + assert.deepEqual(checkContext._relatedNodes, []); + } + }); + + it('should return undefined for inline elements spanning multiple lines that are overlapped', function () { + fixture.innerHTML = '
' + + '

Text oh heyyyy and here\'s
a link

'; + var target = fixture.querySelector('#target'); + assert.isUndefined(checks['color-contrast'].evaluate.call(checkContext, target)); + assert.deepEqual(checkContext._relatedNodes, []); + }); + + it('should return true for inline elements with sufficient contrast', function () { + fixture.innerHTML = '

Text oh heyyyy and here\'s bold text

'; + var target = fixture.querySelector('#target'); + var result = checks['color-contrast'].evaluate.call(checkContext, target); + assert.isTrue(result); + assert.deepEqual(checkContext._relatedNodes, []); + }); + it('should return false when there is not sufficient contrast', function () { fixture.innerHTML = '
' + 'My text
'; @@ -182,8 +209,12 @@ describe('color-contrast', function () { fixture.innerHTML = ''; var target = fixture.querySelector('#target'); - var result = checks['color-contrast'].evaluate.call(checkContext, target); - assert.isTrue(result); + if (window.PHANTOMJS) { + assert.ok('PhantomJS is a liar'); + } else { + var result = checks['color-contrast'].evaluate.call(checkContext, target); + assert.isTrue(result); + } }); it('should return true when a label wraps a text input but doesn\'t overlap', function () { diff --git a/test/commons/color/get-background-color.js b/test/commons/color/get-background-color.js index 35723c2f3b..f6f1c6aad4 100644 --- a/test/commons/color/get-background-color.js +++ b/test/commons/color/get-background-color.js @@ -192,6 +192,32 @@ describe('color.getBackgroundColor', function () { assert.deepEqual(bgNodes, [target]); }); + it('should return a bgcolor for a multiline inline element fully covering the background', function () { + fixture.innerHTML = '
' + + '
' + + '

Text oh heyyyy and here\'s
a link

' + + '
'; + var actual = axe.commons.color.getBackgroundColor(document.getElementById('target'), []); + if (window.PHANTOMJS) { + assert.ok('PhantomJS is a liar'); + } else { + assert.isNotNull(actual); + assert.equal(Math.round(actual.blue), 0); + assert.equal(Math.round(actual.red), 0); + assert.equal(Math.round(actual.green), 0); + } + }); + + it('should return null if a multiline inline element does not fully cover background', function () { + fixture.innerHTML = '
' + + '
' + + '

Text oh heyyyy and here\'s
a link

' + + '
'; + var actual = axe.commons.color.getBackgroundColor(document.getElementById('target'), []); + assert.isNull(actual); + assert.equal(axe.commons.color.incompleteData.get('bgColor'), 'elmPartiallyObscuring'); + }); + it('should count a TR as a background element for TD', function () { fixture.innerHTML = '
' + '' + diff --git a/test/integration/rules/color-contrast/color-contrast.html b/test/integration/rules/color-contrast/color-contrast.html index 4bf4e3d40d..0f24df2826 100644 --- a/test/integration/rules/color-contrast/color-contrast.html +++ b/test/integration/rules/color-contrast/color-contrast.html @@ -8,11 +8,6 @@
Pass.
- -