Skip to content

Commit

Permalink
fix(landmark-unique): follow spec, aside -> landmark (#4469)
Browse files Browse the repository at this point in the history
Update the landmark-unique rule matcher for aside elements so that they
are treated as landmarks using the same criteria specified in [Sections
3.4.8 and 3.4.9 of the HTML Accessibility API Mappings
1.0](https://w3c.github.io/html-aam/#el-aside-ancestorbodymain).

Closes: #4460

---------

Co-authored-by: Steven Lambert <[email protected]>
  • Loading branch information
gabalafou and straker authored Jun 4, 2024
1 parent 070bc01 commit e32f803
Show file tree
Hide file tree
Showing 4 changed files with 251 additions and 43 deletions.
62 changes: 49 additions & 13 deletions lib/commons/standards/implicit-html-roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,32 @@ import { closest } from '../../core/utils';
import cache from '../../core/base/cache';
import getExplicitRole from '../aria/get-explicit-role';

const getSectioningElementSelector = () => {
return cache.get('sectioningElementSelector', () => {
// Sectioning content elements: article, aside, nav, section
// https://html.spec.whatwg.org/multipage/dom.html#sectioning-content
const getSectioningContentSelector = () => {
return cache.get('sectioningContentSelector', () => {
return (
getElementsByContentType('sectioning')
.map(nodeName => `${nodeName}:not([role])`)
.join(', ') +
' , main:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'
' , [role=article], [role=complementary], [role=navigation], [role=region]'
);
});
};

const getSectioningContentPlusMainSelector = () => {
// Why is there this similar but slightly different selector?
// ->
// Asides can be scoped to body or main, but headers and footers must be
// scoped **only** to body (for landmark role mapping).
// - Header: https://w3c.github.io/html-aam/#el-header-ancestorbody
// - Footer: https://w3c.github.io/html-aam/#el-footer-ancestorbody
// - Aside: https://w3c.github.io/html-aam/#el-aside-ancestorbodymain
return cache.get('sectioningContentPlusMainSelector', () => {
return getSectioningContentSelector() + ' , main:not([role]), [role=main]';
});
};

// sectioning elements only have an accessible name if the
// aria-label, aria-labelledby, or title attribute has valid
// content.
Expand All @@ -36,18 +51,22 @@ const getSectioningElementSelector = () => {
// 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) {
//
// `checkTitle` means - also check the title attribute and
// return true if the node has a non-empty title
function hasAccessibleName(vNode, { checkTitle = false } = {}) {
// 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
// safari - if non-empty aria-labelledby or aria-label
//
// we will go with safaris implantation as it is the least common
// we will go with safaris implementation as it is the least common
// denominator
const ariaLabelledby = sanitize(arialabelledbyText(vNode));
const ariaLabel = sanitize(arialabelText(vNode));

return !!(ariaLabelledby || ariaLabel);
return !!(
sanitize(arialabelledbyText(vNode)) ||
sanitize(arialabelText(vNode)) ||
(checkTitle && vNode?.props.nodeType === 1 && sanitize(vNode.attr('title')))
);
}

const implicitHtmlRoles = {
Expand All @@ -58,7 +77,18 @@ const implicitHtmlRoles = {
return vNode.hasAttr('href') ? 'link' : null;
},
article: 'article',
aside: 'complementary',
aside: vNode => {
if (
closest(vNode.parent, getSectioningContentSelector()) &&
// An aside within sectioning content can still be mapped to
// role=complementary if it has an accessible name
!hasAccessibleName(vNode, { checkTitle: true })
) {
return null;
}

return 'complementary';
},
body: 'document',
button: 'button',
datalist: 'listbox',
Expand All @@ -70,7 +100,10 @@ const implicitHtmlRoles = {
fieldset: 'group',
figure: 'figure',
footer: vNode => {
const sectioningElement = closest(vNode, getSectioningElementSelector());
const sectioningElement = closest(
vNode,
getSectioningContentPlusMainSelector()
);

return !sectioningElement ? 'contentinfo' : null;
},
Expand All @@ -84,7 +117,10 @@ const implicitHtmlRoles = {
h5: 'heading',
h6: 'heading',
header: vNode => {
const sectioningElement = closest(vNode, getSectioningElementSelector());
const sectioningElement = closest(
vNode,
getSectioningContentPlusMainSelector()
);

return !sectioningElement ? 'banner' : null;
},
Expand Down
21 changes: 0 additions & 21 deletions lib/rules/landmark-unique-matches.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
import { isVisibleToScreenReaders } from '../commons/dom';
import { closest } from '../core/utils';
import { getRole } from '../commons/aria';
import { getAriaRolesByType } from '../commons/standards';
import { accessibleTextVirtual } from '../commons/text';

/*
* Since this is a best-practice rule, we are filtering elements as dictated by ARIA 1.1 Practices regardless of treatment by browser/AT combinations.
*
* Info: https://www.w3.org/TR/wai-aria-practices-1.1/#aria_landmark
*/
const excludedParentsForHeaderFooterLandmarks = [
'article',
'aside',
'main',
'nav',
'section'
].join(',');

export default function landmarkUniqueMatches(node, virtualNode) {
return (
isLandmarkVirtual(virtualNode) && isVisibleToScreenReaders(virtualNode)
Expand All @@ -31,9 +17,6 @@ function isLandmarkVirtual(vNode) {
}

const { nodeName } = vNode.props;
if (nodeName === 'header' || nodeName === 'footer') {
return isHeaderFooterLandmark(vNode);
}

if (nodeName === 'section' || nodeName === 'form') {
const accessibleText = accessibleTextVirtual(vNode);
Expand All @@ -42,7 +25,3 @@ function isLandmarkVirtual(vNode) {

return landmarkRoles.indexOf(role) >= 0 || role === 'region';
}

function isHeaderFooterLandmark(headerFooterElement) {
return !closest(headerFooterElement, excludedParentsForHeaderFooterLandmarks);
}
98 changes: 96 additions & 2 deletions test/commons/aria/implicit-role.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('aria.implicitRole', function () {
assert.equal(implicitRole(node), 'contentinfo');
});

it('should return null for footer with sectioning parent', function () {
it('should return null for footer with sectioning or main parent', function () {
var nodes = ['article', 'aside', 'main', 'nav', 'section'];
var roles = ['article', 'complementary', 'main', 'navigation', 'region'];

Expand Down Expand Up @@ -131,14 +131,108 @@ describe('aria.implicitRole', function () {
assert.isNull(implicitRole(node));
});

it('should return complementary for aside scoped to body', function () {
fixture.innerHTML = '<aside id="target"></aside>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'complementary');
});

it('should return complementary for aside scoped to main', function () {
fixture.innerHTML = '<main><aside id="target"></aside></main>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'complementary');
});

it('should return complementary for aside scoped to element with role=main', function () {
fixture.innerHTML =
'<article role="main"><aside id="target"></aside></article>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'complementary');
});

it('should return null for aside with sectioning parent', function () {
var nodes = ['article', 'aside', 'nav', 'section'];
var roles = ['article', 'complementary', 'navigation', 'region'];

for (var i = 0; i < nodes.length; i++) {
fixture.innerHTML =
'<' + nodes[i] + '><header id="target"></header></' + nodes[i] + '>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node), nodes[i] + ' not null');
}

for (var i = 0; i < roles.length; i++) {
fixture.innerHTML =
'<div role="' + roles[i] + '"><header id="target"></header></div>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.isNull(implicitRole(node), '[' + roles[i] + '] not null');
}
});

it('should return complementary for aside with sectioning parent if aside has aria-label', function () {
var nodes = ['article', 'aside', 'nav', 'section'];
var roles = ['article', 'complementary', 'navigation', 'region'];

for (var i = 0; i < nodes.length; i++) {
fixture.innerHTML =
'<' +
nodes[i] +
'><aside id="target" aria-label="test label"></aside></' +
nodes[i] +
'>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'complementary');
}

for (var i = 0; i < roles.length; i++) {
fixture.innerHTML =
'<div role="' +
roles[i] +
'"><aside id="target" aria-label="test label"></aside></div>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'complementary');
}
});

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

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

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

it('should return banner for "body header"', function () {
fixture.innerHTML = '<header id="target"></header>';
var node = fixture.querySelector('#target');
flatTreeSetup(fixture);
assert.equal(implicitRole(node), 'banner');
});

it('should return null for header with sectioning parent', function () {
it('should return null for header with sectioning or main parent', function () {
var nodes = ['article', 'aside', 'main', 'nav', 'section'];
var roles = ['article', 'complementary', 'main', 'navigation', 'region'];

Expand Down
Loading

0 comments on commit e32f803

Please sign in to comment.