diff --git a/lib/rules/template-no-redundant-role.js b/lib/rules/template-no-redundant-role.js index abaf75cf2d..604274b545 100644 --- a/lib/rules/template-no-redundant-role.js +++ b/lib/rules/template-no-redundant-role.js @@ -1,3 +1,5 @@ +const { roles } = require('aria-query'); + const DEFAULT_CONFIG = { checkAllHTMLElements: true, }; @@ -37,6 +39,48 @@ const ALLOWED_ELEMENT_ROLES = [ { name: 'input', role: 'combobox' }, ]; +// Per HTML-AAM, `) — per HTML boolean-attr + // semantics the attribute value is an empty string, which Number() + // parses as 0. Per HTML's default size (>1 → listbox), 0 leaves the + // implicit role as combobox. Treat the same as the static-0 case. + if (!sizeAttr.value) { + return 'combobox'; + } + if (sizeAttr.value.type !== 'GlimmerTextNode') { + // Dynamic `size={{...}}` / concat — can't tell whether the runtime + // value is >1 or not, so bail out instead of risking a false positive. + return 'unknown'; + } + const sizeValue = Number(sizeAttr.value.chars); + if (Number.isFinite(sizeValue) && sizeValue > 1) { + return 'listbox'; + } + } + return 'combobox'; +} + // Mapping of roles to their corresponding HTML elements // From https://www.w3.org/TR/html-aria/ const ROLE_TO_ELEMENTS = { @@ -45,6 +89,10 @@ const ROLE_TO_ELEMENTS = { button: ['button'], cell: ['td'], checkbox: ['input'], + // 's implicit role depends on attributes (HTML-AAM): + // - default (no `multiple`, `size` absent or <= 1) → "combobox" + // - `multiple` or `size` > 1 → "listbox" + // A role attribute is only redundant when it matches the element's + // computed implicit role. Guard both combobox and listbox against + // the opposite configuration, and bail when `size` is dynamic + // ('unknown') rather than risk a false positive. + if (node.tag === 'select' && (roleValue === 'combobox' || roleValue === 'listbox')) { + const implicit = getSelectImplicitRole(node); + if (implicit !== roleValue) { + return; + } + } + const isRedundant = elementsWithRole.includes(node.tag) && !ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue); diff --git a/tests/lib/rules/template-no-redundant-role.js b/tests/lib/rules/template-no-redundant-role.js index 4d529b2fe1..b3e5b983f6 100644 --- a/tests/lib/rules/template-no-redundant-role.js +++ b/tests/lib/rules/template-no-redundant-role.js @@ -48,6 +48,24 @@ ruleTester.run('template-no-redundant-role', rule, { options: [{ checkAllHTMLElements: false }], }, '', + // (size > 1) has implicit role listbox. + '', + // Default ` — per HTML boolean-attr semantics, the + // attribute value is an empty string; Number('') is 0; 0 is NOT > 1, + // so the implicit role stays combobox. `role="combobox"` is therefore + // redundant and must be flagged. + code: '', + output: '', + errors: [ + { + message: 'Use of redundant or invalid role: combobox on with `multiple` has implicit role "listbox", so role="combobox" + // is not redundant (it disagrees with the implicit role, but that is for + // other rules to catch — this rule only flags redundancy). + '', + // ', + // Default ', + '', ], invalid: [ { @@ -243,6 +311,30 @@ hbsRuleTester.run('template-no-redundant-role', rule, { '', errors: [{ message: 'Use of redundant or invalid role: listbox on without `multiple` or `size` defaults to role "combobox". + code: '', + output: '', + errors: [{ message: 'Use of redundant or invalid role: combobox on ', + output: '', + errors: [{ message: 'Use of redundant or invalid role: combobox on , combined with the implicit-role check. + code: '', + output: '', + errors: [{ message: 'Use of redundant or invalid role: combobox on