eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
265 lines (238 loc) • 9.38 kB
JavaScript
const { roles } = require('aria-query');
const { AXObjectRoles, elementAXObjects } = require('axobject-query');
// ARIA role values are whitespace-separated tokens compared ASCII-case-insensitively.
// Returns the list of normalised tokens, or undefined when the attribute is
// missing or dynamic.
function getStaticRolesFromElement(node) {
const roleAttr = node.attributes?.find((attr) => attr.name === 'role');
if (roleAttr?.value?.type === 'GlimmerTextNode') {
return splitRoleTokens(roleAttr.value.chars);
}
return undefined;
}
function getStaticRolesFromMustache(node) {
const rolePair = node.hash?.pairs?.find((pair) => pair.key === 'role');
if (rolePair?.value?.type === 'GlimmerStringLiteral') {
return splitRoleTokens(rolePair.value.value);
}
return undefined;
}
function splitRoleTokens(value) {
if (!value) {
return undefined;
}
const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean);
return tokens.length > 0 ? tokens : undefined;
}
// Reads the static lowercase value of `name` from either a GlimmerElementNode
// (angle-bracket attributes) or a GlimmerMustacheStatement (hash pairs).
// Returns undefined for dynamic values or missing attributes.
function getStaticAttrValue(node, name) {
if (node?.type === 'GlimmerElementNode') {
const attr = node.attributes?.find((a) => a.name === name);
if (attr?.value?.type === 'GlimmerTextNode') {
return attr.value.chars?.toLowerCase();
}
return undefined;
}
if (node?.type === 'GlimmerMustacheStatement') {
const pair = node.hash?.pairs?.find((p) => p.key === name);
if (pair?.value?.type === 'GlimmerStringLiteral') {
return pair.value.value?.toLowerCase();
}
return undefined;
}
return undefined;
}
// In classic Handlebars (.hbs) `{{input}}` globally resolves to Ember's
// built-in input helper, which renders a native <input>. In strict-mode
// GJS/GTS there is no corresponding lowercase `input` export from
// `@ember/component` (only the PascalCase `<Input>` component), so
// `{{input}}` in strict mode is always a user-bound identifier and cannot
// be assumed to render a native <input>. Treating it as native there
// would silently skip required-ARIA checks on arbitrary components.
function isClassicHbsFilename(context) {
const filename = context.filename || context.getFilename?.() || '';
return !filename.endsWith('.gjs') && !filename.endsWith('.gts');
}
function getTagName(node, context) {
if (node?.type === 'GlimmerElementNode') {
// HTML tag names are case-insensitive; normalize so <INPUT>/<Input> match
// the lowercase keys in AX_CONCEPTS_BY_TAG and the semantic-role maps.
return node.tag?.toLowerCase();
}
if (node?.type === 'GlimmerMustacheStatement' && node.path?.original === 'input') {
if (!context || isClassicHbsFilename(context)) {
return 'input';
}
// Strict-mode {{input}} — not the classic helper, can't claim native.
return null;
}
return null;
}
// Does this {element, role} pair match one of axobject-query's elementAXObjects
// concepts? If so, the native element exposes the role's required ARIA state
// automatically (e.g., <input type=checkbox> exposes aria-checked via the
// `checked` attribute for both role=checkbox and role=switch).
//
// Mirrors jsx-a11y's `isSemanticRoleElement` util
// (https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isSemanticRoleElement.js).
//
// Pre-indexed at module load: elementAXObjects is static data, so we resolve
// each concept's exposed-role set once (walking axObjectNames → AXObjectRoles
// → role names) and bucket concepts by tag. That turns the per-call hot path
// into O(concepts-for-this-tag × attrs-on-that-concept), which in practice
// is a handful of entries. Benchmarked at ~12.5× faster than the naive full-
// map walk on a realistic 200k-call workload.
const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
function buildAxConceptsByTag() {
const index = new Map();
for (const [concept, axObjectNames] of elementAXObjects) {
const conceptRoles = new Set();
for (const axName of axObjectNames) {
const axRoles = AXObjectRoles.get(axName);
if (!axRoles) {
continue;
}
for (const axRole of axRoles) {
conceptRoles.add(axRole.name);
}
}
const entry = { attributes: concept.attributes || [], roles: conceptRoles };
if (!index.has(concept.name)) {
index.set(concept.name, []);
}
index.get(concept.name).push(entry);
}
return index;
}
function isSemanticRoleElement(node, role, context) {
const tag = getTagName(node, context);
if (!tag || typeof role !== 'string') {
return false;
}
const entries = AX_CONCEPTS_BY_TAG.get(tag);
if (!entries) {
return false;
}
const targetRole = role.toLowerCase();
for (const { attributes, roles: conceptRoles } of entries) {
if (!conceptRoles.has(targetRole)) {
continue;
}
const allMatch = attributes.every((cAttr) => {
const nodeVal = getStaticAttrValue(node, cAttr.name);
if (nodeVal === undefined) {
return false;
}
if (cAttr.value === undefined) {
return true;
}
return nodeVal === String(cAttr.value).toLowerCase();
});
if (allMatch) {
return true;
}
}
return false;
}
// For an ARIA role-fallback list like "combobox listbox", check required
// attributes against the FIRST recognised role (the primary) per ARIA 1.2
// role-fallback semantics — a user agent picks the first role it recognises.
// Abstract roles (widget, input, command, section, … — ARIA §5.3) are
// ontology categories, not valid authoring roles, so UAs skip them too.
//
// When the primary role is a semantic-role element (axobject-query says the
// native element provides the required ARIA state natively — e.g. <input
// type=checkbox role=switch>), the element is exempt: return { role, missing: null }.
//
// Diverges from jsx-a11y, which validates every recognised token.
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context) {
for (const role of roleTokens) {
const roleDefinition = roles.get(role);
if (!roleDefinition || roleDefinition.abstract) {
continue;
}
// Semantic-role elements expose required ARIA state natively — skip.
if (isSemanticRoleElement(node, role, context)) {
return { role, missing: null };
}
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
const missingRequiredAttributes = requiredAttributes
.filter((requiredAttribute) => !foundAriaAttributes.includes(requiredAttribute))
// Sort for deterministic report order (aria-query's requiredProps
// iteration order is not guaranteed stable across versions).
.sort();
return {
role,
missing: missingRequiredAttributes.length > 0 ? missingRequiredAttributes : null,
};
}
return null;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require mandatory ARIA attributes for ARIA roles',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-mandatory-role-attributes.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
missingAttributes:
'The {{attributeWord}} {{attributes}} {{verb}} required by the role {{role}}',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/require-mandatory-role-attributes.js',
docs: 'docs/rule/require-mandatory-role-attributes.md',
tests: 'test/unit/rules/require-mandatory-role-attributes-test.js',
},
},
create(context) {
function reportMissingAttributes(node, role, missingRequiredAttributes) {
context.report({
node,
messageId: 'missingAttributes',
data: {
attributeWord: missingRequiredAttributes.length < 2 ? 'attribute' : 'attributes',
attributes: missingRequiredAttributes.join(', '),
verb: missingRequiredAttributes.length < 2 ? 'is' : 'are',
role,
},
});
}
return {
GlimmerElementNode(node) {
const roleTokens = getStaticRolesFromElement(node);
if (!roleTokens) {
return;
}
const foundAriaAttributes = (node.attributes ?? [])
.filter((attribute) => attribute.name?.startsWith('aria-'))
.map((attribute) => attribute.name);
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
if (result?.missing) {
reportMissingAttributes(node, result.role, result.missing);
}
},
GlimmerMustacheStatement(node) {
const roleTokens = getStaticRolesFromMustache(node);
if (!roleTokens) {
return;
}
const foundAriaAttributes = (node.hash?.pairs ?? [])
.filter((pair) => pair.key.startsWith('aria-'))
.map((pair) => pair.key);
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
if (result?.missing) {
reportMissingAttributes(node, result.role, result.missing);
}
},
};
},
};