|
| 1 | +// Audit fixture — translated test cases from peer plugins to measure |
| 2 | +// behavioral parity of `ember/template-no-role-presentation-on-focusable` |
| 3 | +// against vuejs-accessibility/no-role-presentation-on-focusable. |
| 4 | +// |
| 5 | +// These tests are NOT part of the main suite and do not run in CI. They encode |
| 6 | +// the CURRENT behavior of our rule so that running this file reports pass. |
| 7 | +// Each divergence from an upstream plugin is annotated as "DIVERGENCE —". |
| 8 | +// |
| 9 | +// Only vuejs-accessibility carries this rule among surveyed peer a11y plugins. |
| 10 | +// |
| 11 | +// Source file: |
| 12 | +// - context/eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/no-role-presentation-on-focusable.test.ts |
| 13 | + |
| 14 | +'use strict'; |
| 15 | + |
| 16 | +const rule = require('../../../lib/rules/template-no-role-presentation-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-role-presentation-on-focusable (gts)', rule, { |
| 25 | + valid: [ |
| 26 | + // === Upstream parity (valid in both vue-a11y and us) === |
| 27 | + // No role at all on focusable element — both skip. |
| 28 | + '<template><button>Submit</button></template>', |
| 29 | + // Nested focusable without role — both skip. |
| 30 | + '<template><div><button>Submit</button></div></template>', |
| 31 | + // `a` with tabindex='-1' but no role — both skip. |
| 32 | + '<template><a href="#" tabindex="-1">link</a></template>', |
| 33 | + |
| 34 | + // Non-focusable container with role="presentation" wrapping a focusable |
| 35 | + // child that is itself defocused via tabindex='-1'. Vue: valid (its |
| 36 | + // hasFocusableElements recurses, but finds no focusable descendant because |
| 37 | + // the child's tabindex='-1' short-circuits). Ours: valid for the outer div |
| 38 | + // (div isn't focusable itself) AND for the inner button (no role on it). |
| 39 | + // Happens to match vue — but note the mechanisms differ: |
| 40 | + // - Vue's rule checks element + descendants. |
| 41 | + // - Our rule checks only the element bearing the role. |
| 42 | + '<template><div role="presentation"><button tabindex="-1">Some text</button></div></template>', |
| 43 | + |
| 44 | + // Same pattern with an `a` child that is defocused via tabindex='-1'. |
| 45 | + '<template><div role="presentation"><a href="#" tabindex="-1">Link</a></div></template>', |
| 46 | + ], |
| 47 | + |
| 48 | + invalid: [ |
| 49 | + // === Upstream parity (invalid in both vue-a11y and us) === |
| 50 | + // <button> is inherently focusable — both flag. |
| 51 | + { |
| 52 | + code: '<template><button type="button" role="presentation">Submit</button></template>', |
| 53 | + output: null, |
| 54 | + errors: [{ messageId: 'invalidPresentation' }], |
| 55 | + }, |
| 56 | + // <a href> is inherently focusable — both flag. |
| 57 | + { |
| 58 | + code: '<template><a href="#" role="presentation">Link</a></template>', |
| 59 | + output: null, |
| 60 | + errors: [{ messageId: 'invalidPresentation' }], |
| 61 | + }, |
| 62 | + // <span tabindex="0"> is focusable via tabindex — both flag. |
| 63 | + { |
| 64 | + code: '<template><span tabindex="0" role="presentation"><em>Icon</em></span></template>', |
| 65 | + output: null, |
| 66 | + errors: [{ messageId: 'invalidPresentation' }], |
| 67 | + }, |
| 68 | + |
| 69 | + // === INTENTIONAL DIVERGENCE — tabindex="-1" on inherently-focusable === |
| 70 | + // Vue: VALID — `<button tabindex='-1' role='presentation'>Press</button>`. |
| 71 | + // Vue's hasFocusableElements treats an interactive element with |
| 72 | + // tabindex='-1' as non-focusable. |
| 73 | + // Our rule: INVALID — a `<button>` with `tabindex='-1'` is still |
| 74 | + // programmatically focusable (focus() / sequential-focus-exclusion only |
| 75 | + // removes it from tab order). The element can still receive focus, at |
| 76 | + // which point it's announced by AT. Flagging is intentional. |
| 77 | + // Same tabindex stance taken by `template-no-aria-hidden-on-focusable` |
| 78 | + // (#19) — see PR #22 body for rationale. |
| 79 | + { |
| 80 | + code: '<template><button tabindex="-1" role="presentation">Press</button></template>', |
| 81 | + output: null, |
| 82 | + errors: [{ messageId: 'invalidPresentation' }], |
| 83 | + }, |
| 84 | + ], |
| 85 | +}); |
| 86 | + |
| 87 | +// === INTENTIONAL DIVERGENCE — scope: element-only vs. element + descendants === |
| 88 | +// Vue's rule flags a non-focusable container with role="presentation" when any |
| 89 | +// DESCENDANT is focusable: |
| 90 | +// <div role='presentation'><button>Submit</button></div> → vue: INVALID |
| 91 | +// Our rule only inspects the element bearing the role. A non-focusable wrapper |
| 92 | +// is left alone; the authoring error vue targets (stripping semantics from |
| 93 | +// a container whose children remain focusable) is not detected by us. |
| 94 | +// |
| 95 | +// This is a known gap, not a bug fix waiting to happen: |
| 96 | +// - The WAI-ARIA §4.6 conflict-resolution text is phrased for elements with |
| 97 | +// role=presentation that are themselves focusable. Applying it to ancestors |
| 98 | +// is vue's interpretation, not a spec mandate. |
| 99 | +// - Our rule is narrower and has fewer false positives, at the cost of this |
| 100 | +// true positive. |
| 101 | +// Captured here as a "valid" bucket for OUR rule to make the divergence |
| 102 | +// explicit in the audit output. |
| 103 | +ruleTester.run('audit:no-role-presentation-on-focusable — wrapper scope (gts)', rule, { |
| 104 | + valid: [ |
| 105 | + // Vue: INVALID (outer div flagged because descendant <button> is focusable). |
| 106 | + // Ours: VALID (div isn't itself focusable; we don't descend). |
| 107 | + '<template><div role="presentation"><button>Submit</button></div></template>', |
| 108 | + // Same shape with <a href>. |
| 109 | + '<template><div role="presentation"><a href="#">Link</a></div></template>', |
| 110 | + // Same shape with tabindex on a descendant. |
| 111 | + '<template><div role="presentation"><span tabindex="0">Focusable</span></div></template>', |
| 112 | + ], |
| 113 | + invalid: [], |
| 114 | +}); |
| 115 | + |
| 116 | +// === INTENTIONAL EXTENSION — role="none" treated identically to "presentation" === |
| 117 | +// Vue's test suite exercises only role="presentation". Per WAI-ARIA 1.2, |
| 118 | +// role="none" is a synonym introduced to avoid the misleading "presentation" |
| 119 | +// name. Our rule flags both. No conflict with vue — vue simply doesn't test |
| 120 | +// "none" — but captured here so the extension is visible in the audit. |
| 121 | +ruleTester.run('audit:no-role-presentation-on-focusable — role="none" extension (gts)', rule, { |
| 122 | + valid: [ |
| 123 | + // Non-focusable element with role="none" — fine. |
| 124 | + '<template><div role="none"></div></template>', |
| 125 | + ], |
| 126 | + invalid: [ |
| 127 | + // Inherently focusable element with role="none" — we flag. |
| 128 | + { |
| 129 | + code: '<template><button role="none">Click</button></template>', |
| 130 | + output: null, |
| 131 | + errors: [{ messageId: 'invalidPresentation' }], |
| 132 | + }, |
| 133 | + // <a href> with role="none" — we flag. |
| 134 | + { |
| 135 | + code: '<template><a href="/x" role="none">Link</a></template>', |
| 136 | + output: null, |
| 137 | + errors: [{ messageId: 'invalidPresentation' }], |
| 138 | + }, |
| 139 | + ], |
| 140 | +}); |
| 141 | + |
| 142 | +const hbsRuleTester = new RuleTester({ |
| 143 | + parser: require.resolve('ember-eslint-parser/hbs'), |
| 144 | + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, |
| 145 | +}); |
| 146 | + |
| 147 | +hbsRuleTester.run('audit:no-role-presentation-on-focusable (hbs)', rule, { |
| 148 | + valid: [ |
| 149 | + // Upstream parity — no role. |
| 150 | + '<button>Submit</button>', |
| 151 | + '<div><button>Submit</button></div>', |
| 152 | + '<a href="#" tabindex="-1">link</a>', |
| 153 | + // Upstream parity — wrapper whose descendants are all defocused. |
| 154 | + '<div role="presentation"><button tabindex="-1">Some text</button></div>', |
| 155 | + '<div role="presentation"><a href="#" tabindex="-1">Link</a></div>', |
| 156 | + |
| 157 | + // DIVERGENCE (wrapper scope) — captured as valid for us; vue flags. |
| 158 | + '<div role="presentation"><button>Submit</button></div>', |
| 159 | + '<div role="presentation"><a href="#">Link</a></div>', |
| 160 | + |
| 161 | + // EXTENSION — role="none" on non-focusable element is fine for us. |
| 162 | + '<div role="none"></div>', |
| 163 | + ], |
| 164 | + invalid: [ |
| 165 | + // Upstream parity — both flag. |
| 166 | + { |
| 167 | + code: '<button type="button" role="presentation">Submit</button>', |
| 168 | + output: null, |
| 169 | + errors: [{ messageId: 'invalidPresentation' }], |
| 170 | + }, |
| 171 | + { |
| 172 | + code: '<a href="#" role="presentation">Link</a>', |
| 173 | + output: null, |
| 174 | + errors: [{ messageId: 'invalidPresentation' }], |
| 175 | + }, |
| 176 | + { |
| 177 | + code: '<span tabindex="0" role="presentation"><em>Icon</em></span>', |
| 178 | + output: null, |
| 179 | + errors: [{ messageId: 'invalidPresentation' }], |
| 180 | + }, |
| 181 | + |
| 182 | + // INTENTIONAL DIVERGENCE — tabindex="-1" on button. Vue: valid. Ours: flag. |
| 183 | + { |
| 184 | + code: '<button tabindex="-1" role="presentation">Press</button>', |
| 185 | + output: null, |
| 186 | + errors: [{ messageId: 'invalidPresentation' }], |
| 187 | + }, |
| 188 | + |
| 189 | + // INTENTIONAL EXTENSION — role="none" treated as presentation. |
| 190 | + { |
| 191 | + code: '<button role="none">Click</button>', |
| 192 | + output: null, |
| 193 | + errors: [{ messageId: 'invalidPresentation' }], |
| 194 | + }, |
| 195 | + ], |
| 196 | +}); |
| 197 | + |
| 198 | +// AUDIT-SKIP — none. |
| 199 | +// All vue-a11y test cases from no-role-presentation-on-focusable.test.ts are |
| 200 | +// represented above (6 valid + 4 invalid in the source file). |
0 commit comments