Skip to content

Commit

Permalink
fix(label): work with virtual nodes (dequelabs#2354)
Browse files Browse the repository at this point in the history
* fix(label): work with virtual nodes

* last two checks

* Update lib/commons/text/label-virtual.js

Co-authored-by: Wilco Fiers <[email protected]>

* integration

* finalize

* fix tests

Co-authored-by: Wilco Fiers <[email protected]>
  • Loading branch information
straker and WilcoFiers committed Jul 20, 2020
1 parent 3eb6d2c commit 44b033c
Show file tree
Hide file tree
Showing 28 changed files with 516 additions and 155 deletions.
28 changes: 16 additions & 12 deletions lib/checks/label/explicit-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,26 @@ import { getRootNode, isVisible } from '../../commons/dom';
import { accessibleText } from '../../commons/text';
import { escapeSelector } from '../../core/utils';

function explicitEvaluate(node) {
if (node.getAttribute('id')) {
const root = getRootNode(node);
const id = escapeSelector(node.getAttribute('id'));
const label = root.querySelector(`label[for="${id}"]`);
function explicitEvaluate(node, options, virtualNode) {
try {
if (virtualNode.attr('id')) {
const root = getRootNode(virtualNode.actualNode);
const id = escapeSelector(virtualNode.attr('id'));
const label = root.querySelector(`label[for="${id}"]`);

if (label) {
// defer to hidden-explicit-label check for better messaging
if (!isVisible(label)) {
return true;
} else {
return !!accessibleText(label);
if (label) {
// defer to hidden-explicit-label check for better messaging
if (!isVisible(label)) {
return true;
} else {
return !!accessibleText(label);
}
}
}
return false;
} catch (e) {
return undefined;
}
return false;
}

export default explicitEvaluate;
3 changes: 2 additions & 1 deletion lib/checks/label/explicit.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"impact": "critical",
"messages": {
"pass": "Form element has an explicit <label>",
"fail": "Form element does not have an explicit <label>"
"fail": "Form element does not have an explicit <label>",
"incomplete": "Unable to determine if form element has an explicit <label>"
}
}
}
22 changes: 13 additions & 9 deletions lib/checks/label/hidden-explicit-label-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ import { accessibleTextVirtual } from '../../commons/text';
import { escapeSelector } from '../../core/utils';

function hiddenExplicitLabelEvaluate(node, options, virtualNode) {
if (node.getAttribute('id')) {
const root = getRootNode(node);
const id = escapeSelector(node.getAttribute('id'));
const label = root.querySelector(`label[for="${id}"]`);
try {
if (virtualNode.hasAttr('id')) {
const root = getRootNode(node);
const id = escapeSelector(node.getAttribute('id'));
const label = root.querySelector(`label[for="${id}"]`);

if (label && !isVisible(label, true)) {
const name = accessibleTextVirtual(virtualNode).trim();
const isNameEmpty = name === '';
return isNameEmpty;
if (label && !isVisible(label, true)) {
const name = accessibleTextVirtual(virtualNode).trim();
const isNameEmpty = name === '';
return isNameEmpty;
}
}
return false;
} catch (e) {
return undefined;
}
return false;
}

export default hiddenExplicitLabelEvaluate;
3 changes: 2 additions & 1 deletion lib/checks/label/hidden-explicit-label.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"impact": "critical",
"messages": {
"pass": "Form element has a visible explicit <label>",
"fail": "Form element has explicit <label> that is hidden"
"fail": "Form element has explicit <label> that is hidden",
"incomplete": "Unable to determine if form element has explicit <label> that is hidden"
}
}
}
16 changes: 10 additions & 6 deletions lib/checks/label/implicit-evaluate.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { findUpVirtual } from '../../commons/dom';
import { accessibleText } from '../../commons/text';
import { closest } from '../../core/utils';
import { accessibleTextVirtual } from '../../commons/text';

function implicitEvaluate(node, options, virtualNode) {
const label = findUpVirtual(virtualNode, 'label');
if (label) {
return !!accessibleText(label, { inControlContext: true });
try {
const label = closest(virtualNode, 'label');
if (label) {
return !!accessibleTextVirtual(label, { inControlContext: true });
}
return false;
} catch (e) {
return undefined;
}
return false;
}

export default implicitEvaluate;
3 changes: 2 additions & 1 deletion lib/checks/label/implicit.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"impact": "critical",
"messages": {
"pass": "Form element has an implicit (wrapped) <label>",
"fail": "Form element does not have an implicit (wrapped) <label>"
"fail": "Form element does not have an implicit (wrapped) <label>",
"incomplete": "Unable to determine if form element has an implicit (wrapped} <label>"
}
}
}
10 changes: 5 additions & 5 deletions lib/commons/aria/label-virtual.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import sanitize from '../text/sanitize';
* @method labelVirtual
* @memberof axe.commons.aria
* @instance
* @param {Object} actualNode The virtualNode to test
* @param {VirtualNode} virtualNode The virtualNode to test
* @return {Mixed} String of visible text, or `null` if no label is found
*/
function labelVirtual({ actualNode }) {
function labelVirtual(virtualNode) {
let ref, candidate;

if (actualNode.getAttribute('aria-labelledby')) {
if (virtualNode.attr('aria-labelledby')) {
// aria-labelledby
ref = idrefs(actualNode, 'aria-labelledby');
ref = idrefs(virtualNode.actualNode, 'aria-labelledby');
candidate = ref
.map(function(thing) {
// TODO: es-module-utils.getNodeFromTree
Expand All @@ -32,7 +32,7 @@ function labelVirtual({ actualNode }) {
}

// aria-label
candidate = actualNode.getAttribute('aria-label');
candidate = virtualNode.attr('aria-label');
if (candidate) {
candidate = sanitize(candidate).trim();
if (candidate) {
Expand Down
7 changes: 6 additions & 1 deletion lib/commons/dom/is-visible.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ function isAreaVisible(el, screenReader, recursed) {
* @return {Boolean} The element's visibilty status
*/
function isVisible(el, screenReader, recursed) {
'use strict';
if (!el) {
throw new TypeError(
'Cannot determine if element is visible for non-DOM nodes'
);
}

const vNode = getNodeFromTree(el);
const cacheName = '_isVisible' + (screenReader ? 'ScreenReader' : '');

Expand Down
13 changes: 8 additions & 5 deletions lib/commons/standards/implicit-html-roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,14 @@ const implicitHtmlRoles = {
},
input: vNode => {
// Source: https://www.w3.org/TR/html52/sec-forms.html#suggestions-source-element
const listElement = idrefs(vNode.actualNode, 'list').filter(
node => !!node
)[0];
const suggestionsSourceElement =
listElement && listElement.nodeName.toLowerCase() === 'datalist';
let suggestionsSourceElement;
if (vNode.hasAttr('list')) {
const listElement = idrefs(vNode.actualNode, 'list').filter(
node => !!node
)[0];
suggestionsSourceElement =
listElement && listElement.nodeName.toLowerCase() === 'datalist';
}

switch ((vNode.attr('type') || '').toLowerCase()) {
case 'button':
Expand Down
77 changes: 46 additions & 31 deletions lib/commons/text/form-control-value.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import isAriaCombobox from '../forms/is-aria-combobox';
import isAriaRange from '../forms/is-aria-range';
import getOwnedVirtual from '../aria/get-owned-virtual';
import isHiddenWithCSS from '../dom/is-hidden-with-css';
import AbstractVirtualNode from '../../core/base/virtual-node/abstract-virtual-node';
import { getNodeFromTree, querySelectorAll } from '../../core/utils';

const controlValueRoles = [
'textbox',
Expand Down Expand Up @@ -80,9 +82,11 @@ function formControlValue(virtualNode, context = {}) {
* @return {string} The calculated value
*/
function nativeTextboxValue(node) {
node = node.actualNode || node;
if (isNativeTextbox(node)) {
return node.value || '';
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);

if (isNativeTextbox(vNode)) {
return vNode.props.value || '';
}
return '';
}
Expand All @@ -94,16 +98,22 @@ function nativeTextboxValue(node) {
* @return {string} The calculated value
*/
function nativeSelectValue(node) {
node = node.actualNode || node;
if (!isNativeSelect(node)) {
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);

if (!isNativeSelect(vNode)) {
return '';
}
return (
Array.from(node.options)
.filter(option => option.selected)
.map(option => option.text)
.join(' ') || ''
);

const options = querySelectorAll(vNode, 'option');
const selectedOptions = options.filter(option => option.hasAttr('selected'));

// browser automatically selects the first option
if (!selectedOptions.length) {
selectedOptions.push(options[0]);
}

return selectedOptions.map(option => visibleVirtual(option)).join(' ') || '';
}

/**
Expand All @@ -112,13 +122,16 @@ function nativeSelectValue(node) {
* @param {VirtualNode} element The VirtualNode instance whose value we want
* @return {string} The calculated value
*/
function ariaTextboxValue(virtualNode) {
const { actualNode } = virtualNode;
if (!isAriaTextbox(actualNode)) {
function ariaTextboxValue(node) {
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);

let { actualNode } = vNode;
if (!isAriaTextbox(vNode)) {
return '';
}
if (!isHiddenWithCSS(actualNode)) {
return visibleVirtual(virtualNode, true);
if (!actualNode || (actualNode && !isHiddenWithCSS(actualNode))) {
return visibleVirtual(vNode, true);
} else {
return actualNode.textContent;
}
Expand All @@ -134,16 +147,17 @@ function ariaTextboxValue(virtualNode) {
* @property {Bool} debug Enable logging for formControlValue
* @return {string} The calculated value
*/
function ariaListboxValue(virtualNode, context) {
const { actualNode } = virtualNode;
if (!isAriaListbox(actualNode)) {
function ariaListboxValue(node, context) {
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);

if (!isAriaListbox(vNode)) {
return '';
}

const selected = getOwnedVirtual(virtualNode).filter(
const selected = getOwnedVirtual(vNode).filter(
owned =>
getRole(owned) === 'option' &&
owned.actualNode.getAttribute('aria-selected') === 'true'
getRole(owned) === 'option' && owned.attr('aria-selected') === 'true'
);

if (selected.length === 0) {
Expand All @@ -165,17 +179,16 @@ function ariaListboxValue(virtualNode, context) {
* @property {Bool} debug Enable logging for formControlValue
* @return {string} The calculated value
*/
function ariaComboboxValue(virtualNode, context) {
const { actualNode } = virtualNode;
function ariaComboboxValue(node, context) {
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);
let listbox;

// For combobox, find the first owned listbox:
if (!isAriaCombobox(actualNode)) {
if (!isAriaCombobox(vNode)) {
return '';
}
listbox = getOwnedVirtual(virtualNode).filter(
elm => getRole(elm) === 'listbox'
)[0];
listbox = getOwnedVirtual(vNode).filter(elm => getRole(elm) === 'listbox')[0];

return listbox ? ariaListboxValue(listbox, context) : '';
}
Expand All @@ -187,13 +200,15 @@ function ariaComboboxValue(virtualNode, context) {
* @return {string} The calculated value
*/
function ariaRangeValue(node) {
node = node.actualNode || node;
if (!isAriaRange(node) || !node.hasAttribute('aria-valuenow')) {
const vNode =
node instanceof AbstractVirtualNode ? node : getNodeFromTree(node);

if (!isAriaRange(vNode) || !vNode.hasAttr('aria-valuenow')) {
return '';
}
// Validate the number, if not, return 0.
// Chrome 70 typecasts this, Firefox 62 does not
const valueNow = +node.getAttribute('aria-valuenow');
const valueNow = +vNode.attr('aria-valuenow');
return !isNaN(valueNow) ? String(valueNow) : '0';
}

Expand Down
24 changes: 15 additions & 9 deletions lib/commons/text/label-virtual.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import ariaLabelVirtual from '../aria/label-virtual';
import visible from './visible';
import visibleVirtual from './visible-virtual';
import getRootNode from '../dom/get-root-node';
import findUpVirtual from '../dom/find-up-virtual';
import { closest, escapeSelector } from '../../core/utils';

/**
* Gets the visible text of a label for a given input
Expand All @@ -12,28 +13,33 @@ import findUpVirtual from '../dom/find-up-virtual';
* @param {VirtualNode} node The virtual node mapping to the input to test
* @return {Mixed} String of visible text, or `null` if no label is found
*/
function labelVirtual(node) {
function labelVirtual(virtualNode) {
var ref, candidate, doc;

candidate = ariaLabelVirtual(node);
candidate = ariaLabelVirtual(virtualNode);
if (candidate) {
return candidate;
}

// explicit label
if (node.actualNode.id) {
// TODO: es-module-utils.escapeSelector
const id = axe.utils.escapeSelector(node.actualNode.getAttribute('id'));
doc = getRootNode(node.actualNode);
if (virtualNode.attr('id')) {
if (!virtualNode.actualNode) {
throw new TypeError(
'Cannot resolve explicit label reference for non-DOM nodes'
);
}

const id = escapeSelector(virtualNode.attr('id'));
doc = getRootNode(virtualNode.actualNode);
ref = doc.querySelector('label[for="' + id + '"]');
candidate = ref && visible(ref, true);
if (candidate) {
return candidate;
}
}

ref = findUpVirtual(node, 'label');
candidate = ref && visible(ref, true);
ref = closest(virtualNode, 'label');
candidate = ref && visibleVirtual(ref, true);
if (candidate) {
return candidate;
}
Expand Down
Loading

0 comments on commit 44b033c

Please sign in to comment.