Skip to content

Commit ccbc523

Browse files
committed
test: add Phase 3 audit fixture for no-aria-hidden-on-focusable
1 parent 7dd6e17 commit ccbc523

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Audit fixture — translated test cases from peer plugins to measure
2+
// behavioral parity of `ember/template-no-aria-hidden-on-focusable` against
3+
// jsx-a11y/no-aria-hidden-on-focusable and
4+
// vuejs-accessibility/no-aria-hidden-on-focusable.
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/no-aria-hidden-on-focusable-test.js
12+
// - eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/no-aria-hidden-on-focusable.test.ts
13+
14+
'use strict';
15+
16+
const rule = require('../../../lib/rules/template-no-aria-hidden-on-focusable');
17+
const RuleTester = require('eslint').RuleTester;
18+
19+
const ruleTester = new RuleTester({
20+
parser: require.resolve('ember-eslint-parser'),
21+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
22+
});
23+
24+
ruleTester.run('audit:no-aria-hidden-on-focusable (gts)', rule, {
25+
valid: [
26+
// === Upstream parity (valid in both jsx-a11y and us) ===
27+
// jsx-a11y: valid — `<div>` is not focusable by default.
28+
'<template><div aria-hidden="true" /></template>',
29+
30+
// jsx-a11y: valid — `<img>` is not focusable by default.
31+
'<template><img aria-hidden="true" /></template>',
32+
33+
// jsx-a11y: valid — aria-hidden="false" is an explicit opt-out.
34+
'<template><a aria-hidden="false" href="#"></a></template>',
35+
36+
// jsx-a11y: valid — no aria-hidden on a focusable element.
37+
'<template><button></button></template>',
38+
'<template><a href="/"></a></template>',
39+
40+
// === jsx-a11y `<div onClick={...} aria-hidden="true" />` ===
41+
// jsx-a11y: valid — `<div>` with an onClick is still not in the rule's
42+
// focusable set (event handlers don't add tabindex). In HBS the rough
43+
// analogue is `{{on "click" this.fn}}`. Our rule also ignores modifiers,
44+
// so it's still valid.
45+
'<template><div {{on "click" this.handler}} aria-hidden="true"></div></template>',
46+
47+
// === Upstream parity with vue-a11y (valid in both) ===
48+
// vue-a11y: valid — descendant focusable with its own tabindex="-1" is
49+
// "escorted out" of the tab order. Our rule only inspects the element
50+
// that carries aria-hidden; we agree by not descending.
51+
`<template>
52+
<div aria-hidden="true">
53+
<button tabindex="-1">Some text</button>
54+
</div>
55+
</template>`,
56+
57+
// vue-a11y: valid — `<a href tabindex="-1">` is escorted out of tab order.
58+
// We don't flag because the aria-hidden is on the non-focusable <div>.
59+
`<template>
60+
<div aria-hidden="true">
61+
<a href="#" tabindex="-1">Link</a>
62+
</div>
63+
</template>`,
64+
65+
// vue-a11y: valid — no aria-hidden anywhere.
66+
`<template>
67+
<div>
68+
<button>Submit</button>
69+
</div>
70+
</template>`,
71+
72+
// vue-a11y: valid — `<a>` with tabindex="-1" but no aria-hidden.
73+
'<template><a href="#" tabindex="-1">link</a></template>',
74+
],
75+
76+
invalid: [
77+
// === Upstream parity (invalid in both jsx-a11y and us) ===
78+
// jsx-a11y: `<div aria-hidden tabIndex="0">` → focusable via tabindex.
79+
{
80+
code: '<template><div aria-hidden="true" tabindex="0"></div></template>',
81+
output: null,
82+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
83+
},
84+
// jsx-a11y: `<input>` is inherently focusable.
85+
{
86+
code: '<template><input aria-hidden="true" /></template>',
87+
output: null,
88+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
89+
},
90+
// jsx-a11y: `<a href>` is focusable.
91+
{
92+
code: '<template><a href="/" aria-hidden="true"></a></template>',
93+
output: null,
94+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
95+
},
96+
// jsx-a11y: `<button>` is inherently focusable.
97+
{
98+
code: '<template><button aria-hidden="true"></button></template>',
99+
output: null,
100+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
101+
},
102+
// jsx-a11y: `<textarea>` is inherently focusable.
103+
{
104+
code: '<template><textarea aria-hidden="true"></textarea></template>',
105+
output: null,
106+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
107+
},
108+
// jsx-a11y: `<p tabindex="0" aria-hidden>` — tabindex makes it focusable.
109+
{
110+
code: '<template><p tabindex="0" aria-hidden="true">text</p></template>',
111+
output: null,
112+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
113+
},
114+
115+
// vue-a11y: `<button type aria-hidden>` — inherently focusable.
116+
{
117+
code: `<template><button type="button" aria-hidden="true">Submit</button></template>`,
118+
output: null,
119+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
120+
},
121+
// vue-a11y: `<a href aria-hidden>` — focusable.
122+
{
123+
code: `<template><a href="#" aria-hidden="true">Link</a></template>`,
124+
output: null,
125+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
126+
},
127+
// vue-a11y: `<span tabindex="0" aria-hidden>` — tabindex makes focusable.
128+
{
129+
code: `<template><span tabindex="0" aria-hidden="true"><em>Icon</em></span></template>`,
130+
output: null,
131+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
132+
},
133+
134+
// === DIVERGENCE — `tabindex="-1"` on an inherently focusable element ===
135+
// This is the load-bearing, intentional divergence that PR #19 encodes.
136+
// jsx-a11y: VALID — `<button aria-hidden="true" tabIndex="-1" />` is
137+
// accepted; the author has acknowledged the element is "escorted out"
138+
// of the tab order.
139+
// vue-a11y: VALID — same: `<button tabindex="-1" aria-hidden="true">`
140+
// is accepted.
141+
// Our rule: INVALID. Rationale (see lib/rules/template-no-aria-hidden-on-focusable.js
142+
// lines 54-62): tabindex="-1" still makes the element *programmatically*
143+
// focusable (reachable via `.focus()` and click). Combined with
144+
// aria-hidden="true" this creates a keyboard trap / AT-invisibility
145+
// mismatch. Our rule flags any tabindex attribute on an aria-hidden
146+
// element regardless of value.
147+
// This is the DIVERGENCE the PR is defending.
148+
{
149+
code: '<template><button aria-hidden="true" tabindex="-1"></button></template>',
150+
output: null,
151+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
152+
},
153+
{
154+
code: '<template><button tabindex="-1" aria-hidden="true">Press</button></template>',
155+
output: null,
156+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
157+
},
158+
],
159+
});
160+
161+
// === DIVERGENCE — `<a>` without `href` ===
162+
// jsx-a11y: has no case for `<a aria-hidden="true">` without href. By its
163+
// focusable table, an anchor without href is not focusable → not flagged.
164+
// Our rule matches: `a` tag requires an `href` attr to be focusable. Captured
165+
// in main rule tests, no extra case needed here.
166+
//
167+
// === DIVERGENCE — `<input type="hidden">` ===
168+
// jsx-a11y: has no explicit case for this. Our rule special-cases
169+
// `<input type="hidden">` as non-focusable. Captured in main rule tests.
170+
171+
// === DIVERGENCE — vue-a11y descendant-focusable check ===
172+
// vue-a11y: INVALID when aria-hidden is on an ancestor and a focusable
173+
// descendant exists:
174+
// `<div aria-hidden="true"><button>Submit</button></div>` → flagged
175+
// Its rule descends into children and fires on the aria-hidden ancestor
176+
// if any descendant is focusable.
177+
// Our rule: VALID — we only inspect the element that carries aria-hidden.
178+
// A div is not focusable, so we do not flag. This is a deliberate scope
179+
// decision (match jsx-a11y) and leaves vue-a11y's descendant-check behavior
180+
// as a capture-only difference. The PR's doc should note that users relying
181+
// on vue-a11y's broader semantic should pair this rule with an additional
182+
// check or use `aria-hidden` sparingly on wrappers.
183+
//
184+
// Captured below as valid cases (reflecting OUR non-flagging behavior).
185+
ruleTester.run('audit:no-aria-hidden-on-focusable descendant-only (gts)', rule, {
186+
valid: [
187+
// vue-a11y flags this; we don't.
188+
`<template>
189+
<div aria-hidden="true">
190+
<button>Submit</button>
191+
</div>
192+
</template>`,
193+
],
194+
invalid: [],
195+
});
196+
197+
// === AUDIT-SKIP — curly-literal aria-hidden value forms ===
198+
// jsx-a11y: tests use string-literal `aria-hidden="true"`. It also recognizes
199+
// JSX expression forms like `aria-hidden={true}` and `aria-hidden={"true"}`.
200+
// Our rule's `isAriaHiddenTruthy` handles the HBS equivalents
201+
// (`{{true}}` and `{{"true"}}`), but neither peer plugin's upstream suite
202+
// has cases for those forms, so there is nothing to translate here. These
203+
// forms are exercised by the main rule tests.
204+
205+
const hbsRuleTester = new RuleTester({
206+
parser: require.resolve('ember-eslint-parser/hbs'),
207+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
208+
});
209+
210+
hbsRuleTester.run('audit:no-aria-hidden-on-focusable (hbs)', rule, {
211+
valid: [
212+
// Upstream parity (valid in both).
213+
'<div aria-hidden="true"></div>',
214+
'<img aria-hidden="true" />',
215+
'<a aria-hidden="false" href="#"></a>',
216+
'<button></button>',
217+
'<a href="/"></a>',
218+
219+
// vue-a11y parity — descendant has its own tabindex="-1" opt-out.
220+
'<div aria-hidden="true"><button tabindex="-1">Some text</button></div>',
221+
'<div aria-hidden="true"><a href="#" tabindex="-1">Link</a></div>',
222+
223+
// DIVERGENCE captured in hbs — we don't descend (vue-a11y does).
224+
'<div aria-hidden="true"><button>Submit</button></div>',
225+
],
226+
invalid: [
227+
{
228+
code: '<div aria-hidden="true" tabindex="0"></div>',
229+
output: null,
230+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
231+
},
232+
{
233+
code: '<input aria-hidden="true" />',
234+
output: null,
235+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
236+
},
237+
{
238+
code: '<a href="/" aria-hidden="true"></a>',
239+
output: null,
240+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
241+
},
242+
{
243+
code: '<button aria-hidden="true"></button>',
244+
output: null,
245+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
246+
},
247+
{
248+
code: '<textarea aria-hidden="true"></textarea>',
249+
output: null,
250+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
251+
},
252+
{
253+
code: '<p tabindex="0" aria-hidden="true">text</p>',
254+
output: null,
255+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
256+
},
257+
// DIVERGENCE — tabindex="-1" on an inherently focusable element.
258+
// jsx-a11y + vue-a11y: valid. Ours: invalid. Load-bearing.
259+
{
260+
code: '<button aria-hidden="true" tabindex="-1"></button>',
261+
output: null,
262+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
263+
},
264+
{
265+
code: '<button tabindex="-1" aria-hidden="true">Press</button>',
266+
output: null,
267+
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
268+
},
269+
],
270+
});

0 commit comments

Comments
 (0)