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

shadowDOM accessible text calculation #420

Merged
merged 7 commits into from
Jul 17, 2017
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/commons/dom/find-elms-in-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* global axe, dom */
/**
* Find elements referenced from a given context
*
* @param object {
* context: Node | virtual node Element in the same context
* value: String attribute value to search for
* attr: String attribute name to search for
* elm: String ndoeName to search for (optional)
* }
* @return Array[Node]
*/
dom.findElmsInContext = function ({ context, value, attr, elm = '' }) {
let root;
context = context.actualNode || context;
const escapedValue = axe.utils.escapeSelector(value);

if (context.nodeType === 9 || context.nodeType === 11) { // It's already root
root = context;
} else {
root = dom.getRootNode(context);
}
return Array.from(
root.querySelectorAll(elm + '[' + attr + '=' + escapedValue + ']')
);
};
174 changes: 95 additions & 79 deletions lib/commons/text/accessible-text.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*global text, dom, aria, axe */
/*jshint maxstatements: 25, maxcomplexity: 19 */
/*jshint maxstatements: 27, maxcomplexity: 19 */

var defaultButtonValues = {
submit: 'Submit',
Expand All @@ -19,34 +19,34 @@ var phrasingElements = ['A', 'EM', 'STRONG', 'SMALL', 'MARK', 'ABBR', 'DFN', 'I'
* @param {HTMLElement} element The HTMLElement
* @return {HTMLElement} The label element, or null if none is found
*/
function findLabel(element) {
var ref = null;
if (element.id) {
ref = document.querySelector('label[for="' + axe.utils.escapeSelector(element.id) + '"]');
if (ref) {
return ref;
}
function findLabel({ actualNode }) {
let label;
if (actualNode.id) {
label = dom.findElmsInContext({
elm: 'label', attr: 'for', value: actualNode.id, context: actualNode
})[0];
} else {
label = dom.findUp(actualNode, 'label');
}
ref = dom.findUp(element, 'label');
return ref;
return axe.utils.getNodeFromTree(axe._tree[0], label);
}

function isButton(element) {
return ['button', 'reset', 'submit'].indexOf(element.type) !== -1;
function isButton({ actualNode }) {
return ['button', 'reset', 'submit'].includes(actualNode.type.toLowerCase());
}

function isInput(element) {
var nodeName = element.nodeName.toUpperCase();
function isInput({ actualNode }) {
var nodeName = actualNode.nodeName.toUpperCase();
return (nodeName === 'TEXTAREA' || nodeName === 'SELECT') ||
(nodeName === 'INPUT' && element.type.toLowerCase() !== 'hidden');
(nodeName === 'INPUT' && actualNode.type.toLowerCase() !== 'hidden');
}

function shouldCheckSubtree(element) {
return ['BUTTON', 'SUMMARY', 'A'].indexOf(element.nodeName.toUpperCase()) !== -1;
function shouldCheckSubtree({ actualNode }) {
return ['BUTTON', 'SUMMARY', 'A'].includes(actualNode.nodeName.toUpperCase());
}

function shouldNeverCheckSubtree(element) {
return ['TABLE', 'FIGURE'].indexOf(element.nodeName.toUpperCase()) !== -1;
function shouldNeverCheckSubtree({ actualNode }) {
return ['TABLE', 'FIGURE'].includes(actualNode.nodeName.toUpperCase());
}

/**
Expand All @@ -55,19 +55,18 @@ function shouldNeverCheckSubtree(element) {
* @param {HTMLElement} element The HTMLElement
* @return {string} The calculated value
*/
function formValueText(element) {
var nodeName = element.nodeName.toUpperCase();
function formValueText({ actualNode }) {
const nodeName = actualNode.nodeName.toUpperCase();
if (nodeName === 'INPUT') {
if (!element.hasAttribute('type') || (
inputTypes.indexOf(element.getAttribute('type').toLowerCase()) !== -1) &&
element.value) {
return element.value;
if (!actualNode.hasAttribute('type') ||
inputTypes.includes(actualNode.type.toLowerCase())) {
return actualNode.value;
}
return '';
}

if (nodeName === 'SELECT') {
var opts = element.options;
var opts = actualNode.options;
if (opts && opts.length) {
var returnText = '';
for (var i = 0; i < opts.length; i++) {
Expand All @@ -80,14 +79,18 @@ function formValueText(element) {
return '';
}

if (nodeName === 'TEXTAREA' && element.value) {
return element.value;
if (nodeName === 'TEXTAREA' && actualNode.value) {
return actualNode.value;
}
return '';
}

function checkDescendant(element, nodeName) {
var candidate = element.querySelector(nodeName.toLowerCase());
/**
* Get the accessible text of first matching node
* IMPORTANT: This method does not look at the composed tree
*/
function checkDescendant({ actualNode }, nodeName) {
var candidate = actualNode.querySelector(nodeName.toLowerCase());
Copy link
Contributor

Choose a reason for hiding this comment

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

should we put a note in here to indicate that this function is intended to be used only on elements that cannot contain a shadow root?

if (candidate) {
return text.accessibleText(candidate);
}
Expand All @@ -102,25 +105,27 @@ function checkDescendant(element, nodeName) {
* @param {HTMLElement} element The HTMLElement
* @return {boolean} True if embedded control
*/
function isEmbeddedControl(e) {
if (!e) {
function isEmbeddedControl(elm) {
if (!elm) {
return false;
}
switch (e.nodeName.toUpperCase()) {
const { actualNode } = elm;
switch (actualNode.nodeName.toUpperCase()) {
case 'SELECT':
case 'TEXTAREA':
return true;
case 'INPUT':
return !e.hasAttribute('type') || (inputTypes.indexOf(e.getAttribute('type').toLowerCase()) !== -1);
return (!actualNode.hasAttribute('type') ||
inputTypes.includes(actualNode.getAttribute('type').toLowerCase()));
default:
return false;
}
}

function shouldCheckAlt(element) {
var nodeName = element.nodeName.toUpperCase();
return (nodeName === 'INPUT' && element.type.toLowerCase() === 'image') ||
['IMG', 'APPLET', 'AREA'].indexOf(nodeName) !== -1;
function shouldCheckAlt({ actualNode }) {
const nodeName = actualNode.nodeName.toUpperCase();
return ['IMG', 'APPLET', 'AREA'].includes(nodeName) ||
(nodeName === 'INPUT' && actualNode.type.toLowerCase() === 'image');
}

function nonEmptyText(t) {
Expand All @@ -137,34 +142,33 @@ function nonEmptyText(t) {
* @return {string}
*/
text.accessibleText = function(element, inLabelledByContext) {
//todo: implement shadowDOM
var accessibleNameComputation;
var encounteredNodes = [];
let accessibleNameComputation;
const encounteredNodes = [];
if (element instanceof Node) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This works differently from a lot of the other commons - which do not try to maintain backwards compatibility. Whether we want to support backwards compatibility or not and if we do, we need to change those other utilities.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The reason I did this is because it would have taken a whole lot of additional changes to make sure every method that uses accessibleText has access to a virtual node. It wasn't even for backward compatibility, but that's a good additional reason. Further, I think these methods should be useful outside of a rule too, which they aren't currently, since they rely on setup that gets done in another place.

I think, instead of calling element = axe.utils.getNodeFromTree(axe._tree[0], element); we need a method that will take any DOM element and return it's virtual counterpart. Something like virtualNode = axe.utils.getVirtualNode(node). It can look at axe._tree[0] if available, and if it isn't, it should create a new tree and pull the virtual node from that. We could also make that method store virtual nodes in a set rather than a tree, so that lookup is faster.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, the biggest problem with not passing in the virtualNode is the potential performance impact. I am worried about memory leaks if we use these APIs (or make them usable) without understanding what they do. So I don't support building this cache if it doesn't exist.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am going to accept this pull request and have opened a separate issue to deal with this #435

element = axe.utils.getNodeFromTree(axe._tree[0], element);
}

function getInnerText (element, inLabelledByContext, inControlContext) {
var nodes = element.childNodes;
var returnText = '';
var node;

for (var i = 0; i < nodes.length; i++) {
node = nodes[i];
if (node.nodeType === 3) {
returnText += node.textContent;
} else if (node.nodeType === 1) {
if (phrasingElements.indexOf(node.nodeName.toUpperCase()) === -1) {
return element.children.reduce((returnText, child) => {
const { actualNode } = child;
if (actualNode.nodeType === 3) {
returnText += actualNode.nodeValue;
} else if (actualNode.nodeType === 1) {
if (!phrasingElements.includes(actualNode.nodeName.toUpperCase())) {
returnText += ' ';
}
returnText += accessibleNameComputation(nodes[i], inLabelledByContext, inControlContext);
returnText += accessibleNameComputation(child, inLabelledByContext,
inControlContext);
}
}

return returnText;
return returnText;
}, '');
}

function checkNative (element, inLabelledByContext, inControlContext) {
// jshint maxstatements:30
var returnText = '';
var nodeName = element.nodeName.toUpperCase();
let returnText = '';
const { actualNode } = element;
const nodeName = actualNode.nodeName.toUpperCase();

if (shouldCheckSubtree(element)) {
returnText = getInnerText(element, false, false) || '';
Expand All @@ -187,20 +191,21 @@ text.accessibleText = function(element, inLabelledByContext) {
return returnText;
}

returnText = element.getAttribute('title') || element.getAttribute('summary') || '';
returnText = (actualNode.getAttribute('title') ||
actualNode.getAttribute('summary') || '');

if (nonEmptyText(returnText)) {
return returnText;
}
}

if (shouldCheckAlt(element)) {
return element.getAttribute('alt') || '';
return actualNode.getAttribute('alt') || '';
}

if (isInput(element) && !inControlContext) {
if (isButton(element)) {
return element.value || element.title || defaultButtonValues[element.type] || '';
return actualNode.value || actualNode.title || defaultButtonValues[actualNode.type] || '';
}

var labelElement = findLabel(element);
Expand All @@ -213,21 +218,29 @@ text.accessibleText = function(element, inLabelledByContext) {
}

function checkARIA (element, inLabelledByContext, inControlContext) {

if (!inLabelledByContext && element.hasAttribute('aria-labelledby')) {
return text.sanitize(dom.idrefs(element, 'aria-labelledby').map(function(l) {
if (element === l) {
encounteredNodes.pop();
} //let element be encountered twice
return accessibleNameComputation(l, true, element !== l);
let returnText = '';
const { actualNode } = element;
if (!inLabelledByContext && actualNode.hasAttribute('aria-labelledby')) {
// Store the return text, if it's empty, fall back to aria-label
returnText = text.sanitize(dom.idrefs(actualNode, 'aria-labelledby').map(label => {
if (label !== null) {// handle unfound elements by dom.idref
if (actualNode === label) {
encounteredNodes.pop();
} //let element be encountered twice
const vLabel = axe.utils.getNodeFromTree(axe._tree[0], label);
return accessibleNameComputation(vLabel, true, actualNode !== label);
} else {
return '';
}
}).join(' '));
}

if (!(inControlContext && isEmbeddedControl(element)) && element.hasAttribute('aria-label')) {
return text.sanitize(element.getAttribute('aria-label'));
if (!returnText && !(inControlContext && isEmbeddedControl(element)) &&
actualNode.hasAttribute('aria-label')) {
return text.sanitize(actualNode.getAttribute('aria-label'));
}

return '';
return returnText;
}

/**
Expand All @@ -240,20 +253,22 @@ text.accessibleText = function(element, inLabelledByContext) {
* @return {string}
*/
accessibleNameComputation = function (element, inLabelledByContext, inControlContext) {
'use strict';

var returnText;
let returnText;
// If the node was already checked or is null, skip
if (element === null || (encounteredNodes.indexOf(element) !== -1)) {
if (!element || encounteredNodes.includes(element)) {
return '';

// if the node is invalid, throw
} else if (element !== null && element.actualNode instanceof Node !== true) {
throw new Error('Invalid argument. Virtual Node must be provided');

//Step 2a: Skip if the element is hidden, unless part of labelledby
} else if(!inLabelledByContext && !dom.isVisible(element, true)) {
} else if(!inLabelledByContext && !dom.isVisible(element.actualNode, true)) {
return '';
}

encounteredNodes.push(element);
var role = element.getAttribute('role');
var role = element.actualNode.getAttribute('role');

//Step 2b & 2c
returnText = checkARIA(element, inLabelledByContext, inControlContext);
Expand All @@ -276,7 +291,8 @@ text.accessibleText = function(element, inLabelledByContext) {
}

//Step 2f
if (!shouldNeverCheckSubtree(element) && (!role || aria.getRolesWithNameFromContents().indexOf(role) !== -1)) {
if (!shouldNeverCheckSubtree(element) &&
(!role || aria.getRolesWithNameFromContents().indexOf(role) !== -1)) {

returnText = getInnerText(element, inLabelledByContext, inControlContext);

Expand All @@ -288,8 +304,8 @@ text.accessibleText = function(element, inLabelledByContext) {
//Step 2g - if text node, return value (handled in getInnerText)

//Step 2h
if (element.hasAttribute('title')) {
return element.getAttribute('title');
if (element.actualNode.hasAttribute('title')) {
return element.actualNode.getAttribute('title');
}

return '';
Expand Down
Loading