eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
159 lines (144 loc) • 4.28 kB
JavaScript
// 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,
},
});
}
}
},
};
},
};