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

feat(standards): add get-elements-by-content-type and implicit-html-roles #2375

Merged
merged 3 commits into from
Jul 16, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions lib/commons/aria/implicit-role.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import lookupTable from './lookup-table';
import implicitHtmlRoles from '../standards/implicit-html-roles';
import { getNodeFromTree } from '../../core/utils';
import AbstractVirtuaNode from '../../core/base/virtual-node/abstract-virtual-node';

Expand Down Expand Up @@ -32,7 +32,7 @@ function implicitRole(node) {
}

const nodeName = vNode.props.nodeName;
const role = lookupTable.implicitHtmlRole[nodeName];
const role = implicitHtmlRoles[nodeName];

if (!role) {
return null;
Expand Down
30 changes: 30 additions & 0 deletions lib/commons/standards/get-elements-by-content-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import standards from '../../standards';

/**
* Return a list of html elements whose content type matches the provided value. Note: this will not work for 'interactive' content types as those depend on the element.
* @param {String} type The desired content type
* @return {String[]} List of all elements matching the type
*/
function getElementsByContentType(type) {
return Object.keys(standards.htmlElms).filter(nodeName => {
const elm = standards.htmlElms[nodeName];

if (elm.contentTypes) {
return elm.contentTypes.includes(type);
}

// some elements do not have content types
if (!elm.variant) {
return false;
}

if (elm.variant.default && elm.variant.default.contentTypes) {
return elm.variant.default.contentTypes.includes(type);
}

// content type depends on a virtual node
return false;
});
}

export default getElementsByContentType;
175 changes: 175 additions & 0 deletions lib/commons/standards/implicit-html-roles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
// Source: https://www.w3.org/TR/html-aria/
import getElementsByContentType from './get-elements-by-content-type';
import getGlobalAriaAttrs from './get-global-aria-attrs';
import arialabelledbyText from '../aria/arialabelledby-text';
import arialabelText from '../aria/arialabel-text';
import idrefs from '../dom/idrefs';
import isColumnHeader from '../table/is-column-header';
import isRowHeader from '../table/is-row-header';
import sanitize from '../text/sanitize';
import isFocusable from '../dom/is-focusable';
import { closest } from '../../core/utils';

const sectioningElementSelector =
getElementsByContentType('sectioning')
.map(nodeName => `${nodeName}:not([role])`)
.join(', ') +
' , main:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]';

// sectioning elements only have an accessible name if the
// aria-label, aria-labelledby, or title attribute has valid
// content.
// can't go through the normal accessible name computation
// as it leads into an infinite loop of asking for the role
// of the element while the implicit role needs the name.
// Source: https://www.w3.org/TR/html-aam-1.0/#section-and-grouping-element-accessible-name-computation
//
// form elements also follow this same pattern although not
// specifically called out in the spec like section elements
// (per Scott O'Hara)
// Source: https://web-a11y.slack.com/archives/C042TSFGN/p1590607895241100?thread_ts=1590602189.217800&cid=C042TSFGN
function hasAccessibleName(vNode) {
straker marked this conversation as resolved.
Show resolved Hide resolved
return !!(
// testing for when browsers give a <section> a region role:
// chrome - always a region role
// firefox - if non-empty aria-labelledby, aria-label, or title
// safari - if non-empty aria-lablledby or aria-label
//
// we will go with safaris implantation as it is the least common
// denominator
(
(vNode.hasAttr('aria-labelledby') &&
sanitize(arialabelledbyText(vNode))) ||
(vNode.hasAttr('aria-label') && sanitize(arialabelText(vNode)))
)
);
}

const implicitHtmlRoles = {
a: vNode => {
return vNode.hasAttr('href') ? 'link' : null;
},
area: vNode => {
return vNode.hasAttr('href') ? 'link' : null;
},
article: 'article',
aside: 'complementary',
body: 'document',
button: 'button',
datalist: 'listbox',
dd: 'definition',
dfn: 'term',
details: 'group',
dialog: 'dialog',
dt: 'term',
fieldset: 'group',
figure: 'figure',
footer: vNode => {
const sectioningElement = closest(vNode, sectioningElementSelector);

return !sectioningElement ? 'contentinfo' : null;
},
form: vNode => {
return hasAccessibleName(vNode) ? 'form' : null;
},
h1: 'heading',
h2: 'heading',
h3: 'heading',
h4: 'heading',
h5: 'heading',
h6: 'heading',
header: vNode => {
const sectioningElement = closest(vNode, sectioningElementSelector);

return !sectioningElement ? 'banner' : null;
},
hr: 'separator',
img: vNode => {
// an images role is considered implicitly presentation if the
// alt attribute is empty. But that shouldn't be the case if it
// has global aria attributes or is focusable, so we need to
// override the role back to `img`
// e.g. <img alt="" aria-label="foo"></img>
const emptyAlt = vNode.hasAttr('alt') && !vNode.attr('alt');
const hasGlobalAria = getGlobalAriaAttrs().find(attr =>
vNode.hasAttr(attr)
);

return emptyAlt && !hasGlobalAria && !isFocusable(vNode.actualNode)
? 'presentation'
: 'img';
},
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';

switch ((vNode.attr('type') || '').toLowerCase()) {
case 'button':
case 'image':
case 'reset':
case 'submit':
return 'button';
case 'checkbox':
return 'checkbox';
case 'email':
case 'tel':
case 'text':
case 'url':
case '': // text is the default value
return !suggestionsSourceElement ? 'textbox' : 'combobox';
case 'number':
return 'spinbutton';
case 'radio':
return 'radio';
case 'range':
return 'slider';
case 'search':
return !suggestionsSourceElement ? 'searchbox' : 'combobox';
}
},
// Note: if an li (or some other elms) do not have a required
// parent, Firefox ignores the implicit semantic role and treats
// it as a generic text.
li: 'listitem',
main: 'main',
math: 'math',
menu: 'list',
nav: 'navigation',
ol: 'list',
optgroup: 'group',
option: 'option',
output: 'status',
progress: 'progressbar',
section: vNode => {
return hasAccessibleName(vNode) ? 'region' : null;
},
select: vNode => {
return vNode.hasAttr('multiple') || parseInt(vNode.attr('size')) > 1
? 'listbox'
: 'combobox';
},
summary: 'button',
table: 'table',
tbody: 'rowgroup',
td: 'cell',
textarea: 'textbox',
tfoot: 'rowgroup',
th: vNode => {
if (isColumnHeader(vNode.actualNode)) {
return 'columnheader';
}
if (isRowHeader(vNode.actualNode)) {
return 'rowheader';
}
},
thead: 'rowgroup',
tr: 'row',
ul: 'list'
};

export default implicitHtmlRoles;
2 changes: 2 additions & 0 deletions lib/commons/standards/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export { default as getAriaRolesByType } from './get-aria-roles-by-type';
export { default as getAriaRolesSupportingNameFromContent } from './get-aria-roles-supporting-name-from-content';
export { default as getGlobalAriaAttrs } from './get-global-aria-attrs';
export { default as getElementSpec } from './get-element-spec';
export { default as getElementsByContentType } from './get-elements-by-content-type';
export { default as implicitHtmlRoles } from './implicit-html-roles';
64 changes: 64 additions & 0 deletions test/commons/standards/get-elements-by-content-type.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
describe('standards.getElementsByContentType', function() {
var getElementsByContentType = axe.commons.standards.getElementsByContentType;

before(function() {
axe._load({});
});

after(function() {
axe.reset();
});

it('should return a list of node names by content type', function() {
// Source: https://html.spec.whatwg.org/multipage/dom.html#sectioning-content
var sectioningContent = getElementsByContentType('sectioning');
assert.deepEqual(sectioningContent, ['article', 'aside', 'nav', 'section']);
});

it('should return a default variants', function() {
// Source: https://html.spec.whatwg.org/multipage/dom.html#embedded-content-2
var sectioningContent = getElementsByContentType('embedded');
assert.deepEqual(sectioningContent, [
'audio',
'canvas',
'embed',
'iframe',
'img',
'math',
'object',
'picture',
'svg',
'video'
]);
});

it('should return configured roles', function() {
axe.configure({
standards: {
htmlElms: {
myElm: {
contentTypes: ['flow', 'sectioning']
}
}
}
});

var structureRoles = getElementsByContentType('sectioning');
assert.include(structureRoles, 'myElm');
});

it('should not return role that is configured to not be of the type', function() {
axe.configure({
standards: {
htmlElms: {
article: {
contentTypes: ['flow']
}
}
}
});

var structureRoles = getElementsByContentType('sectioning');
assert.notInclude(structureRoles, 'article');
});
});