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
+
+ Click me
+
+```
+
+```gjs
+
+ Press key
+
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+
+
+
+```
+
+```gjs
+
+ Click me
+
+```
+
+```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: 'Link',
+ output: null,
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Interactive
',
+ output: null,
+ },
+ {
+ filename: 'test.gjs',
+ code: 'No handlers
',
+ output: null,
+ },
+ {
+ filename: 'test.gjs',
+ code: '',
+ output: null,
+ },
+
+ // Test cases ported from ember-template-lint
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '
',
+ '
',
+ '',
+ '',
+ '{{#with (hash bar=(component "foo")) as |foo|}}{{/with}}',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '
',
+ '',
+ ' ',
+ '',
+ '
',
+ '
',
+ ],
+
+ invalid: [
+ {
+ filename: 'test.gjs',
+ code: 'Click me
',
+ output: null,
+ errors: [
+ {
+ messageId: 'noInvalidInteractive',
+ data: { tagName: 'div', handler: 'onclick' },
+ },
+ ],
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Press key',
+ output: null,
+ errors: [
+ {
+ messageId: 'noInvalidInteractive',
+ data: { tagName: 'span', handler: 'onkeydown' },
+ },
+ ],
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Double click
',
+ 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' }],
+ },
+ ],
+});