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