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(landmark-unique): follow spec, aside -> landmark #4469

Merged
merged 5 commits into from
Jun 4, 2024
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
46 changes: 39 additions & 7 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?
// ->
// To be mapped to landmark roles, asides can be scoped to body or main. But
// headers and footers must be scoped **only** to body.
// - 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 @@ -40,7 +55,7 @@ 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
// safari - if non-empty aria-labelledby or aria-label
//
// we will go with safaris implantation as it is the least common
// denominator
Expand All @@ -58,7 +73,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)
gabalafou marked this conversation as resolved.
Show resolved Hide resolved
) {
return null;
}

return 'complementary';
},
body: 'document',
button: 'button',
datalist: 'listbox',
Expand All @@ -70,7 +96,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 +113,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
gabalafou marked this conversation as resolved.
Show resolved Hide resolved
*/
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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in a previous comment, this function has apparently been redundant ever since the implicit role mapping was introduced/updated.

return !closest(headerFooterElement, excludedParentsForHeaderFooterLandmarks);
}
82 changes: 80 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,92 @@ 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 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
113 changes: 106 additions & 7 deletions test/rule-matches/landmark-unique-matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 () {
Copy link
Contributor Author

@gabalafou gabalafou May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look at the rest of the test file, this newly added code is nearly copy-paste of the header and footer tests. Is that preferred, or should I attempt to reduce the repetition?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repetition is fine, we can clean it up later if we decide to.

sectioningContentElements.forEach(function (exclusionType) {
it(
'should not match because aside is scoped to ' +
exclusionType +
' and has no label',
function () {
axeFixtureSetup(
'<' +
exclusionType +
'><aside data-test>an element</aside></' +
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 +
'><aside aria-label="sample label" data-test>an element</aside></' +
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('<aside>an element</aside>');
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');
Expand Down Expand Up @@ -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"><aside data-test>an element</aside></' +
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 +
'><aside aria-label="sample label" data-test>an element</aside></' +
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 = '<aside>an element</aside>';
axeFixtureSetup(container);
var virtualNode = axe.utils.querySelectorAll(axe._tree[0], 'aside')[0];
assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode));
});
});
}
});