Skip to content

Commit 5427f19

Browse files
committed
test: add Phase 3 audit fixture for click-events-have-key-events
Translates peer-plugin test cases (jsx-a11y, vuejs-accessibility, angular-eslint template, lit-a11y) into HBS/gts form to measure behavioral parity with `ember/template-click-events-have-key-events`. Covered peers: jsx-a11y/click-events-have-key-events, vuejs-accessibility/click-events-have-key-events, angular-eslint template/click-events-have-key-events, lit-a11y/click-events-have-key-events. Counts: 59 cases (37 valid, 22 invalid) across gts + hbs RuleTesters. Divergences annotated inline include: <option> flagged by us but valid in peers, <input type="hidden"> flagged by us, widget-role elements (div/span/p with role="button") flagged by us but valid in angular, and custom-element defaults (we skip, lit flags). Fixture is not part of CI; it encodes current behavior so the run passes on the feat branch.
1 parent 036131a commit 5427f19

1 file changed

Lines changed: 355 additions & 0 deletions

File tree

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
// Audit fixture — translated test cases from peer plugins to measure
2+
// behavioral parity of `ember/template-click-events-have-key-events` against
3+
// jsx-a11y/click-events-have-key-events, vuejs-accessibility/click-events-have-key-events,
4+
// angular-eslint template/click-events-have-key-events, and lit-a11y/click-events-have-key-events.
5+
//
6+
// These tests are NOT part of the main suite and do not run in CI. They encode
7+
// the CURRENT behavior of our rule so that running this file reports pass.
8+
// Each divergence from an upstream plugin is annotated as "DIVERGENCE —".
9+
//
10+
// Source files (context/ checkouts):
11+
// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/click-events-have-key-events-test.js
12+
// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/click-events-have-key-events.test.ts
13+
// - angular-eslint-main/packages/eslint-plugin-template/tests/rules/click-events-have-key-events/cases.ts
14+
// - eslint-plugin-lit-a11y/tests/lib/rules/click-events-have-key-events.js
15+
//
16+
// Translation notes:
17+
// JSX `onClick={fn}` / Vue `@click='fn'` / Angular `(click)="fn()"` / Lit `@click=${fn}`
18+
// → HBS `{{on "click" this.fn}}` (element modifier, which is what our rule inspects).
19+
// JSX `onKeyDown`/`onKeyUp`/`onKeyPress` → `{{on "keydown"}}` etc.
20+
// JSX attribute values like `aria-hidden={true}`, `role={undefined}`, and spread
21+
// (`{...props}`) have no direct HBS equivalent; those cases are dropped with
22+
// AUDIT-SKIP notes where they don't translate.
23+
24+
'use strict';
25+
26+
const rule = require('../../../lib/rules/template-click-events-have-key-events');
27+
const RuleTester = require('eslint').RuleTester;
28+
29+
const ruleTester = new RuleTester({
30+
parser: require.resolve('ember-eslint-parser'),
31+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
32+
});
33+
34+
ruleTester.run('audit:click-events-have-key-events (gts)', rule, {
35+
valid: [
36+
// === Upstream parity — click paired with a keyboard listener ===
37+
// jsx-a11y / vue / lit all: valid.
38+
'<template><div {{on "click" this.onClick}} {{on "keydown" this.onKey}}></div></template>',
39+
'<template><div {{on "click" this.onClick}} {{on "keyup" this.onKey}}></div></template>',
40+
'<template><div {{on "click" this.onClick}} {{on "keypress" this.onKey}}></div></template>',
41+
'<template><div {{on "click" this.onClick}} {{on "keydown" this.a}} {{on "keyup" this.b}}></div></template>',
42+
43+
// === Upstream parity — no click handler at all ===
44+
// jsx-a11y: valid (`<div className="foo" />`).
45+
// vue: valid (`<div class='void 0' />`).
46+
'<template><div class="foo"></div></template>',
47+
48+
// === Upstream parity — aria-hidden on clickable element ===
49+
// jsx-a11y: valid (`aria-hidden`, `aria-hidden={true}`).
50+
// vue: valid (`aria-hidden`, `aria-hidden='true'`).
51+
// lit: valid.
52+
// angular: valid (static `aria-hidden`, `aria-hidden="true"`).
53+
'<template><div aria-hidden {{on "click" this.onClick}}></div></template>',
54+
'<template><div aria-hidden="true" {{on "click" this.onClick}}></div></template>',
55+
56+
// aria-hidden=false paired with a keyboard listener → still valid because
57+
// the keyboard listener is present.
58+
'<template><div aria-hidden="false" {{on "click" this.a}} {{on "keydown" this.b}}></div></template>',
59+
60+
// aria-hidden="undefined" with a keyboard listener → valid.
61+
// jsx-a11y: valid (`aria-hidden={undefined}` + onKeyDown).
62+
'<template><div {{on "click" this.a}} {{on "keydown" this.b}} aria-hidden="undefined"></div></template>',
63+
64+
// === Upstream parity — inherently-interactive elements ===
65+
// jsx-a11y / vue / angular: valid.
66+
'<template><input type="text" {{on "click" this.onClick}} /></template>',
67+
'<template><input {{on "click" this.onClick}} /></template>',
68+
'<template><button {{on "click" this.onClick}} class="foo"></button></template>',
69+
'<template><select {{on "click" this.onClick}} class="foo"></select></template>',
70+
'<template><textarea {{on "click" this.onClick}} class="foo"></textarea></template>',
71+
72+
// <a> becomes interactive when it has href.
73+
// jsx-a11y / vue / angular / lit: valid.
74+
'<template><a {{on "click" this.onClick}} href="http://x.y.z"></a></template>',
75+
'<template><a {{on "click" this.onClick}} href="http://x.y.z" tabindex="0"></a></template>',
76+
77+
// <input type="hidden"> is not considered interactive by our rule, but
78+
// hidden inputs are also not visible — jsx-a11y / vue exempt them.
79+
// Our rule returns early from `isInteractiveElement` for `type="hidden"`
80+
// but then still flags the click handler. See DIVERGENCE block below.
81+
82+
// Presentation role disables the check.
83+
'<template><div {{on "click" this.onClick}} role="presentation"></div></template>',
84+
'<template><div {{on "click" this.onClick}} role="none"></div></template>',
85+
86+
// === Upstream parity — components / non-DOM tags ===
87+
// jsx-a11y: valid (`<TestComponent />`, `<Button />`, `<Footer />`).
88+
// vue: valid (`<TestComponent>`, `<Button>`).
89+
// angular: valid (custom elements like `<cui-button>`).
90+
// lit: valid when `allowCustomElements` / `allowList` set (see DIVERGENCE).
91+
// Our rule: custom-element tags aren't in aria-query's `dom`, so we skip.
92+
'<template><TestComponent {{on "click" this.onClick}} /></template>',
93+
'<template><Button {{on "click" this.onClick}} /></template>',
94+
'<template><Footer {{on "click" this.onClick}} /></template>',
95+
'<template><cui-button {{on "click" this.onClick}}></cui-button></template>',
96+
97+
// === Upstream parity — `<div role="button">` etc. ===
98+
// angular: valid (click handler on a widget-role element is considered
99+
// opt-in-interactive and not flagged by their rule).
100+
// jsx-a11y does not include equivalent tests here, but by aria-query
101+
// `isInteractiveRole` the same behavior applies.
102+
// Our rule: does NOT check ARIA roles for the interactive check — it only
103+
// looks at native HTML interactivity. So `<div role="button" {{on "click"}}>`
104+
// would be flagged by us. Captured below in DIVERGENCE.
105+
106+
// === DIVERGENCE — <option> treated as interactive by peers ===
107+
// jsx-a11y: valid (`<option onClick={...} className="foo" />`).
108+
// vue: valid.
109+
// aria-query includes <option> in `dom`, but our INHERENTLY_INTERACTIVE_TAGS
110+
// set does not — so we FLAG this. FALSE POSITIVE. See invalid block below.
111+
112+
// === DIVERGENCE — `<input type="hidden">` treated as valid by peers ===
113+
// jsx-a11y / vue: valid.
114+
// Our rule: `isInteractiveElement` returns false for `type="hidden"`, and
115+
// the element is not aria-hidden either, so we FLAG. FALSE POSITIVE.
116+
// See invalid block below.
117+
118+
// === DIVERGENCE — `role="button"` (widget role) treated as interactive by angular ===
119+
// angular: valid (div/span/p with role="button" is treated as interactive).
120+
// Our rule: ignores role for the interactive check, so we FLAG. FALSE POSITIVE.
121+
// See invalid block below.
122+
123+
// === DIVERGENCE — `aria-hidden="undefined"` alone (no keyboard listener) ===
124+
// jsx-a11y treats the literal `aria-hidden={undefined}` as aria-hidden
125+
// being effectively unset (test pairs it with onKeyDown so it's not a
126+
// standalone case there). Our rule reads the literal string "undefined",
127+
// which is neither empty, `"true"`, nor the boolean-attribute sentinel —
128+
// so we consider aria-hidden to be FALSY and still flag the click.
129+
// This diverges from jsx-a11y's runtime-semantics interpretation.
130+
// (No dedicated peer-valid case flips purely on this; captured as a note.)
131+
],
132+
133+
invalid: [
134+
// === Upstream parity — lone click on non-interactive element ===
135+
// jsx-a11y / vue / angular / lit: all flag.
136+
{
137+
code: '<template><div {{on "click" this.onClick}}></div></template>',
138+
output: null,
139+
errors: [{ messageId: 'needsKeyEvent' }],
140+
},
141+
{
142+
code: '<template><section {{on "click" this.onClick}}></section></template>',
143+
output: null,
144+
errors: [{ messageId: 'needsKeyEvent' }],
145+
},
146+
{
147+
code: '<template><main {{on "click" this.onClick}}></main></template>',
148+
output: null,
149+
errors: [{ messageId: 'needsKeyEvent' }],
150+
},
151+
{
152+
code: '<template><article {{on "click" this.onClick}}></article></template>',
153+
output: null,
154+
errors: [{ messageId: 'needsKeyEvent' }],
155+
},
156+
{
157+
code: '<template><header {{on "click" this.onClick}}></header></template>',
158+
output: null,
159+
errors: [{ messageId: 'needsKeyEvent' }],
160+
},
161+
{
162+
code: '<template><footer {{on "click" this.onClick}}></footer></template>',
163+
output: null,
164+
errors: [{ messageId: 'needsKeyEvent' }],
165+
},
166+
167+
// aria-hidden="false" without a keyboard listener → flagged.
168+
// jsx-a11y / vue / angular: all flag.
169+
{
170+
code: '<template><div {{on "click" this.onClick}} aria-hidden="false"></div></template>',
171+
output: null,
172+
errors: [{ messageId: 'needsKeyEvent' }],
173+
},
174+
175+
// <a> without href is not interactive → flagged.
176+
// jsx-a11y / vue / angular / lit: all flag.
177+
{
178+
code: '<template><a {{on "click" this.onClick}}></a></template>',
179+
output: null,
180+
errors: [{ messageId: 'needsKeyEvent' }],
181+
},
182+
// <a tabindex="0"> without href is still not interactive → flagged.
183+
// jsx-a11y / vue: both flag.
184+
{
185+
code: '<template><a tabindex="0" {{on "click" this.onClick}}></a></template>',
186+
output: null,
187+
errors: [{ messageId: 'needsKeyEvent' }],
188+
},
189+
190+
// Non-presentation role (e.g. role="header", role="aside") — angular flags.
191+
// Our rule: flags (only role=presentation|none exempts).
192+
{
193+
code: '<template><div {{on "click" this.onClick}} role="header"></div></template>',
194+
output: null,
195+
errors: [{ messageId: 'needsKeyEvent' }],
196+
},
197+
{
198+
code: '<template><div {{on "click" this.onClick}} role="aside"></div></template>',
199+
output: null,
200+
errors: [{ messageId: 'needsKeyEvent' }],
201+
},
202+
203+
// === DIVERGENCE — <option> flagged (peers treat as valid) ===
204+
// jsx-a11y / vue: VALID. Our rule: INVALID.
205+
{
206+
code: '<template><option {{on "click" this.onClick}} class="foo"></option></template>',
207+
output: null,
208+
errors: [{ messageId: 'needsKeyEvent' }],
209+
},
210+
211+
// === DIVERGENCE — <input type="hidden"> flagged (peers treat as valid) ===
212+
// jsx-a11y / vue: VALID (hidden inputs don't need keyboard support).
213+
// Our rule: `isInteractiveElement` short-circuits to false for type=hidden,
214+
// then the element is not aria-hidden, so we FLAG.
215+
{
216+
code: '<template><input {{on "click" this.onClick}} type="hidden" /></template>',
217+
output: null,
218+
errors: [{ messageId: 'needsKeyEvent' }],
219+
},
220+
221+
// === DIVERGENCE — widget role treated as interactive by angular ===
222+
// angular: VALID (`<div role="button" (click)>`, `<span role="button">`,
223+
// `<p role="button">`). Our rule: INVALID — role is not consulted for
224+
// interactivity. FALSE POSITIVE.
225+
{
226+
code: '<template><div {{on "click" this.onClick}} role="button"></div></template>',
227+
output: null,
228+
errors: [{ messageId: 'needsKeyEvent' }],
229+
},
230+
{
231+
code: '<template><span {{on "click" this.onClick}} role="button"></span></template>',
232+
output: null,
233+
errors: [{ messageId: 'needsKeyEvent' }],
234+
},
235+
{
236+
code: '<template><p {{on "click" this.onClick}} role="button"></p></template>',
237+
output: null,
238+
errors: [{ messageId: 'needsKeyEvent' }],
239+
},
240+
241+
// === DIVERGENCE — key pseudo-events (Angular `(keyup.enter)`) ===
242+
// angular: VALID — `<div (click)="..." (keyup.enter)="...">`.
243+
// In HBS there's no pseudo-event syntax on `{{on}}`; the equivalent would
244+
// be a bare `{{on "keyup" this.fn}}` (checked manually for Enter inside fn).
245+
// That case is already in the `valid` block. No separate invalid case.
246+
],
247+
});
248+
249+
// AUDIT-SKIP (no HBS analog):
250+
// - jsx-a11y `<div onClick role={undefined} />` — JSX allows `{undefined}` as
251+
// an attribute value; HBS treats `role={{undefined}}` as a dynamic mustache
252+
// expression with different semantics. Cannot be translated directly.
253+
// - jsx-a11y `<div onClick {...props} />` — JSX spread attributes have no
254+
// direct analog; `...attributes` in a component body is not an element-level
255+
// attribute and doesn't exercise the same code path.
256+
// - angular `<A (click) />` (uppercase `A`) — Angular's parser preserves the
257+
// uppercase tag name and the rule flags it. In our parser, `<A>` would be
258+
// treated as an Ember component (GlimmerElementNode with uppercase tag),
259+
// and `dom.has('A')` is false, so we correctly skip it as non-DOM. jsx-a11y
260+
// has no uppercase-tag test. This is coincidental parity.
261+
// - angular `ignoreWithDirectives` option tests — our rule has no options.
262+
// - lit `allowCustomElements` / `allowList` option tests — our rule has no
263+
// options; custom-element tags (with hyphen) are not in aria-query's `dom`,
264+
// so they are skipped by default. Lit FLAGS by default unless opted-out,
265+
// which is itself a divergence (see below).
266+
// - vue `<div @click='toggle'>` — unclosed tag; parser-dependent, not
267+
// meaningful in HBS.
268+
269+
// === DIVERGENCE — custom-element defaults ===
270+
// lit-a11y: custom-element tags (e.g. `<custom-button>`) are FLAGGED by default
271+
// unless `allowCustomElements: true` or `allowList: [...]` is configured.
272+
// Our rule: SKIPS all tags not in aria-query's `dom` set (which excludes
273+
// hyphenated custom elements). So `<my-widget {{on "click"}}>` is VALID for
274+
// us, matching the opt-in behavior of lit under `allowCustomElements: true`.
275+
// Difference: lit defaults to strict; we default to lenient.
276+
ruleTester.run('audit:click-events-have-key-events custom-element default (gts)', rule, {
277+
valid: [
278+
'<template><custom-button {{on "click" this.onClick}}></custom-button></template>',
279+
'<template><another-button {{on "click" this.onClick}}></another-button></template>',
280+
],
281+
invalid: [],
282+
});
283+
284+
const hbsRuleTester = new RuleTester({
285+
parser: require.resolve('ember-eslint-parser/hbs'),
286+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
287+
});
288+
289+
hbsRuleTester.run('audit:click-events-have-key-events (hbs)', rule, {
290+
valid: [
291+
// Paired keyboard listener — valid.
292+
'<div {{on "click" this.a}} {{on "keydown" this.b}}></div>',
293+
'<div {{on "click" this.a}} {{on "keyup" this.b}}></div>',
294+
'<div {{on "click" this.a}} {{on "keypress" this.b}}></div>',
295+
296+
// No click handler.
297+
'<div class="foo"></div>',
298+
299+
// aria-hidden.
300+
'<div aria-hidden {{on "click" this.a}}></div>',
301+
'<div aria-hidden="true" {{on "click" this.a}}></div>',
302+
303+
// Inherently-interactive.
304+
'<button {{on "click" this.a}}></button>',
305+
'<a href="/x" {{on "click" this.a}}></a>',
306+
'<input type="text" {{on "click" this.a}} />',
307+
308+
// Presentation / none.
309+
'<div role="presentation" {{on "click" this.a}}></div>',
310+
'<div role="none" {{on "click" this.a}}></div>',
311+
312+
// Components (non-DOM) skipped.
313+
'<TestComponent {{on "click" this.a}} />',
314+
315+
// DIVERGENCE: custom-element default (lit flags; we skip).
316+
'<custom-button {{on "click" this.a}}></custom-button>',
317+
],
318+
invalid: [
319+
// Lone click on non-interactive.
320+
{
321+
code: '<div {{on "click" this.a}}></div>',
322+
output: null,
323+
errors: [{ messageId: 'needsKeyEvent' }],
324+
},
325+
{
326+
code: '<section {{on "click" this.a}}></section>',
327+
output: null,
328+
errors: [{ messageId: 'needsKeyEvent' }],
329+
},
330+
// <a> without href.
331+
{
332+
code: '<a {{on "click" this.a}}></a>',
333+
output: null,
334+
errors: [{ messageId: 'needsKeyEvent' }],
335+
},
336+
// aria-hidden="false".
337+
{
338+
code: '<div aria-hidden="false" {{on "click" this.a}}></div>',
339+
output: null,
340+
errors: [{ messageId: 'needsKeyEvent' }],
341+
},
342+
// DIVERGENCE — <option> flagged (peers treat as valid).
343+
{
344+
code: '<option {{on "click" this.a}}></option>',
345+
output: null,
346+
errors: [{ messageId: 'needsKeyEvent' }],
347+
},
348+
// DIVERGENCE — widget role (angular treats as interactive).
349+
{
350+
code: '<div role="button" {{on "click" this.a}}></div>',
351+
output: null,
352+
errors: [{ messageId: 'needsKeyEvent' }],
353+
},
354+
],
355+
});

0 commit comments

Comments
 (0)