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..7a80cfe1ea --- /dev/null +++ b/tests/audit/mouse-events-have-key-events/peer-parity.js @@ -0,0 +1,319 @@ +// 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: `