Skip to content

Commit

Permalink
fix(color-contrast): ignore aria-disabled labels (#2130)
Browse files Browse the repository at this point in the history
* fix(color-contrast): ignore aria-disabled labels

If an element is a descendent of an aria-labelledby reference, and any
of the elements referencing it has disabled, the label must be
ignored by the color-contrast rule.

* Apply suggestions from code review

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>

* linter stuff

* chore: update from feedback

* chore: fix CI failures

* Apply suggestions from code review

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>

* chore: fix buggy test

Co-authored-by: Steven Lambert <2433219+straker@users.noreply.github.com>
  • Loading branch information
WilcoFiers and straker authored Jul 16, 2020
1 parent cf11b64 commit e451b87
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 96 deletions.
1 change: 1 addition & 0 deletions lib/commons/forms/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { default as isAriaRange } from './is-aria-range';
export { default as isAriaTextbox } from './is-aria-textbox';
export { default as isNativeSelect } from './is-native-select';
export { default as isNativeTextbox } from './is-native-textbox';
export { default as isDisabled } from './is-disabled';
40 changes: 40 additions & 0 deletions lib/commons/forms/is-disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const disabledNodeNames = ['fieldset', 'button', 'select', 'input', 'textarea'];

/**
* Determines if an element disabled, or part of a disabled element
*
* IMPORANT: This method is fairly loose. There are significant differences in browsers of when
* they'll announce a thing disabled. This tells us if any accessibility supported browser
* identifies an element as disabled, but not if all of them do.
*
* @method isDisabled
* @memberof axe.commons.forms
* @param {VirtualNode} virtualNode
* @return {boolean} whether or not the element is disabled in some way
*/
function isDisabled(virtualNode) {
let disabledState = virtualNode._isDisabled;
if (typeof disabledState === 'boolean') {
return disabledState; // From cache
}

const { nodeName } = virtualNode.props;
const ariaDisabled = virtualNode.attr('aria-disabled');
if (disabledNodeNames.includes(nodeName) && virtualNode.hasAttr('disabled')) {
disabledState = true; // Native
} else if (ariaDisabled) {
// ARIA
disabledState = ariaDisabled.toLowerCase() === 'true';
} else if (virtualNode.parent) {
// Inherited
disabledState = isDisabled(virtualNode.parent);
} else {
// Default
disabledState = false;
}

virtualNode._isDisabled = disabledState;
return disabledState;
}

export default isDisabled;
172 changes: 76 additions & 96 deletions lib/rules/color-contrast-matches.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,46 @@
/* global document */
import { findUpVirtual, visuallyOverlaps, getRootNode } from '../commons/dom';
import { visibleVirtual, removeUnicode, sanitize } from '../commons/text';
import { isDisabled } from '../commons/forms';
import {
getNodeFromTree,
querySelectorAll,
escapeSelector
} from '../core/utils';

function colorContrastMatches(node, virtualNode) {
/* global document */
const { nodeName, type: inputType } = virtualNode.props;

const nodeName = node.nodeName.toUpperCase();
const nodeType = node.type;
// Don't test options, color contrast doesn't work well on these
if (nodeName === 'option') {
return false;
}
// Don't test empty select elements
if (nodeName === 'select' && !node.options.length) {
return false;
}

if (
node.getAttribute('aria-disabled') === 'true' ||
findUpVirtual(virtualNode, '[aria-disabled="true"]')
) {
// some input types don't have text, so the rule shouldn't be applied
const nonTextInput = [
'hidden',
'range',
'color',
'checkbox',
'radio',
'image'
];
if (nodeName === 'input' && nonTextInput.includes(inputType)) {
return false;
}

if (isDisabled(virtualNode)) {
return false;
}

// form elements that don't have direct child text nodes need to check that
// the text indent has not been changed and moved the text away from the
// control
const formElements = ['INPUT', 'SELECT', 'TEXTAREA'];
const formElements = ['input', 'select', 'textarea'];
if (formElements.includes(nodeName)) {
const style = window.getComputedStyle(node);
const textIndent = parseInt(style.getPropertyValue('text-indent'), 10);
Expand All @@ -43,109 +61,74 @@ function colorContrastMatches(node, virtualNode) {
return false;
}
}
// Match all form fields, regardless of if they have text
return true;
}

if (nodeName === 'INPUT') {
return (
['hidden', 'range', 'color', 'checkbox', 'radio', 'image'].indexOf(
nodeType
) === -1 && !node.disabled
);
}

if (nodeName === 'SELECT') {
return !!node.options.length && !node.disabled;
}

if (nodeName === 'TEXTAREA') {
return !node.disabled;
}

if (nodeName === 'OPTION') {
return false;
}

if (
(nodeName === 'BUTTON' && node.disabled) ||
findUpVirtual(virtualNode, 'button[disabled]')
) {
return false;
}

if (
(nodeName === 'FIELDSET' && node.disabled) ||
findUpVirtual(virtualNode, 'fieldset[disabled]')
) {
return false;
}

// check if the element is a label or label descendant for a disabled control
const nodeParentLabel = findUpVirtual(virtualNode, 'label');
if (nodeName === 'LABEL' || nodeParentLabel) {
let relevantNode = node;
let relevantVirtualNode = virtualNode;

if (nodeParentLabel) {
relevantNode = nodeParentLabel;
// we need an input candidate from a parent to account for label children
relevantVirtualNode = getNodeFromTree(nodeParentLabel);
}
// explicit label of disabled input
const doc = getRootNode(relevantNode);
let candidate =
relevantNode.htmlFor && doc.getElementById(relevantNode.htmlFor);
const candidateVirtualNode = getNodeFromTree(candidate);

if (
candidate &&
(candidate.disabled ||
candidate.getAttribute('aria-disabled') === 'true' ||
findUpVirtual(candidateVirtualNode, '[aria-disabled="true"]'))
) {
if (nodeName === 'label' || nodeParentLabel) {
let labelNode = nodeParentLabel || node;
let labelVirtual = nodeParentLabel
? getNodeFromTree(nodeParentLabel)
: virtualNode;

// explicit label of disabled control
const doc = getRootNode(labelNode);
const explicitControl = doc.getElementById(labelNode.htmlFor || '');
const explicitControlVirtual =
explicitControl && getNodeFromTree(explicitControl);

if (explicitControlVirtual && isDisabled(explicitControlVirtual)) {
return false;
}

candidate = querySelectorAll(
relevantVirtualNode,
// implicit label of disabled control
const query =
'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) {
':not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea';
const implicitControl = querySelectorAll(labelVirtual, query)[0];

if (implicitControl && isDisabled(implicitControl)) {
return false;
}
}

// label of disabled control associated w/ aria-labelledby
if (node.getAttribute('id')) {
const id = escapeSelector(node.getAttribute('id'));
const doc = getRootNode(node);
const candidate = doc.querySelector('[aria-labelledby~=' + id + ']');
if (candidate && candidate.disabled) {
return false;
const ariaLabelledbyControls = [];
let ancestorNode = virtualNode;
while (ancestorNode) {
// Find any ancestor (including itself) that is used with aria-labelledby
if (ancestorNode.props.id) {
const doc = getRootNode(node);
const escapedId = escapeSelector(ancestorNode.props.id);
const controls = Array.from(
doc.querySelectorAll(`[aria-labelledby~="${escapedId}"]`)
);
const virtualControls = controls.map(control => getNodeFromTree(control));

ariaLabelledbyControls.push(...virtualControls);
}
ancestorNode = ancestorNode.parent;
}

const visibleText = visibleVirtual(virtualNode, false, true);
if (
visibleText === '' ||
removeUnicode(visibleText, {
emoji: true,
nonBmp: false,
punctuations: true
}) === ''
ariaLabelledbyControls.length > 0 &&
ariaLabelledbyControls.every(isDisabled)
) {
return false;
}

const visibleText = visibleVirtual(virtualNode, false, true);
const removeUnicodeOptions = {
emoji: true,
nonBmp: false,
punctuations: true
};
if (!visibleText || !removeUnicode(visibleText, removeUnicodeOptions)) {
return false;
}

const range = document.createRange();
const childNodes = virtualNode.children;
let length = childNodes.length;
let child = null;
let index = 0;

for (index = 0; index < length; index++) {
child = childNodes[index];

for (let index = 0; index < childNodes.length; index++) {
let child = childNodes[index];
if (
child.actualNode.nodeType === 3 &&
sanitize(child.actualNode.nodeValue) !== ''
Expand All @@ -155,15 +138,12 @@ function colorContrastMatches(node, virtualNode) {
}

const rects = range.getClientRects();
length = rects.length;

for (index = 0; index < length; index++) {
for (let index = 0; index < rects.length; index++) {
//check to see if the rectangle impinges
if (visuallyOverlaps(rects[index], node)) {
return true;
}
}

return false;
}

Expand Down
Loading

0 comments on commit e451b87

Please sign in to comment.