eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
287 lines (252 loc) • 7.96 kB
JavaScript
function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}
function getTextAttr(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
if (attr?.value?.type === 'GlimmerTextNode') {
return attr.value.chars;
}
return undefined;
}
const DISALLOWED_DOM_EVENTS = new Set([
// Mouse events:
'click',
'dblclick',
'mousedown',
'mousemove',
'mouseover',
'mouseout',
'mouseup',
// Keyboard events:
'keydown',
'keypress',
'keyup',
]);
const ELEMENT_ALLOWED_EVENTS = {
form: new Set(['submit', 'reset', 'change']),
img: new Set(['load', 'error']),
};
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow non-interactive elements with interactive handlers',
category: 'Accessibility',
templateMode: 'both',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-interactive.md',
},
schema: [
{
type: 'object',
properties: {
additionalInteractiveTags: { type: 'array', items: { type: 'string' } },
ignoredTags: { type: 'array', items: { type: 'string' } },
ignoreTabindex: { type: 'boolean' },
ignoreUsemap: { type: 'boolean' },
},
additionalProperties: false,
},
],
messages: {
noInvalidInteractive:
'Non-interactive element <{{tagName}}> should not have interactive handler "{{handler}}".',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-invalid-interactive.js',
docs: 'docs/rule/no-invalid-interactive.md',
tests: 'test/unit/rules/no-invalid-interactive-test.js',
},
},
create(context) {
const options = context.options[0] || {};
const additionalInteractiveTags = new Set(options.additionalInteractiveTags || []);
const ignoredTags = new Set(options.ignoredTags || []);
const ignoreTabindex = options.ignoreTabindex || false;
const ignoreUsemap = options.ignoreUsemap || false;
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
'button',
'canvas',
'details',
'embed',
'iframe',
'input',
'label',
'select',
'summary',
'textarea',
]);
const INTERACTIVE_ROLES = new Set([
'button',
'checkbox',
'link',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'radio',
'scrollbar',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'textbox',
'tooltip',
'treeitem',
'combobox',
'gridcell',
]);
// eslint-disable-next-line complexity
function isInteractive(node) {
const tag = node.tag?.toLowerCase();
if (!tag) {
return false;
}
if (additionalInteractiveTags.has(tag)) {
return true;
}
// <a> is only interactive when it has href
if (tag === 'a' && hasAttr(node, 'href')) {
return true;
}
// <audio>/<video> with controls attribute is interactive
if ((tag === 'audio' || tag === 'video') && hasAttr(node, 'controls')) {
return true;
}
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
// Hidden input is not interactive
if (tag === 'input') {
const type = getTextAttr(node, 'type');
if (type === 'hidden') {
return false;
}
}
return true;
}
// Check role
const role = getTextAttr(node, 'role');
if (role && INTERACTIVE_ROLES.has(role)) {
return true;
}
// Check tabindex
if (!ignoreTabindex && hasAttr(node, 'tabindex')) {
return true;
}
// Check contenteditable
const ce = getTextAttr(node, 'contenteditable');
if (ce && ce !== 'false') {
return true;
}
// Check usemap (only on img/object)
if (!ignoreUsemap && (tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
return true;
}
return false;
}
return {
// eslint-disable-next-line complexity
GlimmerElementNode(node) {
const tag = node.tag?.toLowerCase();
if (!tag) {
return;
}
if (ignoredTags.has(tag)) {
return;
}
// Skip if element is interactive
if (isInteractive(node)) {
return;
}
// Skip components (PascalCase, @-prefixed, this.-prefixed, path-based like foo.bar)
if (
/^[A-Z]/.test(node.tag) ||
node.tag.startsWith('@') ||
node.tag.startsWith('this.') ||
node.tag.includes('.')
) {
return;
}
const allowedEvents = ELEMENT_ALLOWED_EVENTS[tag];
// Check attributes
for (const attr of node.attributes || []) {
const attrName = attr.name?.toLowerCase();
if (!attrName || attrName.startsWith('@')) {
continue;
}
const isDynamic =
attr.value?.type === 'GlimmerMustacheStatement' ||
attr.value?.type === 'GlimmerConcatStatement';
if (!isDynamic) {
continue;
}
const isOnAttr = attrName.startsWith('on') && attrName.length > 2;
const event = isOnAttr ? attrName.slice(2) : null;
// Allow element-specific events (e.g. submit/reset/change on form, load/error on img)
if (isOnAttr && event && allowedEvents?.has(event)) {
continue;
}
const isActionHelper =
attr.value?.type === 'GlimmerMustacheStatement' &&
attr.value.path?.original === 'action';
// Flag {{action}} helper used in any attribute on a non-interactive element
if (isActionHelper) {
context.report({
node,
messageId: 'noInvalidInteractive',
data: { tagName: tag, handler: attrName },
});
continue;
}
// Flag disallowed DOM events (click, mousedown, keydown, etc.) with dynamic values
if (isOnAttr && DISALLOWED_DOM_EVENTS.has(event)) {
context.report({
node,
messageId: 'noInvalidInteractive',
data: { tagName: tag, handler: attrName },
});
}
}
// Check modifiers
for (const mod of node.modifiers || []) {
const modName = mod.path?.original;
if (modName === 'on') {
const eventParam = mod.params?.[0];
const event =
eventParam?.type === 'GlimmerStringLiteral' ? eventParam.value : undefined;
// Allow element-specific events
if (event && allowedEvents?.has(event)) {
continue;
}
// Allow non-disallowed events (scroll, copy, toggle, pause, etc.)
if (event && !DISALLOWED_DOM_EVENTS.has(event)) {
continue;
}
context.report({
node,
messageId: 'noInvalidInteractive',
data: { tagName: tag, handler: '{{on}}' },
});
} else if (modName === 'action') {
// Determine the event from on= hash param (default: 'click')
let event = 'click';
const onPair = mod.hash?.pairs?.find((p) => p.key === 'on');
if (onPair) {
event = onPair.value?.value || onPair.value?.original || 'click';
}
// Allow element-specific events
if (allowedEvents?.has(event)) {
continue;
}
context.report({
node,
messageId: 'noInvalidInteractive',
data: { tagName: tag, handler: '{{action}}' },
});
}
}
},
};
},
};