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
+
+
+ - Item
+
+
+```
+
+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
+ '',
+ '',
+ 'Tab title',
+ 'Tab Title
',
+ '',
+ '',
+ 'Hello
',
+ `
+
+ `,
+ `
+ `,
+ `
+
+ <:default>Button text
+
+ `,
+ {
+ code: '',
+ options: [{ additionalNonSemanticTags: ['custom-element'] }],
+ },
+ ],
+
+ invalid: [
+ // Case 1: Presentational role on specific parent elements (ESLint-specific additions)
+ {
+ code: `
+
+ `,
+ output: null,
+ errors: [
+ {
+ message:
+ 'Element has role="presentation" but contains semantic child - . Presentational elements should only contain presentational children.',
+ type: 'GlimmerElementNode',
+ },
+ ],
+ },
+ {
+ code: `
+
+ `,
+ output: null,
+ errors: [
+ {
+ message:
+ 'Element
has role="none" but contains semantic child . Presentational elements should only contain presentational children.',
+ type: 'GlimmerElementNode',
+ },
+ ],
+ },
+
+ // Case 2: ETL-matching tests
+ {
+ code: 'Test
',
+ output: null,
+ errors: [{ messageId: 'invalid' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'invalid' }],
+ },
+ {
+ code: ' ',
+ output: null,
+ errors: [{ messageId: 'invalid' }, { messageId: 'invalid' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-require-presentational-children', rule, {
+ valid: [
+ '',
+ '',
+ '- Tab title
',
+ 'Tab Title
',
+ '',
+ '',
+ 'Hello
',
+ `
+
+ `,
+ `
+ `,
+ `
+
+ <:default>Button text
+
+ `,
+ {
+ code: '',
+ options: [{ additionalNonSemanticTags: ['custom-element'] }],
+ },
+ ],
+ invalid: [
+ {
+ code: 'Test
',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Element has role="button" but contains semantic child
. Presentational elements should only contain presentational children.',
+ },
+ ],
+ },
+ {
+ code: '![]()
',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Element has role="button" but contains semantic child
![]()
. Presentational elements should only contain presentational children.',
+ },
+ ],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Element
has role="button" but contains semantic child