eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
233 lines (203 loc) • 7.13 kB
JavaScript
const { roles, elementRoles } = require('aria-query');
function getStaticAttrValue(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
if (!attr) {
return undefined;
}
if (!attr.value || attr.value.type !== 'GlimmerTextNode') {
// Presence with dynamic value — treat as "set" but unknown string.
return '';
}
return attr.value.chars.trim();
}
function nodeSatisfiesAttributeConstraint(node, attrSpec) {
const value = getStaticAttrValue(node, attrSpec.name);
const isSet = value !== undefined;
if (attrSpec.constraints?.includes('set')) {
return isSet;
}
if (attrSpec.constraints?.includes('undefined')) {
return !isSet;
}
if (attrSpec.value !== undefined) {
// HTML enumerated attribute values are ASCII case-insensitive
// (HTML common-microsyntaxes §2.3.3). aria-query's attrSpec.value is
// already lowercase, so lowercase the node's value for comparison.
return isSet && value.toLowerCase() === attrSpec.value;
}
// No constraint listed — just require presence.
return isSet;
}
function keyMatchesNode(node, key) {
if (key.name !== node.tag) {
return false;
}
if (!key.attributes || key.attributes.length === 0) {
return true;
}
return key.attributes.every((attrSpec) => nodeSatisfiesAttributeConstraint(node, attrSpec));
}
// Pre-index elementRoles by tag name at module load. aria-query's Map is
// static data; bucketing by tag turns the per-call scan (~80 keys) into a
// 1–5 key lookup per tag. Benchmarked at ~2.6× speedup on realistic
// 200k-call workloads; parity verified across representative tag/attr
// combinations before landing.
const ELEMENT_ROLES_KEYS_BY_TAG = buildElementRolesIndex();
function buildElementRolesIndex() {
const index = new Map();
for (const key of elementRoles.keys()) {
if (!index.has(key.name)) {
index.set(key.name, []);
}
index.get(key.name).push(key);
}
return index;
}
function getImplicitRole(node) {
// Honor aria-query's attribute constraints when mapping element -> implicit role.
// Each elementRoles entry lists attributes that must match (with optional
// constraints "set" / "undefined"); pick the most specific entry whose
// attribute spec is fully satisfied by the node.
//
// Heuristic: "specificity = attribute-constraint count". aria-query exports
// elementRoles as an unordered Map and does not document how consumers
// should resolve multi-match cases; this count-based tiebreak is an
// inference from the data shape. It resolves the motivating bugs:
// - <input type="text"> without `list` → textbox, not combobox
// (the combobox entry requires `list=set`, a stricter 2-attr match;
// the textbox entry's 1-attr type=text wins when `list` is absent).
// - <input type="password"> → no role (no elementRoles entry matches).
// If aria-query ever publishes a resolution order, switch to that.
const keys = ELEMENT_ROLES_KEYS_BY_TAG.get(node.tag);
if (!keys) {
return undefined;
}
let bestKey;
let bestSpecificity = -1;
for (const key of keys) {
if (!keyMatchesNode(node, key)) {
continue;
}
const specificity = key.attributes?.length ?? 0;
if (specificity > bestSpecificity) {
bestKey = key;
bestSpecificity = specificity;
}
}
if (!bestKey) {
return undefined;
}
return elementRoles.get(bestKey)[0];
}
function getExplicitRole(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
if (roleAttr && roleAttr.value?.type === 'GlimmerTextNode') {
return roleAttr.value.chars.trim();
}
return null;
}
function removeRangeWithAdjacentWhitespace(sourceText, range) {
let [start, end] = range;
if (sourceText[end - 1] === ' ') {
return [start, end];
}
if (sourceText[start - 1] === ' ') {
start -= 1;
} else if (sourceText[end] === ' ') {
end += 1;
}
return [start, end];
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow ARIA attributes that are not supported by the element role',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unsupported-role-attributes.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
unsupportedExplicit: 'The attribute {{attribute}} is not supported by the role {{role}}',
unsupportedImplicit:
'The attribute {{attribute}} is not supported by the element {{element}} with the implicit role of {{role}}',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-unsupported-role-attributes.js',
docs: 'docs/rule/no-unsupported-role-attributes.md',
tests: 'test/unit/rules/no-unsupported-role-attributes-test.js',
},
},
create(context) {
const sourceCode = context.sourceCode;
function reportUnsupported(node, invalidNode, attribute, role, element) {
const messageId = element ? 'unsupportedImplicit' : 'unsupportedExplicit';
context.report({
node,
messageId,
data: element ? { attribute, role, element } : { attribute, role },
fix(fixer) {
const [start, end] = removeRangeWithAdjacentWhitespace(
sourceCode.getText(),
invalidNode.range
);
return fixer.removeRange([start, end]);
},
});
}
return {
GlimmerElementNode(node) {
let role = getExplicitRole(node);
let element;
if (!role) {
element = node.tag;
role = getImplicitRole(node);
}
if (!role) {
return;
}
const roleDefinition = roles.get(role);
if (!roleDefinition) {
return;
}
const supportedProps = Object.keys(roleDefinition.props);
for (const attr of node.attributes || []) {
if (attr.type !== 'GlimmerAttrNode' || !attr.name?.startsWith('aria-')) {
continue;
}
if (!supportedProps.includes(attr.name)) {
reportUnsupported(node, attr, attr.name, role, element);
}
}
},
GlimmerMustacheStatement(node) {
if (!node.hash || !node.hash.pairs) {
return;
}
const rolePair = node.hash.pairs.find((pair) => pair.key === 'role');
if (!rolePair || rolePair.value?.type !== 'GlimmerStringLiteral') {
return;
}
const role = rolePair.value.value;
if (!role) {
return;
}
const roleDefinition = roles.get(role);
if (!roleDefinition) {
return;
}
const supportedProps = Object.keys(roleDefinition.props);
const ariaPairs = node.hash.pairs.filter((pair) => pair.key.startsWith('aria-'));
for (const pair of ariaPairs) {
if (!supportedProps.includes(pair.key)) {
reportUnsupported(node, pair, pair.key, role);
}
}
},
};
},
};