Skip to content

Commit d2d8c7f

Browse files
Merge pull request #2758 from johanrd/fix/landmark-roles-util
refactor: extract landmark-roles util (preserving deliberate per-rule region exclusion)
2 parents 3eb86d4 + e888a65 commit d2d8c7f

7 files changed

Lines changed: 137 additions & 34 deletions

lib/rules/template-no-duplicate-landmark-elements.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
// This rule inspects aria-label / aria-labelledby before classifying a node
2+
// as a landmark (see getLabel + the dynamic-label skip in GlimmerElementNode),
3+
// so it can safely include `region` — it won't flag an unnamed <section> as
4+
// a landmark duplicate. Use the full spec-listed 8-role set.
5+
const { ALL_LANDMARK_ROLES: LANDMARK_ROLES } = require('../utils/landmark-roles');
6+
17
/** @type {import('eslint').Rule.RuleModule} */
28
module.exports = {
39
meta: {
@@ -33,18 +39,6 @@ module.exports = {
3339
form: 'form',
3440
};
3541

36-
// Landmark ARIA roles
37-
const LANDMARK_ROLES = new Set([
38-
'banner',
39-
'complementary',
40-
'contentinfo',
41-
'form',
42-
'main',
43-
'navigation',
44-
'region',
45-
'search',
46-
]);
47-
4842
// Sectioning elements that strip banner/contentinfo roles from header/footer
4943
const SECTIONING_ELEMENTS = new Set(['article', 'aside', 'main', 'nav', 'section']);
5044
const elementStack = [];

lib/rules/template-no-nested-landmark.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,4 @@
1-
const LANDMARK_ROLES = new Set([
2-
'banner',
3-
'complementary',
4-
'contentinfo',
5-
'form',
6-
'main',
7-
'navigation',
8-
'search',
9-
]);
1+
const { LANDMARK_ROLES } = require('../utils/landmark-roles');
102

113
const LANDMARK_ELEMENTS = new Set(['header', 'aside', 'footer', 'form', 'main', 'nav']);
124

lib/rules/template-no-redundant-role.js

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { roles } = require('aria-query');
2+
const { LANDMARK_ROLES } = require('../utils/landmark-roles');
23

34
const DEFAULT_CONFIG = {
45
checkAllHTMLElements: true,
@@ -19,17 +20,6 @@ function createErrorMessageAnyElement(element, role) {
1920
return `Use of redundant or invalid role: ${role} on <${element}> detected.`;
2021
}
2122

22-
// https://www.w3.org/TR/html-aria/#docconformance
23-
const LANDMARK_ROLES = new Set([
24-
'banner',
25-
'main',
26-
'complementary',
27-
'search',
28-
'form',
29-
'navigation',
30-
'contentinfo',
31-
]);
32-
3323
const ALLOWED_ELEMENT_ROLES = [
3424
{ name: 'nav', role: 'navigation' },
3525
{ name: 'form', role: 'search' },
@@ -114,7 +104,12 @@ const ROLE_TO_ELEMENTS = {
114104
navigation: ['nav'],
115105
option: ['option'],
116106
radio: ['input'],
117-
region: ['section'],
107+
// `region` is intentionally NOT mapped to `<section>` here. `<section>`
108+
// only gets the `region` landmark role when it has an accessible name
109+
// (aria-label / aria-labelledby / title); without one it has role
110+
// `generic`. A static linter cannot verify accessible-name presence.
111+
// Spec: https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/HTML5.html
112+
// See #2694 where the same reasoning was applied to template-no-nested-landmark.
118113
row: ['tr'],
119114
rowgroup: ['tbody', 'tfoot', 'thead'],
120115
rowheader: ['th'],

lib/utils/landmark-roles.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use strict';
2+
3+
const { roles } = require('aria-query');
4+
5+
// Non-abstract landmark roles derived from aria-query's role taxonomy: any
6+
// role whose superClass chain includes 'landmark', minus DPub-ARIA `doc-*`
7+
// (which share that superClass but are outside this plugin's rules' scope —
8+
// downstream callers can extend if needed). The exact size is intentionally
9+
// not hard-coded: the derivation is what matters, so if aria-query adds a
10+
// new non-abstract landmark upstream it will be picked up automatically.
11+
const ALL_LANDMARK_ROLES = buildLandmarkRoleSet();
12+
13+
// The subset that's safe for static-linting rules to treat as landmarks
14+
// without further verification. `region` is EXCLUDED because a static linter
15+
// cannot determine at lint time whether the element has an accessible name
16+
// (via aria-label / aria-labelledby / title), which is required for the
17+
// `region` role to actually apply to a `<section>` per HTML-AAM.
18+
//
19+
// See PR #2694 for the full rationale and spec citation
20+
// (https://www.w3.org/TR/html-aam-1.0/#el-section):
21+
// `<section>` without an accessible name has role `generic`, not `region`.
22+
//
23+
// Most a11y rules that enumerate landmarks should use this subset.
24+
// Rules that DO verify accessible names (e.g. template-no-duplicate-
25+
// landmark-elements, which inspects aria-label / aria-labelledby before
26+
// classifying a node as a landmark) should import ALL_LANDMARK_ROLES.
27+
const LANDMARK_ROLES = new Set([...ALL_LANDMARK_ROLES].filter((role) => role !== 'region'));
28+
29+
function buildLandmarkRoleSet() {
30+
const result = new Set();
31+
for (const [role, def] of roles) {
32+
if (def.abstract) {
33+
continue;
34+
}
35+
if (role.startsWith('doc-')) {
36+
continue;
37+
}
38+
const descendsFromLandmark = (def.superClass || []).some((chain) => chain.includes('landmark'));
39+
if (descendsFromLandmark) {
40+
result.add(role);
41+
}
42+
}
43+
return result;
44+
}
45+
46+
module.exports = { LANDMARK_ROLES, ALL_LANDMARK_ROLES };

tests/lib/rules/template-no-nested-landmark.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ ruleTester.run('template-no-nested-landmark', rule, {
4545

4646
// `<section>` only gets the `region` landmark role when it has an accessible name
4747
// (aria-label/aria-labelledby/title). Without one it has the generic role — see
48-
// https://www.w3.org/TR/html-aam-1.0/#el-section
48+
// https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/HTML5.html
4949
// This rule does not inspect accessible names, so unnamed sections are excluded.
5050
'<template><section><section>Content</section></section></template>',
5151
// `role="region"` is the landmark role a named `<section>` gets. Nesting it is

tests/lib/rules/template-no-redundant-role.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ ruleTester.run('template-no-redundant-role', rule, {
6666
// <button> resolves to `tab` (non-redundant — button's implicit is
6767
// `button`, not `tab`). WAI-ARIA §4.1 fallback-list semantics.
6868
'<template><button role="tab button"></button></template>',
69+
// `region` is a landmark role, but <div> has no implicit role — role="region"
70+
// on a <div> is not redundant.
71+
'<template><div role="region"></div></template>',
72+
{
73+
code: '<template><div role="region"></div></template>',
74+
options: [{ checkAllHTMLElements: false }],
75+
},
76+
// Per #2694: <section> has implicit role `generic` when unnamed and
77+
// `region` when it has an accessible name. role="region" on <section> is
78+
// therefore not unconditionally redundant — #38 aligns with this.
79+
'<template><section role="region" aria-label="Quick facts">...</section></template>',
6980
],
7081
invalid: [
7182
{
@@ -223,6 +234,10 @@ hbsRuleTester.run('template-no-redundant-role', rule, {
223234
// NOT redundant.
224235
'<select role="listbox"></select>',
225236
'<select role="listbox" size="1"></select>',
237+
// Per #2694: <section> has implicit role `generic` when unnamed and
238+
// `region` when it has an accessible name. role="region" on <section> is
239+
// therefore not unconditionally redundant — #38 aligns with this.
240+
'<section role="region" aria-label="Quick facts">...</section>',
226241
],
227242
invalid: [
228243
{
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict';
2+
3+
const { LANDMARK_ROLES, ALL_LANDMARK_ROLES } = require('../../../lib/utils/landmark-roles');
4+
5+
// The WAI-ARIA 1.2 §5.3.4 landmark roles known at authoring time. Asserting
6+
// set-inclusion (not an exact-equality / size match) keeps the test robust
7+
// to future aria-query updates that add non-abstract landmark roles — the
8+
// derivation (non-abstract + superClass contains 'landmark' + not doc-*)
9+
// is what we actually care about.
10+
const KNOWN_LANDMARK_ROLES = [
11+
'banner',
12+
'complementary',
13+
'contentinfo',
14+
'form',
15+
'main',
16+
'navigation',
17+
'region',
18+
'search',
19+
];
20+
21+
describe('ALL_LANDMARK_ROLES', () => {
22+
it('includes every WAI-ARIA 1.2 §5.3.4 landmark role', () => {
23+
for (const role of KNOWN_LANDMARK_ROLES) {
24+
expect(ALL_LANDMARK_ROLES.has(role)).toBe(true);
25+
}
26+
// Floor, not ceiling — lets the set grow if aria-query adds new
27+
// non-abstract landmark roles upstream.
28+
expect(ALL_LANDMARK_ROLES.size).toBeGreaterThanOrEqual(KNOWN_LANDMARK_ROLES.length);
29+
});
30+
31+
it('excludes DPub-ARIA doc-* roles', () => {
32+
for (const role of ALL_LANDMARK_ROLES) {
33+
expect(role.startsWith('doc-')).toBe(false);
34+
}
35+
});
36+
});
37+
38+
describe('LANDMARK_ROLES (the statically-verifiable subset)', () => {
39+
it('includes every known landmark role except region', () => {
40+
for (const role of KNOWN_LANDMARK_ROLES) {
41+
if (role === 'region') {
42+
continue;
43+
}
44+
expect(LANDMARK_ROLES.has(role)).toBe(true);
45+
}
46+
// Floor, not ceiling — mirrors the ALL_LANDMARK_ROLES rationale, minus
47+
// the deliberate region exclusion.
48+
expect(LANDMARK_ROLES.size).toBeGreaterThanOrEqual(KNOWN_LANDMARK_ROLES.length - 1);
49+
});
50+
51+
it('excludes region (cannot verify accessible-name presence statically)', () => {
52+
expect(LANDMARK_ROLES.has('region')).toBe(false);
53+
expect(ALL_LANDMARK_ROLES.has('region')).toBe(true);
54+
});
55+
56+
it('excludes non-landmark roles (button, link, article)', () => {
57+
expect(LANDMARK_ROLES.has('button')).toBe(false);
58+
expect(LANDMARK_ROLES.has('link')).toBe(false);
59+
expect(LANDMARK_ROLES.has('article')).toBe(false);
60+
});
61+
});

0 commit comments

Comments
 (0)