From 006b5b9af85b719f420f1ce7cbf76a15a6dcb4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 21:26:15 +0200 Subject: [PATCH 1/2] feat: add template-mouse-events-have-key-events --- README.md | 69 ++-- .../template-mouse-events-have-key-events.md | 48 +++ .../template-mouse-events-have-key-events.js | 121 +++++++ .../peer-parity.js | 317 ++++++++++++++++++ .../template-mouse-events-have-key-events.js | 124 +++++++ 5 files changed, 645 insertions(+), 34 deletions(-) create mode 100644 docs/rules/template-mouse-events-have-key-events.md create mode 100644 lib/rules/template-mouse-events-have-key-events.js create mode 100644 tests/audit/mouse-events-have-key-events/peer-parity.js create mode 100644 tests/lib/rules/template-mouse-events-have-key-events.js diff --git a/README.md b/README.md index 406ed89301..45b59aea18 100644 --- a/README.md +++ b/README.md @@ -256,40 +256,41 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le ### Accessibility -| Name                                            | Description | 💼 | 🔧 | 💡 | -| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | -| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | | -| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | | -| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | | -| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | | -| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | | -| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | | -| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | | -| [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-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | 📋 | | | -| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | 📋 | | | -| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | 📋 | | | -| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | 📋 | | | -| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | 📋 | | | -| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | 📋 | | | -| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | 📋 | | | -| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | 📋 | | | -| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | 📋 | | | -| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | 📋 | 🔧 | | -| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | 📋 | 🔧 | | -| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | 📋 | | | -| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | 📋 | 🔧 | | -| [template-require-context-role](docs/rules/template-require-context-role.md) | require ARIA roles to be used in appropriate context | 📋 | | | -| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | 📋 | | | -| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | 📋 | | | -| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | 📋 | | | -| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | 📋 | | | -| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | 📋 | | | -| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | 📋 | | | -| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | | -| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | -| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | | +| Name                                            | Description | 💼 | 🔧 | 💡 | +| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------- | :- | :- | :- | +| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | | +| [template-mouse-events-have-key-events](docs/rules/template-mouse-events-have-key-events.md) | require mouseover/mouseout to be accompanied by focus/blur (or focusin/focusout) for keyboard-only users | | | | +| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | | +| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | | +| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | 📋 | 🔧 | | +| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | 📋 | | | +| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | 📋 | 🔧 | | +| [template-no-duplicate-landmark-elements](docs/rules/template-no-duplicate-landmark-elements.md) | disallow duplicate landmark elements without unique labels | 📋 | | | +| [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-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | 📋 | | | +| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | 📋 | | | +| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | 📋 | | | +| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | 📋 | | | +| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | 📋 | | | +| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | 📋 | | | +| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | 📋 | | | +| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | 📋 | | | +| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | 📋 | | | +| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | 📋 | 🔧 | | +| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | 📋 | 🔧 | | +| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | 📋 | | | +| [template-require-aria-activedescendant-tabindex](docs/rules/template-require-aria-activedescendant-tabindex.md) | require non-interactive elements with aria-activedescendant to have tabindex | 📋 | 🔧 | | +| [template-require-context-role](docs/rules/template-require-context-role.md) | require ARIA roles to be used in appropriate context | 📋 | | | +| [template-require-iframe-title](docs/rules/template-require-iframe-title.md) | require iframe elements to have a title attribute | 📋 | | | +| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | 📋 | | | +| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | 📋 | | | +| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | 📋 | | | +| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | 📋 | | | +| [template-require-presentational-children](docs/rules/template-require-presentational-children.md) | require presentational elements to only contain presentational children | 📋 | | | +| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | | +| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | | +| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | | ### Best Practices diff --git a/docs/rules/template-mouse-events-have-key-events.md b/docs/rules/template-mouse-events-have-key-events.md new file mode 100644 index 0000000000..6904dfe9bb --- /dev/null +++ b/docs/rules/template-mouse-events-have-key-events.md @@ -0,0 +1,48 @@ +# ember/template-mouse-events-have-key-events + + + +Enforce that `{{on "mouseover" …}}` is accompanied by `{{on "focus" …}}` / `{{on "focusin" …}}`, and `{{on "mouseout" …}}` by `{{on "blur" …}}` / `{{on "focusout" …}}`. `{{on "mouseenter" …}}` / `{{on "mouseleave" …}}` are NOT checked by default — opt in via `hoverInHandlers` / `hoverOutHandlers` options (see below). + +Keyboard-only users can't trigger mouse events. Pairing hover-in events with focus events (and hover-out events with blur events) ensures the same UI state transitions happen for keyboard navigation. + +## On "normative basis" + +[WCAG 2.1 SC 2.1.1 Keyboard (Level A)](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) requires all functionality to be operable via the keyboard. Pointer-only UI transitions (hover effects that show/hide content, highlight rows, etc.) don't satisfy this when no keyboard equivalent exists. However, this rule's specific "hover event + focus event pairing" heuristic isn't literally mandated by the SC — [Understanding 2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) allows any keyboard path. The event-pairing convention comes from [WAI-ARIA APG keyboard-interaction guidance](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) (authoring guidance, not normative), and from all four peer a11y plugins adopting it as the strongest static-analysis proxy. This rule follows the convention. + +For many simple hover effects the cleaner fix is a CSS `:hover` + `:focus` combined selector rather than paired JS handlers — [Inclusive Components: Tooltips](https://inclusive-components.design/tooltips-toggletips/) is the canonical reference. + +## Examples + +This rule **forbids** the following: + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +## Options + +- `hoverInHandlers` (default `["mouseover"]`) — which events require a focus pair. Matches jsx-a11y's default. Add `"mouseenter"` to also check the non-bubbling per-element variant. +- `hoverOutHandlers` (default `["mouseout"]`) — which events require a blur pair. Matches jsx-a11y's default. Add `"mouseleave"` to also check the non-bubbling per-element variant. + +### Why are `mouseenter` / `mouseleave` opt-in? + +`mouseenter`/`mouseleave` don't bubble — they fire once on entry/exit of the bound element, never on transitions between children. Authors frequently choose them specifically because they want a per-element effect (highlight one row, show one tooltip) that doesn't fire for every child element transition. Those effects are often cleaner to express with CSS `:hover` + `:focus` combined selectors than paired JS handlers. Flagging `mouseenter`/`mouseleave` by default therefore produces noisy false positives on a common authoring pattern. We default to jsx-a11y's narrower handler set; opt in when your project wants the wider check. + +## References + +- [WAI-ARIA APG — Keyboard Interaction](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) +- [MDN — Keyboard-navigable JavaScript widgets](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets) +- [`mouse-events-have-key-events` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/mouse-events-have-key-events.md) diff --git a/lib/rules/template-mouse-events-have-key-events.js b/lib/rules/template-mouse-events-have-key-events.js new file mode 100644 index 0000000000..2644d728dd --- /dev/null +++ b/lib/rules/template-mouse-events-have-key-events.js @@ -0,0 +1,121 @@ +'use strict'; + +const { dom } = require('aria-query'); +const { isNativeElement } = require('../utils/is-native-element'); + +// Mouse-event → focus/blur pairings. Default to jsx-a11y's handler set +// (mouseover / mouseout only) — the canonical peer-plugin default. Authors +// who also want `mouseenter` / `mouseleave` checked opt in via the +// `hoverInHandlers` / `hoverOutHandlers` config options. +// +// Note on semantics: `mouseenter`/`mouseleave` do not bubble (they fire once +// on entry/exit of the bound element), which is often why authors choose them +// over `mouseover`/`mouseout` — for purely visual, per-element hover effects. +// Those same effects may be cleanly expressed as CSS `:hover` + `:focus` +// combined selectors rather than paired JS handlers; the rule is silent on +// that authoring choice by default. +const DEFAULT_HOVER_IN_HANDLERS = ['mouseover']; +const DEFAULT_HOVER_OUT_HANDLERS = ['mouseout']; +const FOCUS_IN_EVENTS = new Set(['focus', 'focusin']); +const FOCUS_OUT_EVENTS = new Set(['blur', 'focusout']); + +function getOnModifierEventName(modifier) { + if (modifier.type !== 'GlimmerElementModifierStatement') { + return undefined; + } + if (modifier.path?.type !== 'GlimmerPathExpression' || modifier.path.original !== 'on') { + return undefined; + } + const firstParam = modifier.params?.[0]; + if (firstParam?.type === 'GlimmerStringLiteral') { + return firstParam.value; + } + return undefined; +} + +function findOnModifier(node, eventName) { + return (node.modifiers || []).find((m) => getOnModifierEventName(m) === eventName); +} + +function hasAnyEvent(node, events) { + for (const modifier of node.modifiers || []) { + const name = getOnModifierEventName(modifier); + if (name && events.has(name)) { + return true; + } + } + return false; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'require mouseover/mouseout to be accompanied by focus/blur (or focusin/focusout) for keyboard-only users', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-mouse-events-have-key-events.md', + templateMode: 'both', + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + hoverInHandlers: { type: 'array', items: { type: 'string' } }, + hoverOutHandlers: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + ], + messages: { + hoverInMissing: + '{{hoverInHandler}} must be accompanied by a focus/focusin listener for keyboard-only users.', + hoverOutMissing: + '{{hoverOutHandler}} must be accompanied by a blur/focusout listener for keyboard-only users.', + }, + }, + + create(context) { + const options = context.options[0] || {}; + const hoverInHandlers = options.hoverInHandlers || DEFAULT_HOVER_IN_HANDLERS; + const hoverOutHandlers = options.hoverOutHandlers || DEFAULT_HOVER_OUT_HANDLERS; + const sourceCode = context.sourceCode; + + return { + GlimmerElementNode(node) { + if (!isNativeElement(node, sourceCode)) { + return; + } + if (!dom.has(node.tag.toLowerCase())) { + return; + } + + // Check hover-in / focus pairing. + const hoverInMatch = hoverInHandlers + .map((event) => ({ event, modifier: findOnModifier(node, event) })) + .find(({ modifier }) => modifier !== undefined); + if (hoverInMatch && !hasAnyEvent(node, FOCUS_IN_EVENTS)) { + context.report({ + node: hoverInMatch.modifier, + messageId: 'hoverInMissing', + data: { hoverInHandler: `{{on "${hoverInMatch.event}" …}}` }, + }); + } + + // Check hover-out / blur pairing. + const hoverOutMatch = hoverOutHandlers + .map((event) => ({ event, modifier: findOnModifier(node, event) })) + .find(({ modifier }) => modifier !== undefined); + if (hoverOutMatch && !hasAnyEvent(node, FOCUS_OUT_EVENTS)) { + context.report({ + node: hoverOutMatch.modifier, + messageId: 'hoverOutMissing', + data: { hoverOutHandler: `{{on "${hoverOutMatch.event}" …}}` }, + }); + } + }, + }; + }, +}; diff --git a/tests/audit/mouse-events-have-key-events/peer-parity.js b/tests/audit/mouse-events-have-key-events/peer-parity.js new file mode 100644 index 0000000000..7275a1efc7 --- /dev/null +++ b/tests/audit/mouse-events-have-key-events/peer-parity.js @@ -0,0 +1,317 @@ +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule. Runs as part of the default Vitest suite (via the `tests/**/*.js` +// include glob) and serves double-duty: (1) auditable record of peer-parity +// divergences, (2) regression coverage pinning CURRENT behavior. Each case +// encodes what OUR rule does today; divergences from upstream plugins are +// annotated as `DIVERGENCE —`. Peer-only constructs that can't be translated +// to Ember templates (JSX spread props, Vue v-bind, Angular `$event`, +// undefined-handler expression analysis) are marked `AUDIT-SKIP`. +// +// Peers covered: jsx-a11y/mouse-events-have-key-events, +// vuejs-accessibility/mouse-events-have-key-events, +// angular-eslint-template/mouse-events-have-key-events, +// lit-a11y/mouse-events-have-key-events. +// +// Source files (context/ checkouts): +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/mouse-events-have-key-events-test.js +// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/mouse-events-have-key-events.test.ts +// - angular-eslint-main/packages/eslint-plugin-template/tests/rules/mouse-events-have-key-events/cases.ts +// - eslint-plugin-lit-a11y/tests/lib/rules/mouse-events-have-key-events.js + +'use strict'; + +const rule = require('../../../lib/rules/template-mouse-events-have-key-events'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('audit:mouse-events-have-key-events (gts)', rule, { + valid: [ + // === Upstream parity (valid in jsx-a11y / vue / angular / lit and us) === + // Base case — no listeners at all. + // jsx-a11y: `
` valid. vue: `
` valid. angular: valid. lit: valid. + '', + '', + + // mouseover paired with focus. + // jsx-a11y: `
` valid. + // vue: `
` valid. + // angular: `
` valid. + // lit: html`
` valid. + '', + + // mouseout paired with blur. Same parity as above across peers. + '', + + // Only a focus/blur handler (no hover at all) — nothing to pair against. + // jsx-a11y: valid. lit: valid. + '', + '', + + // Component / PascalCase — not a DOM element, skipped. + // jsx-a11y: `` valid (skips non-DOM). + // Our rule guards with aria-query's `dom.has(tag)` which is lowercase-only. + '', + '', + '', + '', + '', + + // Custom (dasherized) element — also not in aria-query's dom map, + // so our rule skips. lit-a11y has `allowCustomElements`/`allowList` + // options; we don't support that, but the default behavior aligns + // (no flag on unknown tags). + '', + + // === Options parity === + // jsx-a11y: empty option arrays mean "don't check any handler". + // Our rule: same — empty arrays short-circuit the .find() miss. + { + code: '', + options: [{ hoverInHandlers: [], hoverOutHandlers: [] }], + }, + + // jsx-a11y: custom single-handler option — `hoverInHandlers: ['onMouseOver']`. + // Translated to `hoverInHandlers: ['mouseover']`. + { + code: '', + options: [{ hoverInHandlers: ['mouseover'] }], + }, + + // jsx-a11y: `hoverInHandlers: ['onMouseEnter']` — only mouseenter is checked. + { + code: '', + options: [{ hoverInHandlers: ['mouseenter'] }], + }, + + // jsx-a11y: `hoverOutHandlers: ['onMouseOut']`. + { + code: '', + options: [{ hoverOutHandlers: ['mouseout'] }], + }, + + // jsx-a11y: `hoverOutHandlers: ['onMouseLeave']`. + { + code: '', + options: [{ hoverOutHandlers: ['mouseleave'] }], + }, + + // jsx-a11y: with a narrow custom list, other hover events aren't checked. + // `
` is valid when handlers are + // configured to onPointerEnter/onPointerLeave only. + // Our rule has no pointer* events built in, but users can customize the lists. + // The case translates to "if I only watch pointer events, native mouse is free". + { + code: '', + options: [{ hoverInHandlers: ['pointerenter'], hoverOutHandlers: ['pointerleave'] }], + }, + + // jsx-a11y: custom option only checks the configured handlers. + // `
` with `hoverOutHandlers: ['onPointerLeave']` → valid. + { + code: '', + options: [{ hoverOutHandlers: ['pointerleave'] }], + }, + + // angular: `` valid — custom element + // naming (dasherized) falls outside aria-query's dom map. Parity. + '', + '', + + // === PEER PARITY — default excludes mouseenter/mouseleave === + // jsx-a11y / angular / lit / us: default hoverIn = ['mouseover'] only. + // `
` (no focus) → VALID by default. + // Users who also want mouseenter/mouseleave checked add them via + // `hoverInHandlers: ['mouseover', 'mouseenter']` / similar. The opt-in + // case is captured under invalid[] below with config. + '', + '', + ], + + invalid: [ + // === Upstream parity (invalid in peers and us) === + // jsx-a11y: `
void 0} />` → error. + // vue: `
` → mouseOver error. + // angular: `
` → error. + // lit: html`
` → error. + { + code: '', + output: null, + errors: [{ messageId: 'hoverInMissing' }], + }, + + // mouseout alone — same cross-peer parity. + { + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + + // lit: `html`
`` → two errors. + // Our rule reports each pairing on the offending modifier's source location, + // so here the errors come out in source order: mouseout (hover-out) first, + // then mouseover (hover-in). jsx-a11y also reports both, in its own JSX- + // attribute order. Peer parity modulo error ordering. + { + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }, { messageId: 'hoverInMissing' }], + }, + + // angular: `
` → mouseout unpaired + // (focus does not pair with mouseout). We mirror this: hover-out requires + // blur/focusout, not focus/focusin. + { + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + + // === jsx-a11y custom-options parity === + // `{ hoverInHandlers: ['onMouseOver'], hoverOutHandlers: ['onMouseOut'] }` + // with `
` → two errors. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['mouseover'], hoverOutHandlers: ['mouseout'] }], + errors: [{ messageId: 'hoverInMissing' }, { messageId: 'hoverOutMissing' }], + }, + + // jsx-a11y: `{ hoverInHandlers: ['onPointerEnter'], hoverOutHandlers: ['onPointerLeave'] }` + // with `
` → two errors. + // Translation uses lower-case native event names. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['pointerenter'], hoverOutHandlers: ['pointerleave'] }], + errors: [{ messageId: 'hoverInMissing' }, { messageId: 'hoverOutMissing' }], + }, + + // jsx-a11y: `{ hoverInHandlers: ['onMouseOver'] }` with `
` → error. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['mouseover'] }], + errors: [{ messageId: 'hoverInMissing' }], + }, + // jsx-a11y: same shape with onPointerEnter. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['pointerenter'] }], + errors: [{ messageId: 'hoverInMissing' }], + }, + // jsx-a11y: `{ hoverOutHandlers: ['onMouseOut'] }` with `
`. + { + code: '', + output: null, + options: [{ hoverOutHandlers: ['mouseout'] }], + errors: [{ messageId: 'hoverOutMissing' }], + }, + // jsx-a11y: `{ hoverOutHandlers: ['onPointerLeave'] }` — pointerleave alone flagged. + { + code: '', + output: null, + options: [{ hoverOutHandlers: ['pointerleave'] }], + errors: [{ messageId: 'hoverOutMissing' }], + }, + + // === PEER-PARITY OPT-IN — mouseenter/mouseleave flag only with config === + // Defaults match jsx-a11y / angular / lit: mouseover/mouseout only. + // Users who want the non-bubbling per-element variants checked opt in. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['mouseover', 'mouseenter'] }], + errors: [{ messageId: 'hoverInMissing' }], + }, + { + code: '', + output: null, + options: [{ hoverOutHandlers: ['mouseout', 'mouseleave'] }], + errors: [{ messageId: 'hoverOutMissing' }], + }, + + // === AUDIT-SKIP — peer constructs that don't translate to Ember === + // + // jsx-a11y: spread props `
`. + // Rule still flags because spread doesn't count as a known onFocus. + // There is no HBS equivalent of "spread an opaque props bag onto an + // element". `...attributes` exists but is always splatted on the + // top-level element and is not a runtime object we could detect a + // "focus handler" within. SKIP. + // + // jsx-a11y: `
`. + // The rule inspects the JSX value expression and treats `undefined` as + // "no real handler". Our rule does not analyze modifier argument + // expressions — the presence of `{{on "focus" …}}` is taken at face + // value. This peer case would therefore be reported as VALID by us + // despite jsx-a11y flagging it. SKIP (not a faithful translation). + // + // vue: `@focus='null'` is treated as "no handler"; same reasoning applies. + // SKIP. + // + // lit-a11y: `allowCustomElements: false` + `allowList: ['custom-button']`. + // Our rule does not expose a custom-element policy knob; its behavior + // is always "skip non-dom-map tags". SKIP the options matrix; the + // default (no custom-element flagging) is covered in valid[] above. + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:mouse-events-have-key-events (hbs)', rule, { + valid: [ + // Peer-parity base cases in raw hbs. + '
', + '
', + '
', + '
', + '
', + // PascalCase component — not a DOM element. + '', + // Dasherized custom element — not in aria-query's dom map. + '', + // PEER PARITY — mouseenter/mouseleave excluded from default, matching + // jsx-a11y / angular / lit. + '
', + '
', + ], + invalid: [ + { + code: '
', + output: null, + errors: [{ messageId: 'hoverInMissing' }], + }, + { + code: '
', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + // PEER-PARITY OPT-IN — mouseenter/mouseleave flag only with config. + { + code: '
', + output: null, + options: [{ hoverInHandlers: ['mouseover', 'mouseenter'] }], + errors: [{ messageId: 'hoverInMissing' }], + }, + { + code: '
', + output: null, + options: [{ hoverOutHandlers: ['mouseout', 'mouseleave'] }], + errors: [{ messageId: 'hoverOutMissing' }], + }, + // angular parity — mouseout + focus is still invalid. + { + code: '
', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + ], +}); diff --git a/tests/lib/rules/template-mouse-events-have-key-events.js b/tests/lib/rules/template-mouse-events-have-key-events.js new file mode 100644 index 0000000000..666a0670c8 --- /dev/null +++ b/tests/lib/rules/template-mouse-events-have-key-events.js @@ -0,0 +1,124 @@ +'use strict'; + +const rule = require('../../../lib/rules/template-mouse-events-have-key-events'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-mouse-events-have-key-events', rule, { + valid: [ + // No mouse listeners — rule doesn't fire. + '', + '', + + // Hover-in paired with focus. + '', + '', + '', + + // Hover-out paired with blur. + '', + '', + + // Both pairings. + ``, + + // Component — not a DOM element. + '', + + // Custom element — not in aria-query's dom map. + '', + + // Default handler set matches jsx-a11y — mouseenter/mouseleave are NOT + // flagged by default (opt-in via `hoverInHandlers`/`hoverOutHandlers`). + '', + '', + + // Configurable handler set — opt in to mouseenter via config. + { + code: '', + options: [{ hoverInHandlers: ['mouseover', 'mouseenter'] }], + }, + ], + invalid: [ + { + code: '', + output: null, + errors: [{ messageId: 'hoverInMissing' }], + }, + // mouseenter flags ONLY when opted into via config. + { + code: '', + output: null, + options: [{ hoverInHandlers: ['mouseover', 'mouseenter'] }], + errors: [{ messageId: 'hoverInMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + // mouseleave flags ONLY when opted into via config. + { + code: '', + output: null, + options: [{ hoverOutHandlers: ['mouseout', 'mouseleave'] }], + errors: [{ messageId: 'hoverOutMissing' }], + }, + { + // Both unpaired → two errors on the same element. + code: '', + output: null, + errors: [{ messageId: 'hoverInMissing' }, { messageId: 'hoverOutMissing' }], + }, + { + // Hover-in paired correctly but hover-out unpaired. + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + { + // Hover-out paired with a NON-matching focus variant — still flagged. + // mouseout pairs with blur or focusout; focus alone doesn't satisfy. + code: '', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('template-mouse-events-have-key-events', rule, { + valid: [ + '
', + '
', + '
', + '', + ], + invalid: [ + { + code: '
', + output: null, + errors: [{ messageId: 'hoverInMissing' }], + }, + { + code: '
', + output: null, + errors: [{ messageId: 'hoverOutMissing' }], + }, + ], +}); From 88dc3769edb3a6209eb9f8d91d33241f19adc9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 10:31:49 +0200 Subject: [PATCH 2/2] test(template-mouse-events-have-key-events): clarify skip-attribution comments; add mouseout+focusout pairing case --- tests/audit/mouse-events-have-key-events/peer-parity.js | 8 +++++--- tests/lib/rules/template-mouse-events-have-key-events.js | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/audit/mouse-events-have-key-events/peer-parity.js b/tests/audit/mouse-events-have-key-events/peer-parity.js index 7275a1efc7..7a80cfe1ea 100644 --- a/tests/audit/mouse-events-have-key-events/peer-parity.js +++ b/tests/audit/mouse-events-have-key-events/peer-parity.js @@ -53,15 +53,17 @@ ruleTester.run('audit:mouse-events-have-key-events (gts)', rule, { // Component / PascalCase — not a DOM element, skipped. // jsx-a11y: `` valid (skips non-DOM). - // Our rule guards with aria-query's `dom.has(tag)` which is lowercase-only. + // Our rule guards with `isNativeElement` (PascalCase tags are component + // invocations in Glimmer); the aria-query `dom.has` check then narrows + // remaining native tags to HTML. '', '', '', '', '', - // Custom (dasherized) element — also not in aria-query's dom map, - // so our rule skips. lit-a11y has `allowCustomElements`/`allowList` + // Custom (dasherized) element — `isNativeElement` excludes hyphenated + // tags, so our rule skips. lit-a11y has `allowCustomElements`/`allowList` // options; we don't support that, but the default behavior aligns // (no flag on unknown tags). '', diff --git a/tests/lib/rules/template-mouse-events-have-key-events.js b/tests/lib/rules/template-mouse-events-have-key-events.js index 666a0670c8..cf258cc7c1 100644 --- a/tests/lib/rules/template-mouse-events-have-key-events.js +++ b/tests/lib/rules/template-mouse-events-have-key-events.js @@ -22,6 +22,8 @@ ruleTester.run('template-mouse-events-have-key-events', rule, { // Hover-out paired with blur. '', '', + // mouseout also pairs with focusout (the bubbling counterpart of blur). + '', // Both pairings. `