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

fix(implicit-roles): add proper implicit role calculation #2242

Merged
merged 8 commits into from
Jun 5, 2020
Merged
Show file tree
Hide file tree
Changes from 2 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
50 changes: 30 additions & 20 deletions lib/commons/aria/lookup-table.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import arialabelledbyText from './arialabelledby-text';
import arialabelText from './arialabel-text';
import titleText from '../text/title-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 { closest } from '../../core/utils';

const isNull = value => value === null;
Expand Down Expand Up @@ -2094,6 +2094,29 @@ 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 !!(
(vNode.hasAttr('aria-labelledby') && sanitize(arialabelledbyText(vNode))) ||
(vNode.hasAttr('aria-label') && sanitize(arialabelText(vNode))) ||
(vNode.hasAttr('title') && sanitize(vNode.attr('title')))
);
}

// Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
// Source: https://www.w3.org/TR/html-aria/
lookupTable.implicitRole = {
straker marked this conversation as resolved.
Show resolved Hide resolved
straker marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -2116,20 +2139,12 @@ lookupTable.implicitRole = {
fieldset: 'group',
figure: 'figure',
footer: vNode => {
const sectioningElement = closest(
vNode,
'article, aside, main, nav, section, [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'
);
const sectioningElement = closest(vNode, sectioningElementSelector);

return !sectioningElement ? 'contentinfo' : null;
},
form: vNode => {
// accessible-text-virtual uses getRole, so we have to only
// use the name calculation steps we know apply to a form
// element
return arialabelledbyText(vNode) || arialabelText(vNode) || titleText(vNode)
? 'form'
: null;
return hasAccessibleName(vNode) ? 'form' : null;
},
h1: 'heading',
h2: 'heading',
Expand All @@ -2138,10 +2153,7 @@ lookupTable.implicitRole = {
h5: 'heading',
h6: 'heading',
header: vNode => {
const sectioningElement = closest(
vNode,
'article, aside, main, nav, section, [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'
);
const sectioningElement = closest(vNode, sectioningElementSelector);

return !sectioningElement ? 'banner' : null;
},
Expand All @@ -2157,7 +2169,7 @@ lookupTable.implicitRole = {
const suggestionsSourceElement =
listElement && listElement.nodeName.toLowerCase() === 'datalist';

switch (vNode.attr('type')) {
switch ((vNode.attr('type') || '').toLowerCase()) {
case 'button':
case 'image':
case 'reset':
Expand All @@ -2169,7 +2181,7 @@ lookupTable.implicitRole = {
case 'tel':
case 'text':
case 'url':
case null: // text is the default value
case '': // text is the default value
return !suggestionsSourceElement ? 'textbox' : 'combobox';
case 'number':
return 'spinbutton';
Expand All @@ -2192,9 +2204,7 @@ lookupTable.implicitRole = {
output: 'status',
progress: 'progressbar',
section: vNode => {
return arialabelledbyText(vNode) || arialabelText(vNode) || titleText(vNode)
? 'region'
: null;
return hasAccessibleName(vNode) ? 'region' : null;
},
select: vNode => {
return vNode.hasAttr('multiple') || parseInt(vNode.attr('size')) > 1
Expand Down
56 changes: 54 additions & 2 deletions test/commons/aria/implicit-role.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,28 @@ describe('aria.implicitRole', function() {
}
});

it('should return form for form with accessible name', function() {
it('should return form for form with accessible name aria-label', function() {
fixture.innerHTML = '<form id="target" aria-label="foo"></form>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'form');
});

it('should return form for form with accessible name aria-labelledby', function() {
fixture.innerHTML =
'<div id="foo">foo</div><form id="target" aria-labelledby="foo"></form>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'form');
});

it('should return form for form with accessible name title', function() {
fixture.innerHTML = '<form id="target" title="foo"></form>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'form');
});

it('should return null for form without accessible name', function() {
fixture.innerHTML = '<form id="target"></form>';
var node = fixture.querySelector('#target');
Expand Down Expand Up @@ -270,20 +285,57 @@ describe('aria.implicitRole', function() {
assert.equal(implicitRole(node), 'combobox');
});

it('should return region for "section" with accessible name', function() {
it('should return region for "section" with accessible name aria-label', function() {
fixture.innerHTML = '<section id="target" aria-label="foo"></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'region');
});

it('should return region for section with accessible name aria-labelledby', function() {
fixture.innerHTML =
'<div id="foo">foo</div><section id="target" aria-labelledby="foo"></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'region');
});

it('should return region for section with accessible name title', function() {
fixture.innerHTML = '<section id="target" title="foo"></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'region');
straker marked this conversation as resolved.
Show resolved Hide resolved
});

it('should return null for "section" without accessible name', function() {
fixture.innerHTML = '<section id="target"></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node));
});

it('should return null for "section" with empty aria-label', function() {
fixture.innerHTML = '<section id="target" aria-label=" "></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node));
});

it('should return null for "section" with empty aria-labelledby', function() {
fixture.innerHTML =
'<div id="foo"> </div><section id="target" aria-labelledby="foo"></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node));
});

it('should return null for "section" with empty title', function() {
fixture.innerHTML = '<section id="target" title=" "></section>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node));
});

it('should return listbox for "select[multiple]"', function() {
fixture.innerHTML = '<select id="target" multiple></select>';
var node = fixture.querySelector('#target');
Expand Down