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..0e774156c7 --- /dev/null +++ b/docs/rules/template-require-presentational-children.md @@ -0,0 +1,88 @@ +# ember/template-require-presentational-children + + + +There are roles that require all children to be presentational. This rule checks +if descendants of an element with one of those roles are presentational. By +default, browsers are required to add `role="presentation"` to all descendants, +but we should not rely on browsers to do this. + +The roles that require all children to be presentational are: + +- `button` +- `checkbox` +- `img` +- `meter` +- `menuitemcheckbox` +- `menuitemradio` +- `option` +- `progressbar` +- `radio` +- `scrollbar` +- `separator` +- `slider` +- `switch` +- `tab` + +Please note that children of `` tags will not be checked by this rule, as +they have somewhat special semantics. + +## Examples + +This rule **forbids** the following: + +```gjs +
  • Title of My Tab

  • + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +## 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. + +## Configuration + +- object -- An object with the following keys: + - `additionalNonSemanticTags` -- An array of additional tags that should be considered presentation + +```json +{ + "ember/template-require-presentational-children": [ + "error", + { + "additionalNonSemanticTags": ["my-custom-element"] + } + ] +} +``` + +## References + +- [Roles That Automatically Hide Semantics by Making Their Descendants Presentational](https://w3c.github.io/aria-practices/#children_presentational) diff --git a/lib/rules/template-require-presentational-children.js b/lib/rules/template-require-presentational-children.js new file mode 100644 index 0000000000..172c18ba76 --- /dev/null +++ b/lib/rules/template-require-presentational-children.js @@ -0,0 +1,158 @@ +// 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 === 'presentation'; +} + +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: + '<{{parent}}> has a role of {{role}}, it cannot have semantic descendants like <{{child}}>', + }, + 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; + + if (ROLES_REQUIRING_PRESENTATIONAL_CHILDREN.has(role)) { + 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..e6504e5785 --- /dev/null +++ b/tests/lib/rules/template-require-presentational-children.js @@ -0,0 +1,154 @@ +//------------------------------------------------------------------------------ +// 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: [ + '', + '', + '', + '', + '', + '', + '', + ``, + ``, + ``, + { + code: '', + options: [{ additionalNonSemanticTags: ['custom-element'] }], + }, + ], + + invalid: [ + { + code: '', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like

    ', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like ', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like ', + '
    ', + '
  • Tab title
  • ', + '
  • Tab Title

  • ', + '
    ', + '', + '

    Hello

    ', + ` + + `, + ` + + Title here + + `, + ` + + <:default>Button text + + `, + { + code: '', + options: [{ additionalNonSemanticTags: ['custom-element'] }], + }, + ], + invalid: [ + { + code: '

    Test

    ', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like

    ', + }, + ], + }, + { + code: '

    ', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like ', + }, + ], + }, + { + code: '

    ', + output: null, + errors: [ + { + message: '
    has a role of button, it cannot have semantic descendants like