diff --git a/lib/rules/template-no-duplicate-landmark-elements.js b/lib/rules/template-no-duplicate-landmark-elements.js index 8adcf7c065..03fdec0b76 100644 --- a/lib/rules/template-no-duplicate-landmark-elements.js +++ b/lib/rules/template-no-duplicate-landmark-elements.js @@ -1,3 +1,9 @@ +// This rule inspects aria-label / aria-labelledby before classifying a node +// as a landmark (see getLabel + the dynamic-label skip in GlimmerElementNode), +// so it can safely include `region` — it won't flag an unnamed
as +// a landmark duplicate. Use the full spec-listed 8-role set. +const { ALL_LANDMARK_ROLES: LANDMARK_ROLES } = require('../utils/landmark-roles'); + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -33,18 +39,6 @@ module.exports = { form: 'form', }; - // Landmark ARIA roles - const LANDMARK_ROLES = new Set([ - 'banner', - 'complementary', - 'contentinfo', - 'form', - 'main', - 'navigation', - 'region', - 'search', - ]); - // Sectioning elements that strip banner/contentinfo roles from header/footer const SECTIONING_ELEMENTS = new Set(['article', 'aside', 'main', 'nav', 'section']); const elementStack = []; diff --git a/lib/rules/template-no-nested-landmark.js b/lib/rules/template-no-nested-landmark.js index 9e516d9ccc..ba27c44fe6 100644 --- a/lib/rules/template-no-nested-landmark.js +++ b/lib/rules/template-no-nested-landmark.js @@ -1,12 +1,4 @@ -const LANDMARK_ROLES = new Set([ - 'banner', - 'complementary', - 'contentinfo', - 'form', - 'main', - 'navigation', - 'search', -]); +const { LANDMARK_ROLES } = require('../utils/landmark-roles'); const LANDMARK_ELEMENTS = new Set(['header', 'aside', 'footer', 'form', 'main', 'nav']); diff --git a/lib/rules/template-no-redundant-role.js b/lib/rules/template-no-redundant-role.js index 604274b545..712f2a223f 100644 --- a/lib/rules/template-no-redundant-role.js +++ b/lib/rules/template-no-redundant-role.js @@ -1,4 +1,5 @@ const { roles } = require('aria-query'); +const { LANDMARK_ROLES } = require('../utils/landmark-roles'); const DEFAULT_CONFIG = { checkAllHTMLElements: true, @@ -19,17 +20,6 @@ function createErrorMessageAnyElement(element, role) { return `Use of redundant or invalid role: ${role} on <${element}> detected.`; } -// https://www.w3.org/TR/html-aria/#docconformance -const LANDMARK_ROLES = new Set([ - 'banner', - 'main', - 'complementary', - 'search', - 'form', - 'navigation', - 'contentinfo', -]); - const ALLOWED_ELEMENT_ROLES = [ { name: 'nav', role: 'navigation' }, { name: 'form', role: 'search' }, @@ -114,7 +104,12 @@ const ROLE_TO_ELEMENTS = { navigation: ['nav'], option: ['option'], radio: ['input'], - region: ['section'], + // `region` is intentionally NOT mapped to `
` here. `
` + // only gets the `region` landmark role when it has an accessible name + // (aria-label / aria-labelledby / title); without one it has role + // `generic`. A static linter cannot verify accessible-name presence. + // Spec: https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/HTML5.html + // See #2694 where the same reasoning was applied to template-no-nested-landmark. row: ['tr'], rowgroup: ['tbody', 'tfoot', 'thead'], rowheader: ['th'], diff --git a/lib/utils/landmark-roles.js b/lib/utils/landmark-roles.js new file mode 100644 index 0000000000..360ee6a752 --- /dev/null +++ b/lib/utils/landmark-roles.js @@ -0,0 +1,46 @@ +'use strict'; + +const { roles } = require('aria-query'); + +// Non-abstract landmark roles derived from aria-query's role taxonomy: any +// role whose superClass chain includes 'landmark', minus DPub-ARIA `doc-*` +// (which share that superClass but are outside this plugin's rules' scope — +// downstream callers can extend if needed). The exact size is intentionally +// not hard-coded: the derivation is what matters, so if aria-query adds a +// new non-abstract landmark upstream it will be picked up automatically. +const ALL_LANDMARK_ROLES = buildLandmarkRoleSet(); + +// The subset that's safe for static-linting rules to treat as landmarks +// without further verification. `region` is EXCLUDED because a static linter +// cannot determine at lint time whether the element has an accessible name +// (via aria-label / aria-labelledby / title), which is required for the +// `region` role to actually apply to a `
` per HTML-AAM. +// +// See PR #2694 for the full rationale and spec citation +// (https://www.w3.org/TR/html-aam-1.0/#el-section): +// `
` without an accessible name has role `generic`, not `region`. +// +// Most a11y rules that enumerate landmarks should use this subset. +// Rules that DO verify accessible names (e.g. template-no-duplicate- +// landmark-elements, which inspects aria-label / aria-labelledby before +// classifying a node as a landmark) should import ALL_LANDMARK_ROLES. +const LANDMARK_ROLES = new Set([...ALL_LANDMARK_ROLES].filter((role) => role !== 'region')); + +function buildLandmarkRoleSet() { + const result = new Set(); + for (const [role, def] of roles) { + if (def.abstract) { + continue; + } + if (role.startsWith('doc-')) { + continue; + } + const descendsFromLandmark = (def.superClass || []).some((chain) => chain.includes('landmark')); + if (descendsFromLandmark) { + result.add(role); + } + } + return result; +} + +module.exports = { LANDMARK_ROLES, ALL_LANDMARK_ROLES }; diff --git a/tests/lib/rules/template-no-nested-landmark.js b/tests/lib/rules/template-no-nested-landmark.js index 0b99cbe0a1..c042b3f68a 100644 --- a/tests/lib/rules/template-no-nested-landmark.js +++ b/tests/lib/rules/template-no-nested-landmark.js @@ -45,7 +45,7 @@ ruleTester.run('template-no-nested-landmark', rule, { // `
` only gets the `region` landmark role when it has an accessible name // (aria-label/aria-labelledby/title). Without one it has the generic role — see - // https://www.w3.org/TR/html-aam-1.0/#el-section + // https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/HTML5.html // This rule does not inspect accessible names, so unnamed sections are excluded. '', // `role="region"` is the landmark role a named `
` gets. Nesting it is diff --git a/tests/lib/rules/template-no-redundant-role.js b/tests/lib/rules/template-no-redundant-role.js index b3e5b983f6..1575ca061e 100644 --- a/tests/lib/rules/template-no-redundant-role.js +++ b/tests/lib/rules/template-no-redundant-role.js @@ -66,6 +66,17 @@ ruleTester.run('template-no-redundant-role', rule, { //