From e8c212d64bdadd5532137ee82483fd85f8eecc9f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:22:39 -0400 Subject: [PATCH 1/3] Extract rule: template-no-redundant-role --- README.md | 1 + docs/rules/template-no-redundant-role.md | 73 ++++++ lib/rules/template-no-redundant-role.js | 175 +++++++++++++ tests/lib/rules/template-no-redundant-role.js | 239 ++++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 docs/rules/template-no-redundant-role.md create mode 100644 lib/rules/template-no-redundant-role.js create mode 100644 tests/lib/rules/template-no-redundant-role.js diff --git a/README.md b/README.md index f07fa7d607..5273e45211 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | | | [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | | | [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | | +| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | | 🔧 | | | [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | 🔧 | | | [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | | | | | [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | | 🔧 | | diff --git a/docs/rules/template-no-redundant-role.md b/docs/rules/template-no-redundant-role.md new file mode 100644 index 0000000000..64b6401fe7 --- /dev/null +++ b/docs/rules/template-no-redundant-role.md @@ -0,0 +1,73 @@ +# ember/template-no-redundant-role + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Disallows redundant role attributes on semantic HTML elements. + +The rule checks for redundancy between any semantic HTML element with a default/implicit ARIA role and the role provided. + +For example, if a landmark element is used, any role provided will either be redundant or incorrect. This rule ensures that no role attribute is placed on any of the landmark elements, with the following exceptions: + +- a `nav` element with the `navigation` role to [make the structure of the page more accessible to user agents](https://www.w3.org/WAI/GL/wiki/Using_HTML5_nav_element#Example:The_.3Cnav.3E_element) +- a `form` element with the `search` role to [identify the form's search functionality](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/search_role#examples) +- a `input` element with `combobox` role to [identify the input as a combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/) + +## Examples + +This rule **forbids** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Configuration + +This rule accepts an options object with the following properties: + +- `checkAllHTMLElements` (default: `true`) - When set to `true`, checks all HTML elements for redundant roles. When `false`, only checks landmark elements. + +```js +// .eslintrc.js +module.exports = { + rules: { + 'ember/template-no-redundant-role': ['error', { checkAllHTMLElements: false }], + }, +}; +``` + +## References + +- [ARIA Roles](https://www.w3.org/TR/wai-aria-1.2/#role_definitions) +- [HTML ARIA](https://www.w3.org/TR/html-aria/) diff --git a/lib/rules/template-no-redundant-role.js b/lib/rules/template-no-redundant-role.js new file mode 100644 index 0000000000..3b8e38bc71 --- /dev/null +++ b/lib/rules/template-no-redundant-role.js @@ -0,0 +1,175 @@ +const DEFAULT_CONFIG = { + checkAllHTMLElements: true, +}; + +function parseConfig(config) { + if (config === true) { + return DEFAULT_CONFIG; + } + return { ...DEFAULT_CONFIG, ...config }; +} + +function createErrorMessageLandmarkElement(element, role) { + return `Use of redundant or invalid role: ${role} on <${element}> detected. If a landmark element is used, any role provided will either be redundant or incorrect.`; +} + +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' }, + { name: 'ol', role: 'list' }, + { name: 'ul', role: 'list' }, + { name: 'a', role: 'link' }, + { name: 'input', role: 'combobox' }, +]; + +// Mapping of roles to their corresponding HTML elements +// From https://www.w3.org/TR/html-aria/ +const ROLE_TO_ELEMENTS = { + article: ['article'], + banner: ['header'], + button: ['button'], + cell: ['td'], + checkbox: ['input'], + columnheader: ['th'], + complementary: ['aside'], + contentinfo: ['footer'], + definition: ['dd'], + dialog: ['dialog'], + document: ['body'], + figure: ['figure'], + form: ['form'], + grid: ['table'], + gridcell: ['td'], + group: ['details', 'fieldset', 'optgroup'], + heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + img: ['img'], + link: ['a'], + list: ['ol', 'ul'], + listbox: ['select'], + listitem: ['li'], + main: ['main'], + navigation: ['nav'], + option: ['option'], + radio: ['input'], + region: ['section'], + row: ['tr'], + rowgroup: ['tbody', 'tfoot', 'thead'], + rowheader: ['th'], + search: ['search'], + searchbox: ['input'], + separator: ['hr'], + slider: ['input'], + spinbutton: ['input'], + status: ['output'], + table: ['table'], + term: ['dfn', 'dt'], + textbox: ['input', 'textarea'], +}; + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow redundant role attributes', + category: 'Accessibility', + recommended: false, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-redundant-role.md', + templateMode: 'both', + }, + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + checkAllHTMLElements: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + messages: {}, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-redundant-role.js', + docs: 'docs/rule/no-redundant-role.md', + tests: 'test/unit/rules/no-redundant-role-test.js', + }, + }, + + create(context) { + const config = parseConfig(context.options[0]); + + return { + GlimmerElementNode(node) { + const roleAttr = node.attributes?.find((attr) => attr.name === 'role'); + + if (!roleAttr) { + return; + } + + let roleValue; + if (roleAttr.value && roleAttr.value.type === 'GlimmerTextNode') { + roleValue = roleAttr.value.chars || ''; + } else { + // Skip dynamic role values + return; + } + + const isLandmarkRole = LANDMARK_ROLES.has(roleValue); + if (!config.checkAllHTMLElements && !isLandmarkRole) { + return; + } + + const elementsWithRole = ROLE_TO_ELEMENTS[roleValue]; + if (!elementsWithRole) { + return; + } + + const isRedundant = + elementsWithRole.includes(node.tag) && + !ALLOWED_ELEMENT_ROLES.some((e) => e.name === node.tag && e.role === roleValue); + + if (isRedundant) { + const errorMessage = isLandmarkRole + ? createErrorMessageLandmarkElement(node.tag, roleValue) + : createErrorMessageAnyElement(node.tag, roleValue); + + context.report({ + node, + message: errorMessage, + fix(fixer) { + const sourceCode = context.getSourceCode(); + const elementText = sourceCode.getText(node); + const roleAttrText = sourceCode.getText(roleAttr); + + // Find the role attribute in the element text and remove it along with preceding space + const roleAttrPattern = new RegExp( + `\\s+${roleAttrText.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}` + ); + const fixedText = elementText.replace(roleAttrPattern, ''); + + return fixer.replaceText(node, fixedText); + }, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-redundant-role.js b/tests/lib/rules/template-no-redundant-role.js new file mode 100644 index 0000000000..7533e12ca8 --- /dev/null +++ b/tests/lib/rules/template-no-redundant-role.js @@ -0,0 +1,239 @@ +const rule = require('../../../lib/rules/template-no-redundant-role'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-redundant-role', rule, { + valid: [ + '', + '', + '', + '', + '', + '', + '', + { + code: '', + options: [{ checkAllHTMLElements: false }], + }, + { + code: '', + options: [{ checkAllHTMLElements: true }], + }, + { + code: '', + options: [{ checkAllHTMLElements: true }], + }, + { + code: '', + options: [{ checkAllHTMLElements: true }], + }, + { + code: '', + options: [{ checkAllHTMLElements: true }], + }, + { + code: '', + options: [{ checkAllHTMLElements: false }], + }, + { + code: '', + options: [{ checkAllHTMLElements: false }], + }, + { + code: '', + options: [{ checkAllHTMLElements: false }], + }, + '', + ], + invalid: [ + { + code: '', + output: '', + errors: [ + { + message: 'Use of redundant or invalid role: dialog on detected.', + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + message: + 'Use of redundant or invalid role: banner on
detected. If a landmark element is used, any role provided will either be redundant or incorrect.', + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + message: + 'Use of redundant or invalid role: main on
detected. If a landmark element is used, any role provided will either be redundant or incorrect.', + }, + ], + }, + { + code: '', + output: '', + errors: [ + { + message: + 'Use of redundant or invalid role: complementary on