diff --git a/lib/commons/standards/implicit-html-roles.js b/lib/commons/standards/implicit-html-roles.js
index b302f0319b..579c166954 100644
--- a/lib/commons/standards/implicit-html-roles.js
+++ b/lib/commons/standards/implicit-html-roles.js
@@ -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.
@@ -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 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 = {
@@ -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',
@@ -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;
},
@@ -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;
},
diff --git a/lib/rules/landmark-unique-matches.js b/lib/rules/landmark-unique-matches.js
index b1384e719a..d651949764 100644
--- a/lib/rules/landmark-unique-matches.js
+++ b/lib/rules/landmark-unique-matches.js
@@ -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)
@@ -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);
@@ -42,7 +25,3 @@ function isLandmarkVirtual(vNode) {
return landmarkRoles.indexOf(role) >= 0 || role === 'region';
}
-
-function isHeaderFooterLandmark(headerFooterElement) {
- return !closest(headerFooterElement, excludedParentsForHeaderFooterLandmarks);
-}
diff --git a/test/commons/aria/implicit-role.js b/test/commons/aria/implicit-role.js
index 03b6ed56f5..c970f64356 100644
--- a/test/commons/aria/implicit-role.js
+++ b/test/commons/aria/implicit-role.js
@@ -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'];
@@ -131,6 +131,100 @@ describe('aria.implicitRole', function () {
assert.isNull(implicitRole(node));
});
+ it('should return complementary for aside scoped to body', function () {
+ fixture.innerHTML = '';
+ var node = fixture.querySelector('#target');
+ flatTreeSetup(fixture);
+ assert.equal(implicitRole(node), 'complementary');
+ });
+
+ it('should return complementary for aside scoped to main', function () {
+ fixture.innerHTML = '';
+ 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 =
+ '';
+ 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] + '>' + 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 =
+ '';
+ 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] +
+ '>' +
+ nodes[i] +
+ '>';
+ var node = fixture.querySelector('#target');
+ flatTreeSetup(fixture);
+ assert.equal(implicitRole(node), 'complementary');
+ }
+
+ for (var i = 0; i < roles.length; i++) {
+ fixture.innerHTML =
+ '';
+ 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 =
+ '';
+ var node = fixture.querySelector('#target');
+ flatTreeSetup(fixture);
+ assert.isNull(implicitRole(node));
+ });
+
+ it('should return complementary for sectioned aside with title', function () {
+ fixture.innerHTML =
+ '';
+ 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 =
+ '';
+ var node = fixture.querySelector('#target');
+ flatTreeSetup(fixture);
+ assert.isNull(implicitRole(node));
+ });
+
it('should return banner for "body header"', function () {
fixture.innerHTML = '';
var node = fixture.querySelector('#target');
@@ -138,7 +232,7 @@ describe('aria.implicitRole', function () {
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'];
diff --git a/test/rule-matches/landmark-unique-matches.js b/test/rule-matches/landmark-unique-matches.js
index 539d83769e..518763d953 100644
--- a/test/rule-matches/landmark-unique-matches.js
+++ b/test/rule-matches/landmark-unique-matches.js
@@ -4,13 +4,9 @@ describe('landmark-unique-matches', function () {
var fixture;
var axeFixtureSetup;
var shadowSupport = axe.testUtils.shadowSupport.v1;
- var excludedDescendantsForHeadersFooters = [
- 'article',
- 'aside',
- 'main',
- 'nav',
- 'section'
- ];
+ var sectioningContentElements = ['article', 'aside', 'nav', 'section'];
+ var excludedDescendantsForHeadersFooters =
+ sectioningContentElements.concat('main');
var headerFooterElements = ['header', 'footer'];
beforeEach(function () {
@@ -128,6 +124,51 @@ describe('landmark-unique-matches', function () {
});
});
+ describe('aside should not match when scoped to a sectioning content element unless it has an accessible name', function () {
+ sectioningContentElements.forEach(function (exclusionType) {
+ it(
+ 'should not match because aside is scoped to ' +
+ exclusionType +
+ ' and has no label',
+ function () {
+ axeFixtureSetup(
+ '<' +
+ exclusionType +
+ '>' +
+ exclusionType +
+ '>'
+ );
+ var node = fixture.querySelector('aside[data-test]');
+ var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
+ assert.isFalse(rule.matches(node, virtualNode));
+ }
+ );
+
+ it(
+ 'should match because aside within ' + exclusionType + ' has a label',
+ function () {
+ axeFixtureSetup(
+ '<' +
+ exclusionType +
+ '>' +
+ exclusionType +
+ '>'
+ );
+ var node = fixture.querySelector('aside[data-test]');
+ var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
+ assert.isTrue(rule.matches(node, virtualNode));
+ }
+ );
+ });
+
+ it('should match because aside is not scoped to a sectioning content element', function () {
+ axeFixtureSetup('');
+ var node = fixture.querySelector('aside');
+ var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node);
+ assert.isTrue(rule.matches(node, virtualNode));
+ });
+ });
+
if (shadowSupport) {
it('return true for landmarks contained within shadow dom', function () {
var container = document.createElement('div');
@@ -195,5 +236,63 @@ describe('landmark-unique-matches', function () {
);
});
});
+
+ describe('aside should match inside shadow dom unless it is both within sectioning content and has no accessible name', function () {
+ var container;
+ var shadow;
+
+ beforeEach(function () {
+ container = document.createElement('div');
+ shadow = container.attachShadow({ mode: 'open' });
+ });
+
+ sectioningContentElements.forEach(function (exclusionType) {
+ it(
+ 'should not match because aside is scoped to ' +
+ exclusionType +
+ ' and has no label',
+ function () {
+ shadow.innerHTML =
+ '<' +
+ exclusionType +
+ ' aria-label="sample label">' +
+ exclusionType +
+ '>';
+
+ axeFixtureSetup(container);
+ var virtualNode = axe.utils.querySelectorAll(
+ axe._tree[0],
+ 'aside[data-test]'
+ )[0];
+ assert.isFalse(rule.matches(virtualNode.actualNode, virtualNode));
+ }
+ );
+
+ it(
+ 'should match because aside within ' + exclusionType + ' has a label',
+ function () {
+ shadow.innerHTML =
+ '<' +
+ exclusionType +
+ '>' +
+ exclusionType +
+ '>';
+ axeFixtureSetup(container);
+ var virtualNode = axe.utils.querySelectorAll(
+ axe._tree[0],
+ 'aside[data-test]'
+ )[0];
+ assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode));
+ }
+ );
+ });
+
+ it('should match because aside is not scoped to a sectioning content element', function () {
+ shadow.innerHTML = '';
+ axeFixtureSetup(container);
+ var virtualNode = axe.utils.querySelectorAll(axe._tree[0], 'aside')[0];
+ assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode));
+ });
+ });
}
});