Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 23 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions docs/rules/template-require-presentational-children.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# ember/template-require-presentational-children

<!-- end auto-generated rule header -->

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 `<svg>` tags will not be checked by this rule, as
they have somewhat special semantics.

## Examples

This rule **forbids** the following:

```gjs
<template>
<li role="tab"><h3>Title of My Tab</h3></li>
</template>
```

```gjs
<template>
<div role="button">
<h2 role="presentation">
<button>Test <img /></button>
</h2>
</div>
</template>
```

This rule **allows** the following:

```gjs
<template>
<li role="tab">Title of My Tab</li>
</template>
```

```gjs
<template>
<li role="tab"><h3 role="presentation">Title of My Tab</h3></li>
</template>
```

## 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)
158 changes: 158 additions & 0 deletions lib/rules/template-require-presentational-children.js
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
}
},
};
},
};
Loading
Loading