From 4ce7b75e6a6fdc331fa297f0b8046785c77b9f2a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:01:54 -0500 Subject: [PATCH] Extract rule: template-no-invalid-interactive --- README.md | 1 + docs/rules/template-no-invalid-interactive.md | 59 ++++ lib/rules/template-no-invalid-interactive.js | 261 ++++++++++++++++++ .../rules/template-no-invalid-interactive.js | 144 ++++++++++ 4 files changed, 465 insertions(+) create mode 100644 docs/rules/template-no-invalid-interactive.md create mode 100644 lib/rules/template-no-invalid-interactive.js create mode 100644 tests/lib/rules/template-no-invalid-interactive.js diff --git a/README.md b/README.md index 0861a65724..0392c563c9 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | | | [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-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | | ### Best Practices diff --git a/docs/rules/template-no-invalid-interactive.md b/docs/rules/template-no-invalid-interactive.md new file mode 100644 index 0000000000..90c136e5bc --- /dev/null +++ b/docs/rules/template-no-invalid-interactive.md @@ -0,0 +1,59 @@ +# ember/template-no-invalid-interactive + + + +> Disallow non-interactive elements with interactive handlers + +## Rule Details + +This rule prevents adding interactive event handlers (like `onclick`, `onkeydown`, etc.) to non-interactive HTML elements without proper ARIA roles. + +## Examples + +Examples of **incorrect** code for this rule: + +```gjs + +``` + +```gjs + +``` + +Examples of **correct** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Options + +| Name | Type | Default | Description | +| --------------------------- | ---------- | ------- | ----------------------------------------------------------- | +| `additionalInteractiveTags` | `string[]` | `[]` | Extra tag names to treat as interactive. | +| `ignoredTags` | `string[]` | `[]` | Tag names to skip checking. | +| `ignoreTabindex` | `boolean` | `false` | If `true`, `tabindex` does not make an element interactive. | +| `ignoreUsemap` | `boolean` | `false` | If `true`, `usemap` does not make an element interactive. | + +## References + +- [WCAG 2.1 - 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html) +- [eslint-plugin-ember template-no-invalid-interactive](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-invalid-interactive.md) diff --git a/lib/rules/template-no-invalid-interactive.js b/lib/rules/template-no-invalid-interactive.js new file mode 100644 index 0000000000..d5b89dbf72 --- /dev/null +++ b/lib/rules/template-no-invalid-interactive.js @@ -0,0 +1,261 @@ +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', + strictGjs: true, + strictGts: true, + 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}}".', + }, + }, + + 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([ + 'a', + 'button', + 'canvas', + 'details', + 'embed', + 'iframe', + 'input', + 'label', + 'select', + 'textarea', + ]); + + const INTERACTIVE_ROLES = new Set([ + 'button', + 'checkbox', + 'link', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'option', + 'radio', + 'searchbox', + 'slider', + 'spinbutton', + 'switch', + 'tab', + 'textbox', + 'combobox', + 'gridcell', + ]); + + function isInteractive(node) { + const tag = node.tag?.toLowerCase(); + if (!tag) { + return false; + } + + if (additionalInteractiveTags.has(tag)) { + 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 + if (!ignoreUsemap && 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) + if (/^[A-Z]/.test(node.tag)) { + 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}}' }, + }); + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-invalid-interactive.js b/tests/lib/rules/template-no-invalid-interactive.js new file mode 100644 index 0000000000..dc94dc051a --- /dev/null +++ b/tests/lib/rules/template-no-invalid-interactive.js @@ -0,0 +1,144 @@ +const rule = require('../../../lib/rules/template-no-invalid-interactive'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-invalid-interactive', rule, { + valid: [ + { + filename: 'test.gjs', + code: '', + output: null, + }, + { + filename: 'test.gjs', + code: '', + output: null, + }, + { + filename: 'test.gjs', + code: '', + output: null, + }, + { + filename: 'test.gjs', + code: '', + output: null, + }, + { + filename: 'test.gjs', + code: '', + output: null, + }, + + // Test cases ported from ember-template-lint + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + + invalid: [ + { + filename: 'test.gjs', + code: '', + output: null, + errors: [ + { + messageId: 'noInvalidInteractive', + data: { tagName: 'div', handler: 'onclick' }, + }, + ], + }, + { + filename: 'test.gjs', + code: '', + output: null, + errors: [ + { + messageId: 'noInvalidInteractive', + data: { tagName: 'span', handler: 'onkeydown' }, + }, + ], + }, + { + filename: 'test.gjs', + code: '', + output: null, + errors: [ + { + messageId: 'noInvalidInteractive', + data: { tagName: 'p', handler: 'ondblclick' }, + }, + ], + }, + + // Test cases ported from ember-template-lint + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'noInvalidInteractive' }], + }, + ], +});