Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/rules/template-no-invalid-interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ Examples of **correct** code for this rule:
</template>
```

## Escape hatches

An element opts out of this rule's handler-on-non-interactive check in two cases:

- **`aria-hidden="true"`** (and the spec-truthy `{{true}}` / `{{"true"}}` forms) — the author has explicitly removed the element from the accessibility tree, so a "non-interactive element with handler" is moot; AT users won't encounter it either way. The rule additionally treats valueless `aria-hidden` and `aria-hidden=""` as opt-out (see caveat below). Explicit `aria-hidden="false"` / `{{false}}` still flags.
- **`role="presentation"` / `role="none"`** — the author asserts the element is decorative. We accept `role="presentation"` and `role="none"` (case-insensitive, first recognized token of a space-separated role list per WAI-ARIA first-recognized-token rule) — a deliberate superset of [jsx-a11y's exact-match `isPresentationRole`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isPresentationRole.js) for consistency with [WAI-ARIA 1.2 §4.1](https://www.w3.org/TR/wai-aria-1.2/#host_general_role) role-fallback semantics.

The valueless `aria-hidden` case (e.g. `<div aria-hidden>`) is [genuinely contested](https://www.scottohara.me/blog/2018/05/05/hidden-vs-none.html) — jsx-a11y, vue-a11y, axe-core, and the WAI-ARIA spec take four different positions on whether it counts as "hidden". This rule leans toward fewer false positives: flagging a handler on an author-decorated element creates friction more often than it catches real bugs.

### Related checks outside this rule's scope

- **`role="presentation"` on focusable elements** — per [WAI-ARIA 1.2 §4.6 Conflict Resolution](https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none), browsers ignore `role="presentation"` on focusable elements. [axe-core's `presentation-role-conflict`](https://dequeuniversity.com/rules/axe/4.10/presentation-role-conflict) flags this pattern as an authoring error. This rule does not detect the conflict: the escape hatch check runs before `isInteractive(node)`, so a focusable element with `role="presentation"` returns early and is silently exempted. Interactive-handler cases on plain `<button>` / `<a href>` etc. are still flagged normally when they lack the presentation/none opt-out. If you want to catch the authoring error itself (role=presentation on a focusable element, which has no effect at runtime), layer axe-core or a dedicated rule on top.
- **Click handler on a non-focusable decorated element** — e.g. `<div role="presentation" {{on "click"}}>`. Our escape hatch silences this by design (jsx-a11y-compat). [axe-core's `click-events-have-key-events`](https://dequeuniversity.com/rules/axe/4.10/click-events-have-key-events) is the complementary check. If you want strictness, layer it on top.

## Options

| Name | Type | Default | Description |
Expand Down
69 changes: 68 additions & 1 deletion lib/rules/template-no-invalid-interactive.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const { roles } = require('aria-query');
const { isNativeElement } = require('../utils/is-native-element');
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
Expand All @@ -8,14 +9,71 @@ function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}

function findAttr(node, name) {
return node.attributes?.find((a) => a.name === name);
}

function getTextAttr(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
const attr = findAttr(node, name);
if (attr?.value?.type === 'GlimmerTextNode') {
return attr.value.chars;
}
return undefined;
}

// Does this element carry a non-interactive escape hatch that opts it out
// of the interactive-handler check?
// - role="presentation" or role="none": author asserts the element is
// decorative. We find the first recognized ARIA role token in the
// space-separated list (per WAI-ARIA first-recognized-token rule) and
// check whether it is "presentation" or "none" — matching jsx-a11y's
// isPresentationRole semantics while correctly handling unknown leading
// tokens such as role="foo none".
// - aria-hidden in any plausibly-"hide" form — valueless, empty-string,
// "true" (case-insensitive), `{{true}}`, `{{"true"}}`.
//
// The valueless/empty case is genuinely contested in the ecosystem (see
// PR body: four positions across jsx-a11y / vue-a11y / axe / spec). We
// lean toward fewer false positives — flagging a handler on an author-
// decorated element creates friction more often than it catches real bugs.
// Explicit `aria-hidden="false"` / `{{false}}` still flags.
function hasNonInteractiveEscapeHatch(node) {
const roleAttr = findAttr(node, 'role');
if (roleAttr?.value?.type === 'GlimmerTextNode') {
const tokens = roleAttr.value.chars.trim().toLowerCase().split(/\s+/u);
// WAI-ARIA first-recognized-token: skip unknown tokens, use the first
// one that aria-query recognizes as a valid role.
const firstRecognized = tokens.find((t) => roles.has(t));
if (firstRecognized === 'presentation' || firstRecognized === 'none') {
return true;
}
}

const ariaHidden = findAttr(node, 'aria-hidden');
if (ariaHidden) {
if (!ariaHidden.value) {
return true;
}
if (ariaHidden.value.type === 'GlimmerTextNode') {
const chars = ariaHidden.value.chars.trim().toLowerCase();
if (chars === '' || chars === 'true') {
return true;
}
}
if (ariaHidden.value.type === 'GlimmerMustacheStatement') {
const path = ariaHidden.value.path;
if (path?.type === 'GlimmerBooleanLiteral' && path.value === true) {
return true;
}
if (path?.type === 'GlimmerStringLiteral' && path.value.toLowerCase() === 'true') {
return true;
}
}
}

return false;
}

const DISALLOWED_DOM_EVENTS = new Set([
// Mouse events:
'click',
Expand Down Expand Up @@ -143,6 +201,15 @@ module.exports = {
return;
}

// Skip elements that opt out of interactive semantics via
// `role="presentation"` / `role="none"` or `aria-hidden`. These are
// the same escape hatches honored by jsx-a11y
// (`isPresentationRole` + aria-hidden handling in `isInteractiveElement`)
// and vuejs-accessibility.
if (hasNonInteractiveEscapeHatch(node)) {
return;
}

// Skip if element is interactive
if (isInteractive(node)) {
return;
Expand Down
114 changes: 104 additions & 10 deletions tests/lib/rules/template-no-invalid-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,40 @@ ruleTester.run('template-no-invalid-interactive', rule, {
// Their a11y contract is author-defined; ESLint can't introspect.
'<template><my-element onclick={{this.handler}}></my-element></template>',
'<template><x-foo {{on "click" this.handler}}></x-foo></template>',

// Non-interactive escape hatches:
// - role="presentation" / role="none" (author-declared decorative);
// - aria-hidden in any plausibly-"hide" form.
// Valueless/empty aria-hidden is contested in the ecosystem (see PR body
// for the four positions); we lean fewer-false-positives and treat it as
// an escape hatch. Explicit aria-hidden="false" / {{false}} still flags.
'<template><div role="presentation" onclick={{this.h}}></div></template>',
'<template><div role="none" onclick={{this.h}}></div></template>',
'<template><div role="presentation" {{on "click" this.h}}></div></template>',
'<template><div role="none" {{action "foo"}}></div></template>',
'<template><div aria-hidden onclick={{this.h}}></div></template>',
'<template><div aria-hidden="" onclick={{this.h}}></div></template>',
'<template><div aria-hidden="true" onclick={{this.h}}></div></template>',
'<template><div aria-hidden="TRUE" onclick={{this.h}}></div></template>',
'<template><div aria-hidden={{true}} onclick={{this.h}}></div></template>',
'<template><div aria-hidden={{"true"}} onclick={{this.h}}></div></template>',
'<template><div aria-hidden="true" {{on "click" this.h}}></div></template>',
// Case-insensitive / whitespace tolerance on role values.
'<template><div role=" Presentation " onclick={{this.h}}></div></template>',
'<template><div role="NONE" onclick={{this.h}}></div></template>',

// DIVERGENCE from jsx-a11y no-static: <a tabindex="0"> without href — jsx-a11y
// still flags it because the anchor has no href. Our rule treats any tabindex
// value as making the element interactive, so this is valid.
'<template><a tabindex="0" onclick={{this.h}}>L</a></template>',

// Non-disallowed handlers — onmouseenter / onmouseleave / oncontextmenu /
// ondrag* are NOT in DISALLOWED_DOM_EVENTS. Aligns with jsx-a11y recommended;
// diverges from jsx-a11y strict (which flags these on non-interactive elements).
'<template><div onmouseenter={{this.h}}></div></template>',
'<template><div onmouseleave={{this.h}}></div></template>',
'<template><div oncontextmenu={{this.h}}></div></template>',
'<template><div ondrag={{this.h}}></div></template>',
],

invalid: [
Expand Down Expand Up @@ -219,17 +253,77 @@ ruleTester.run('template-no-invalid-interactive', rule, {
],
},
{
// role="tooltip" is document-structure per WAI-ARIA 1.2 §5.3.3 — NOT
// a widget, so a handler on it is as invalid as a handler on a bare div.
filename: 'test.gjs',
code: '<template><div role="tooltip" onclick={{this.show}}>Tip</div></template>',
// aria-hidden="false" is opt-in to exposure — rule still flags non-interactive + handler.
code: '<template><div aria-hidden="false" onclick={{this.h}}></div></template>',
output: null,
errors: [
{
messageId: 'noInvalidInteractive',
data: { tagName: 'div', handler: 'onclick' },
},
],
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
code: '<template><div aria-hidden={{false}} onclick={{this.h}}></div></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
// `role="note"` is neither presentation/none nor an interactive role.
code: '<template><div role="note" onclick={{this.h}}></div></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
// DIVERGENCE from jsx-a11y no-static: aria-label on section makes it VALID
// in jsx-a11y's no-static rule (treated as interactive-signal), but our rule
// determines interactivity from element type / role alone, not aria-label.
code: '<template><section onclick={{this.h}} aria-label="Nav area"></section></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
// DIVERGENCE from jsx-a11y: menuitem and datalist are in jsx-a11y's
// alwaysInteractive set but not in our NATIVE_INTERACTIVE_ELEMENTS — flagged.
code: '<template><menuitem onclick={{this.h}}></menuitem></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
code: '<template><datalist onclick={{this.h}}></datalist></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
// DIVERGENCE from jsx-a11y: <input type="hidden"> is VALID in jsx-a11y
// (treated as interactive). Our rule explicitly excludes hidden inputs from
// native-interactive — no user-facing surface, so the handler is invalid.
code: '<template><input type="hidden" onclick={{this.h}} /></template>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
});

hbsRuleTester.run('template-no-invalid-interactive', rule, {
valid: [
// Escape hatches: role="presentation" / aria-hidden suppresses the check.
'<div role="presentation" onclick={{this.h}}></div>',
'<div role="none" {{on "click" this.h}}></div>',
'<div aria-hidden="true" onclick={{this.h}}></div>',
'<div aria-hidden {{on "click" this.h}}></div>',
],
invalid: [
{
// role="note" is not presentation/none and not interactive — still flags.
code: '<div role="note" onclick={{this.h}}></div>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
{
// aria-hidden="false" opts in to exposure — handler still flagged.
code: '<div aria-hidden="false" onclick={{this.h}}></div>',
output: null,
errors: [{ messageId: 'noInvalidInteractive' }],
},
],
});
Loading