Skip to content

Commit

Permalink
feat(standards): add get-elements-by-content-type and implicit-html-r…
Browse files Browse the repository at this point in the history
…oles (#2375)

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

* cleanup

* lookuptable to use implicit roles from commons/standards
  • Loading branch information
straker committed Jul 16, 2020
1 parent 9512df7 commit f1e0848
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 169 deletions.
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
169 changes: 2 additions & 167 deletions lib/commons/aria/lookup-table.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import arialabelledbyText from './arialabelledby-text';
import arialabelText from './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';
import implicitHtmlRoles from '../standards/implicit-html-roles';

const isNull = value => value === null;
const isNotNull = value => value !== null;
Expand Down Expand Up @@ -2098,165 +2091,7 @@ lookupTable.role = {
}
};

const sectioningElementSelector =
'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section: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) {
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)))
)
);
}

// Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
// Source: https://www.w3.org/TR/html-aria/
lookupTable.implicitHtmlRole = {
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 = lookupTable.globalAttributes.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'
};
lookupTable.implicitHtmlRole = implicitHtmlRoles;

// Source: https://www.w3.org/TR/html-aria/
lookupTable.elementsAllowedNoRole = [
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;
172 changes: 172 additions & 0 deletions lib/commons/standards/implicit-html-roles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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) {
// 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
const ariaLabelledby = sanitize(arialabelledbyText(vNode));
const ariaLabel = sanitize(arialabelText(vNode));

return !!(ariaLabelledby || ariaLabel);
}

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;
Loading

0 comments on commit f1e0848

Please sign in to comment.