Skip to content

Commit

Permalink
feat: Rebuild the accessible text algorithm (#1163)
Browse files Browse the repository at this point in the history
* chore(WIP): rewrite accessibleText

* chore: More refactoring for accname

* chore(WIP): More improvements to accessibleName

* feat: Reimplement accessible name computation

* chore: All accessible name tests passing

* chore(accName): All tests passing

* chore: Add tests

* chore: Test form-control-value

* chore: Refactor and add docs to accessible-text

* chore: Add tests for namedFromContents

* chore: Refactor subtreeText method

* chore: Refactor native accessible text methods

* chore: Coverage for text.labelText

* fix: update to axe.commons.matches usage

* test: fix nativeTextboxValue tests

* test: fix failing tests

* fix: compute includeHidden as a part of accessibleName fn

* fix: do not mutate context in accessibleText

* chore: Refactor a11yText method for readability

* chore: Update a11yText test results
  • Loading branch information
WilcoFiers committed Feb 18, 2019
1 parent 0c2f42d commit 5f420e5
Show file tree
Hide file tree
Showing 33 changed files with 4,255 additions and 491 deletions.
9 changes: 7 additions & 2 deletions lib/checks/forms/group-labelledby.js
Expand Up @@ -38,12 +38,17 @@ matchingNodes.forEach(groupItem => {
);
});

const accessibleTextOptions = {
// Prevent following further aria-labelledby refs:
inLabelledByContext: true
};

// filter items with no accessible name, do this last for performance reasons
uniqueLabels = uniqueLabels.filter(labelNode =>
text.accessibleText(labelNode, true)
text.accessibleText(labelNode, accessibleTextOptions)
);
sharedLabels = sharedLabels.filter(labelNode =>
text.accessibleText(labelNode, true)
text.accessibleText(labelNode, accessibleTextOptions)
);

if (uniqueLabels.length > 0 && sharedLabels.length > 0) {
Expand Down
6 changes: 4 additions & 2 deletions lib/checks/label/implicit.js
@@ -1,5 +1,7 @@
var label = axe.commons.dom.findUpVirtual(virtualNode, 'label');
const { dom, text } = axe.commons;

var label = dom.findUpVirtual(virtualNode, 'label');
if (label) {
return !!axe.commons.text.accessibleTextVirtual(label);
return !!text.accessibleText(label, { inControlContext: true });
}
return false;
4 changes: 2 additions & 2 deletions lib/checks/shared/aria-label.js
@@ -1,2 +1,2 @@
var label = node.getAttribute('aria-label');
return !!(label ? axe.commons.text.sanitize(label).trim() : '');
const { text, aria } = axe.commons;
return !!text.sanitize(aria.arialabelText(node));
7 changes: 2 additions & 5 deletions lib/checks/shared/aria-labelledby.js
@@ -1,5 +1,2 @@
var getIdRefs = axe.commons.dom.idrefs;

return getIdRefs(node, 'aria-labelledby').some(function(elm) {
return elm && axe.commons.text.accessibleText(elm, true);
});
const { text, aria } = axe.commons;
return !!text.sanitize(aria.arialabelledbyText(node));
4 changes: 2 additions & 2 deletions lib/checks/shared/non-empty-title.js
@@ -1,2 +1,2 @@
var title = node.getAttribute('title');
return !!(title ? axe.commons.text.sanitize(title).trim() : '');
const { text } = axe.commons;
return !!text.sanitize(text.titleText(node));
15 changes: 15 additions & 0 deletions lib/commons/aria/arialabel-text.js
@@ -0,0 +1,15 @@
/* global aria */

/**
* Get the text value of aria-label, if any
*
* @param {VirtualNode|Element} element
* @return {string} ARIA label
*/
aria.arialabelText = function arialabelText(node) {
node = node.actualNode || node;
if (node.nodeType !== 1) {
return '';
}
return node.getAttribute('aria-label') || '';
};
52 changes: 52 additions & 0 deletions lib/commons/aria/arialabelledby-text.js
@@ -0,0 +1,52 @@
/* global aria, dom, text */

/**
* Get the accessible name based on aria-labelledby
*
* @param {VirtualNode} element
* @param {Object} context
* @property {Bool} inLabelledByContext Whether or not the lookup is part of aria-labelledby reference
* @property {Bool} inControlContext Whether or not the lookup is part of a native label reference
* @property {Element} startNode First node in accessible name computation
* @property {Bool} debug Enable logging for formControlValue
* @return {string} Cancatinated text value for referenced elements
*/
aria.arialabelledbyText = function arialabelledbyText(node, context = {}) {
node = node.actualNode || node;
/**
* Note: The there are significant difference in how many "leads" browsers follow.
* - Firefox stops after the first IDREF, so it
* doesn't follow aria-labelledby after a for:>ID ref.
* - Chrome seems to just keep iterating no matter how many levels deep.
* - AccName-AAM 1.1 suggests going one level deep, but to treat
* each ref type separately.
*
* Axe-core's implementation behaves most closely like Firefox as it seems
* to be the most common deniminator. Main difference is that Firefox
* includes the value of form controls in addition to aria-label(s),
* something no other browser seems to do. Axe doesn't do that.
*/
if (
node.nodeType !== 1 ||
context.inLabelledByContext ||
context.inControlContext
) {
return '';
}

const refs = dom.idrefs(node, 'aria-labelledby').filter(elm => elm);
return refs.reduce((accessibleName, elm) => {
const accessibleNameAdd = text.accessibleText(elm, {
// Prevent the infinite reference loop:
inLabelledByContext: true,
startNode: context.startNode || node,
...context
});

if (!accessibleName) {
return accessibleNameAdd;
} else {
return `${accessibleName} ${accessibleNameAdd}`;
}
}, '');
};
24 changes: 24 additions & 0 deletions lib/commons/aria/get-owned-virtual.js
@@ -0,0 +1,24 @@
/* global aria, dom */

/**
* Get an element's owned elements
*
* @param {VirtualNode} element
* @return {VirtualNode[]} Owned elements
*/
aria.getOwnedVirtual = function getOwned({ actualNode, children }) {
if (!actualNode || !children) {
throw new Error('getOwnedVirtual requires a virtual node');
}
// TODO: Check that the element has a role
// TODO: Descend into children with role=presentation|none
// TODO: Exclude descendents owned by other elements

return dom.idrefs(actualNode, 'aria-owns').reduce((ownedElms, element) => {
if (element) {
const virtualNode = axe.utils.getNodeFromTree(axe._tree[0], element);
ownedElms.push(virtualNode);
}
return ownedElms;
}, children);
};
39 changes: 39 additions & 0 deletions lib/commons/aria/named-from-contents.js
@@ -0,0 +1,39 @@
/* global aria */

/**
* Check if an element is named from contents
*
* @param {Node|VirtualNode} element
* @param {Object} options
* @property {Bool} strict Whether or not to follow the spects strictly
* @return {Bool}
*/
aria.namedFromContents = function namedFromContents(node, { strict } = {}) {
node = node.actualNode || node;
if (node.nodeType !== 1) {
return false;
}

const role = aria.getRole(node);
const roleDef = aria.lookupTable.role[role];

if (
(roleDef && roleDef.nameFrom.includes('contents')) ||
// TODO: This is a workaround for axe-core's over-assertive implicitRole computation
// once we fix that, this extra noImplicit check can be removed.
node.nodeName.toUpperCase() === 'TABLE'
) {
return true;
}

/**
* Note: Strictly speaking if the role is null, presentation, or none, the element
* isn't named from contents. Axe-core often needs to know if an element
* has content anyway, so we're allowing it here.
* Use { strict: true } to disable this behavior.
*/
if (strict) {
return false;
}
return !roleDef || ['presentation', 'none'].includes(role);
};

0 comments on commit 5f420e5

Please sign in to comment.