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] + '>
'; + 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] + + '>
'; + 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 + + '>
an element
' + ); + 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 + + '>
an element
' + ); + 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('
an element
'); + 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">
an element
'; + + 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 + + '>
an element
'; + 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 = '
an element
'; + axeFixtureSetup(container); + var virtualNode = axe.utils.querySelectorAll(axe._tree[0], 'aside')[0]; + assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode)); + }); + }); } });