From a769f4dbe00356ee6a9184cc7ca224c1e0e8a0e6 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:16:42 -0400 Subject: [PATCH 1/3] Extract rule: template-require-presentational-children --- README.md | 45 ++-- ...emplate-require-presentational-children.md | 96 +++++++++ ...emplate-require-presentational-children.js | 199 ++++++++++++++++++ ...emplate-require-presentational-children.js | 199 ++++++++++++++++++ 4 files changed, 517 insertions(+), 22 deletions(-) create mode 100644 docs/rules/template-require-presentational-children.md create mode 100644 lib/rules/template-require-presentational-children.js create mode 100644 tests/lib/rules/template-require-presentational-children.js diff --git a/README.md b/README.md index 757c32926e..8046bee21f 100644 --- a/README.md +++ b/README.md @@ -178,28 +178,29 @@ rules in templates can be disabled with eslint directives with mustache or html ### Accessibility -| Name                                    | Description | 💼 | 🔧 | 💡 | -| :----------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | -| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | | -| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | | -| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | | -| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | | -| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | | -| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | | -| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | | -| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | | -| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | | -| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | | -| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | | -| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | | -| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | | -| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | | -| [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-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | | -| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | -| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | | +| Name                                     | Description | 💼 | 🔧 | 💡 | +| :------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | +| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | | +| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | | +| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | | +| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | | +| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | | +| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | | +| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | | | | +| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | | +| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | | +| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | | +| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | | +| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | | +| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | | +| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | | +| [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-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | | | | +| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | | | | +| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | +| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | | ### Best Practices diff --git a/docs/rules/template-require-presentational-children.md b/docs/rules/template-require-presentational-children.md new file mode 100644 index 0000000000..211cb0fd37 --- /dev/null +++ b/docs/rules/template-require-presentational-children.md @@ -0,0 +1,96 @@ +# ember/template-require-presentational-children + + + +Requires presentational elements to only contain presentational children. + +When an element is marked as presentational (with `role="none"` or `role="presentation"`), its semantic children should not be present as they would be confusing to assistive technology users. + +## Rule Details + +This rule checks that elements with `role="presentation"` or `role="none"` don't contain semantic children that expect the parent's semantic structure. + +## Examples + +Examples of **incorrect** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +Examples of **correct** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Configuration + +The following values are valid configuration: + +- object -- An object with the following keys: + - `additionalNonSemanticTags` -- An array of additional tags that should be considered presentational (non-semantic). Elements matching these tags will not be flagged as semantic children violations. + +Example: + +```json +{ + "ember/template-require-presentational-children": [ + "error", + { + "additionalNonSemanticTags": ["my-custom-element"] + } + ] +} +``` + +## Migration + +If violations are found, remediation should be planned to either add `role="presentation"` to the descendants as a quickfix. A better fix is to not use semantic descendants. + +## References + +- [eslint-plugin-ember template-require-presentational-children](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-require-presentational-children.md) +- [WAI-ARIA - Presentational Roles](https://www.w3.org/TR/wai-aria-1.2/#presentation) diff --git a/lib/rules/template-require-presentational-children.js b/lib/rules/template-require-presentational-children.js new file mode 100644 index 0000000000..311091494b --- /dev/null +++ b/lib/rules/template-require-presentational-children.js @@ -0,0 +1,199 @@ +const PRESENTATIONAL_ROLES = new Set(['none', 'presentation']); + +const PRESENTATIONAL_CHILDREN = { + table: ['tr', 'td', 'th', 'thead', 'tbody', 'tfoot'], + select: ['option', 'optgroup'], + ol: ['li'], + ul: ['li'], + dl: ['dt', 'dd'], +}; + +// Roles that require all descendants to be presentational +// https://w3c.github.io/aria-practices/#children_presentational +const ROLES_REQUIRING_PRESENTATIONAL_CHILDREN = new Set([ + 'button', + 'checkbox', + 'img', + 'meter', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'progressbar', + 'radio', + 'scrollbar', + 'separator', + 'slider', + 'switch', + 'tab', +]); + +// Tags that do not have semantic meaning +const NON_SEMANTIC_TAGS = new Set([ + 'span', + 'div', + 'basefont', + 'big', + 'blink', + 'center', + 'font', + 'marquee', + 's', + 'spacer', + 'strike', + 'tt', + 'u', +]); + +const SKIPPED_TAGS = new Set([ + // SVG tags can contain a lot of special child tags + // Instead of marking all possible SVG child tags as NON_SEMANTIC_TAG, + // we skip checking this rule for presentational SVGs + 'svg', +]); + +function getRoleValue(node) { + const roleAttr = node.attributes?.find((a) => a.name === 'role'); + if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') { + return null; + } + return roleAttr.value.chars; +} + +function hasPresentationalRole(node) { + const role = getRoleValue(node); + return role !== null && PRESENTATIONAL_ROLES.has(role); +} + +function findAllSemanticDescendants(children, nonSemanticTags, results) { + for (const child of children || []) { + if (child.type === 'GlimmerElementNode') { + // If child tag starts with ':', it's a named block — skip it but recurse into its children + if (child.tag.startsWith(':')) { + findAllSemanticDescendants(child.children, nonSemanticTags, results); + continue; + } + + const isPresentational = hasPresentationalRole(child); + + // Include this node in results if it's not non-semantic and not presentational + if (!nonSemanticTags.has(child.tag) && !isPresentational) { + results.push(child); + } + + // Always recurse into children — even if the current node is presentational, + // its descendants may still be semantic and need to be reported + findAllSemanticDescendants(child.children, nonSemanticTags, results); + } + } + return results; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'require presentational elements to only contain presentational children', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-presentational-children.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + additionalNonSemanticTags: { + type: 'array', + items: { type: 'string' }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + messages: { + invalid: + 'Element <{{parent}}> has role="{{role}}" but contains semantic child <{{child}}>. Presentational elements should only contain presentational children.', + }, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/require-presentational-children.js', + docs: 'docs/rule/require-presentational-children.md', + tests: 'test/unit/rules/require-presentational-children-test.js', + }, + }, + + create(context) { + const options = context.options[0] || {}; + const nonSemanticTags = new Set([ + ...NON_SEMANTIC_TAGS, + ...(options.additionalNonSemanticTags || []), + ]); + + return { + GlimmerElementNode(node) { + const roleAttr = node.attributes?.find((a) => a.name === 'role'); + if (!roleAttr || roleAttr.value?.type !== 'GlimmerTextNode') { + return; + } + + const role = roleAttr.value.chars; + + // Case 1: Presentational role (none/presentation) on specific parent elements + if (PRESENTATIONAL_ROLES.has(role)) { + const semanticChildren = PRESENTATIONAL_CHILDREN[node.tag]; + if (!semanticChildren) { + return; + } + + if (node.children) { + for (const child of node.children) { + if ( + child.type === 'GlimmerElementNode' && + semanticChildren.includes(child.tag) && + !nonSemanticTags.has(child.tag) + ) { + context.report({ + node: child, + messageId: 'invalid', + data: { + parent: node.tag, + role, + child: child.tag, + }, + }); + } + } + } + return; + } + + // Case 2: Roles that require all descendants to be presentational + if (ROLES_REQUIRING_PRESENTATIONAL_CHILDREN.has(role)) { + // Skip SVG and similar tags + if (SKIPPED_TAGS.has(node.tag)) { + return; + } + + const semanticDescendants = findAllSemanticDescendants( + node.children, + nonSemanticTags, + [] + ); + for (const semanticChild of semanticDescendants) { + context.report({ + node: semanticChild, + messageId: 'invalid', + data: { + parent: node.tag, + role, + child: semanticChild.tag, + }, + }); + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-require-presentational-children.js b/tests/lib/rules/template-require-presentational-children.js new file mode 100644 index 0000000000..786e288b48 --- /dev/null +++ b/tests/lib/rules/template-require-presentational-children.js @@ -0,0 +1,199 @@ +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/template-require-presentational-children'); +const RuleTester = require('eslint').RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-require-presentational-children', rule, { + valid: [ + // Case 1: Presentational role on specific parent elements (ESLint-specific additions) + ``, + ``, + ``, + ``, + + // Case 2: ETL-matching tests + '', + '', + '', + '', + '', + '', + '', + ``, + ``, + ``, + { + code: '', + options: [{ additionalNonSemanticTags: ['custom-element'] }], + }, + ], + + invalid: [ + // Case 1: Presentational role on specific parent elements (ESLint-specific additions) + { + code: ``, + output: null, + errors: [ + { + message: + 'Element