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 1 commit
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
38 changes: 30 additions & 8 deletions lib/rules/landmark-unique-matches.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import { getRole } from '../commons/aria';
import { getAriaRolesByType } from '../commons/standards';
import { accessibleTextVirtual } from '../commons/text';

// Sectioning content -
// https://html.spec.whatwg.org/multipage/dom.html#sectioning-content
const sectioningContentElements = ['article', 'aside', 'nav', 'section'];

/*
* 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
* `header` and `footer` elements map to `banner` landmarks only when scoped to `body`
* https://w3c.github.io/html-aam/#el-header
* https://w3c.github.io/html-aam/#el-footer
*/
const excludedParentsForHeaderFooterLandmarks = [
'article',
'aside',
'main',
'nav',
'section'
...sectioningContentElements,
'main'
].join(',');

export default function landmarkUniqueMatches(node, virtualNode) {
Expand All @@ -35,6 +36,10 @@ function isLandmarkVirtual(vNode) {
return isHeaderFooterLandmark(vNode);
}

if (nodeName === 'aside') {
return isAsideLandmark(vNode);
}

if (nodeName === 'section' || nodeName === 'form') {
const accessibleText = accessibleTextVirtual(vNode);
return !!accessibleText;
Expand All @@ -46,3 +51,20 @@ function isLandmarkVirtual(vNode) {
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);
}

/*
* `aside` elements map to `complementary` landmarks when scoped to `body` or
* `main`. If it is within sectioning content, it is not a landmark unless it
* has an accessible name.
*
* https://w3c.github.io/html-aam/#el-aside-ancestorbodymain
*/
const sectioningContentSelector = sectioningContentElements.join(',');
function isAsideLandmark(asideElement) {
return (
!closest(
asideElement.parent, // closest() can match self, which we do not want, so start at parent element
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't have caught this without tests

sectioningContentSelector
) || !!accessibleTextVirtual(asideElement)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not 100% sure that this function, accessibleTextVirtual(), is 100% equivalent to the "accessible name" requirement of the specification (Section 3.4.9)

);
}
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));
});
});
}
});