-
Notifications
You must be signed in to change notification settings - Fork 793
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
Changes from 5 commits
d29d6cc
48fca9d
0314c42
19a9ff8
55d6aa1
e3ca631
9584884
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 + ']') | ||
); | ||
}; |
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', | ||
|
@@ -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()); | ||
} | ||
|
||
/** | ||
|
@@ -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++) { | ||
|
@@ -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()); | ||
if (candidate) { | ||
return text.accessibleText(candidate); | ||
} | ||
|
@@ -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) { | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) || ''; | ||
|
@@ -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); | ||
|
@@ -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; | ||
} | ||
|
||
/** | ||
|
@@ -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); | ||
|
@@ -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); | ||
|
||
|
@@ -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 ''; | ||
|
There was a problem hiding this comment.
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?