|
| 1 | +// Audit fixture — translated test cases from jsx-a11y to measure behavioral |
| 2 | +// parity of `ember/template-no-noninteractive-tabindex` against |
| 3 | +// jsx-a11y/no-noninteractive-tabindex. |
| 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 jsx-a11y is annotated as "DIVERGENCE —". |
| 8 | +// |
| 9 | +// Source file (context/ checkout): |
| 10 | +// - eslint-plugin-jsx-a11y-main/__tests__/src/rules/no-noninteractive-tabindex-test.js |
| 11 | +// |
| 12 | +// Translation notes: |
| 13 | +// - JSX `tabIndex="0"` → HBS `tabindex="0"`. Peer tests use camelCase |
| 14 | +// `tabIndex`; Ember is lowercase `tabindex`. |
| 15 | +// - JSX `{0}` / `{-1}` literal expressions → HBS `{{0}}` / `{{-1}}`. |
| 16 | +// |
| 17 | +// Rule-option notes: |
| 18 | +// - jsx-a11y ships two configs: `recommended` (with `roles: ['tabpanel']` |
| 19 | +// and `allowExpressionValues: true`) and `strict` (no options). Our rule |
| 20 | +// takes no options. We map jsx-a11y's RECOMMENDED config for |
| 21 | +// `allowExpressionValues`-style behavior (dynamic role expressions pass), |
| 22 | +// and jsx-a11y's STRICT config for `tabpanel` (it's flagged). Where these |
| 23 | +// collide we note the specific divergence. |
| 24 | + |
| 25 | +'use strict'; |
| 26 | + |
| 27 | +const rule = require('../../../lib/rules/template-no-noninteractive-tabindex'); |
| 28 | +const RuleTester = require('eslint').RuleTester; |
| 29 | + |
| 30 | +const ruleTester = new RuleTester({ |
| 31 | + parser: require.resolve('ember-eslint-parser'), |
| 32 | + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, |
| 33 | +}); |
| 34 | + |
| 35 | +ruleTester.run('audit:no-noninteractive-tabindex (gts)', rule, { |
| 36 | + valid: [ |
| 37 | + // === Upstream parity (alwaysValid — valid in jsx-a11y and ours) === |
| 38 | + |
| 39 | + // Component with tabindex — jsx-a11y: skipped (unknown component). |
| 40 | + // Ours: skipped (not in aria-query dom map). |
| 41 | + '<template><MyButton tabindex={{0}} /></template>', |
| 42 | + |
| 43 | + // No tabindex — rule doesn't fire. |
| 44 | + '<template><button /></template>', |
| 45 | + |
| 46 | + // Interactive native element with tabindex — valid. |
| 47 | + '<template><button tabindex="0" /></template>', |
| 48 | + '<template><button tabindex={{0}} /></template>', |
| 49 | + |
| 50 | + // No tabindex on non-interactive element — valid. |
| 51 | + '<template><div /></template>', |
| 52 | + |
| 53 | + // tabindex="-1" exemption — focusable but out of tab order. Valid in both. |
| 54 | + // (jsx-a11y: `getTabIndex` returns -1, rule short-circuits. Ours: |
| 55 | + // `getStaticTabindexValue === -1` short-circuit added in PR #24.) |
| 56 | + '<template><div tabindex="-1" /></template>', |
| 57 | + |
| 58 | + // Non-interactive element made interactive via role — valid. |
| 59 | + '<template><div role="button" tabindex="0"></div></template>', |
| 60 | + |
| 61 | + // Non-interactive role + tabindex="-1" — valid via -1 exemption. |
| 62 | + '<template><div role="article" tabindex="-1"></div></template>', |
| 63 | + |
| 64 | + // Non-interactive native element + tabindex="-1" — valid via -1 exemption. |
| 65 | + '<template><article tabindex="-1"></article></template>', |
| 66 | + |
| 67 | + // === DIVERGENCE — components whose name lowercases to a native tag === |
| 68 | + // jsx-a11y `components` setting maps `<Article>` → `article`, then treats |
| 69 | + // it like the native tag. Without such a setting jsx-a11y would treat |
| 70 | + // `<Article>` as an opaque component and skip. |
| 71 | + // Our rule lowercases `node.tag` before the `dom.has(tag)` check, so |
| 72 | + // `<Article>` becomes `article` and IS validated against the dom map. |
| 73 | + // This has two effects: |
| 74 | + // (a) `<Article tabindex="-1" />` passes via the tabindex=-1 exemption |
| 75 | + // (valid in jsx-a11y too, so no visible divergence here). |
| 76 | + // (b) `<Article tabindex={{0}} />` is FLAGGED by our rule (see invalid |
| 77 | + // section below). jsx-a11y without `components` setting: VALID (opaque |
| 78 | + // component skip). jsx-a11y with `components: {Article: 'article'}` |
| 79 | + // setting: INVALID. Our rule: INVALID regardless — false positive |
| 80 | + // for the no-settings case. |
| 81 | + // Components whose name does NOT collide with a native tag (e.g. |
| 82 | + // `<MyButton>` → `mybutton` which is not in the dom map) are correctly |
| 83 | + // skipped. |
| 84 | + '<template><Article tabindex="-1" /></template>', |
| 85 | + '<template><MyButton tabindex={{0}} /></template>', |
| 86 | + |
| 87 | + // === Upstream parity (jsx-a11y recommended valid) === |
| 88 | + |
| 89 | + // jsx-a11y recommended: `tabpanel` whitelisted via `roles` option → valid. |
| 90 | + // jsx-a11y strict: `tabpanel` flagged (it's not an interactive role). |
| 91 | + // Our rule has no options and treats `tabpanel` as non-interactive per |
| 92 | + // aria-query's role graph. See the invalid section for the strict-side |
| 93 | + // assertion. |
| 94 | + |
| 95 | + // jsx-a11y recommended: dynamic role expressions pass because |
| 96 | + // `allowExpressionValues: true`. Our rule conservatively skips dynamic |
| 97 | + // roles too. Parity with recommended config. |
| 98 | + '<template><div role={{this.role}} tabindex="0"></div></template>', |
| 99 | + |
| 100 | + // === DIVERGENCE — tabpanel tabindex classification === |
| 101 | + // jsx-a11y recommended: `<div role="tabpanel" tabindex="0" />` VALID |
| 102 | + // (tabpanel is in the recommended `roles` whitelist). |
| 103 | + // jsx-a11y strict: INVALID. |
| 104 | + // Our rule: INVALID (tabpanel is not in INTERACTIVE_ROLES). We match |
| 105 | + // jsx-a11y strict, diverge from recommended. Captured in invalid section. |
| 106 | + ], |
| 107 | + |
| 108 | + invalid: [ |
| 109 | + // === Upstream parity (neverValid — invalid in jsx-a11y and ours) === |
| 110 | + |
| 111 | + // Plain non-interactive element with tabindex="0" — invalid. |
| 112 | + { |
| 113 | + code: '<template><div tabindex="0"></div></template>', |
| 114 | + output: null, |
| 115 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 116 | + }, |
| 117 | + |
| 118 | + // Non-interactive role doesn't rescue tabindex="0" — invalid. |
| 119 | + { |
| 120 | + code: '<template><div role="article" tabindex="0"></div></template>', |
| 121 | + output: null, |
| 122 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 123 | + }, |
| 124 | + |
| 125 | + // Non-interactive native element with tabindex="0" — invalid. |
| 126 | + { |
| 127 | + code: '<template><article tabindex="0"></article></template>', |
| 128 | + output: null, |
| 129 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 130 | + }, |
| 131 | + |
| 132 | + // Mustache-number form — parity invalid. |
| 133 | + { |
| 134 | + code: '<template><article tabindex={{0}}></article></template>', |
| 135 | + output: null, |
| 136 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 137 | + }, |
| 138 | + |
| 139 | + // === DIVERGENCE — tabpanel in strict mode === |
| 140 | + // jsx-a11y strict: INVALID (tabpanel not interactive). |
| 141 | + // jsx-a11y recommended: VALID (`roles: ['tabpanel']` whitelist). |
| 142 | + // Our rule: INVALID. Matches jsx-a11y strict, diverges from recommended. |
| 143 | + { |
| 144 | + code: '<template><div role="tabpanel" tabindex="0"></div></template>', |
| 145 | + output: null, |
| 146 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 147 | + }, |
| 148 | + |
| 149 | + // === DIVERGENCE — jsx-a11y strict: expressions as role values === |
| 150 | + // jsx-a11y strict: `<div role={ROLE_BUTTON} ... tabIndex="0" />` INVALID |
| 151 | + // because `allowExpressionValues` defaults to false and `isNonLiteralProperty` |
| 152 | + // flags the expression role as unknown. |
| 153 | + // jsx-a11y recommended: VALID (allowExpressionValues: true). |
| 154 | + // Our rule: VALID — we conservatively skip dynamic roles. No invalid |
| 155 | + // assertion here; captured as a comment only. Our behavior matches |
| 156 | + // jsx-a11y RECOMMENDED, not strict. |
| 157 | + |
| 158 | + // === DIVERGENCE — component name collides with a native tag === |
| 159 | + // `<Article tabIndex={0} />` — classifications: |
| 160 | + // jsx-a11y without `components` setting: VALID (opaque component skip). |
| 161 | + // jsx-a11y with `components: {Article: 'article'}`: INVALID (article |
| 162 | + // is non-interactive). |
| 163 | + // Our rule: INVALID unconditionally. We lowercase `node.tag` before the |
| 164 | + // `dom.has(tag)` check, so `Article` → `article` is found in the dom |
| 165 | + // map and validated like the native tag. This is a FALSE POSITIVE |
| 166 | + // relative to jsx-a11y's no-settings default, and accidental parity |
| 167 | + // with jsx-a11y's components-configured mode. |
| 168 | + // Components whose lowercased name doesn't collide with a native tag |
| 169 | + // (e.g. `<MyButton>`) are correctly skipped. |
| 170 | + { |
| 171 | + code: '<template><Article tabindex={{0}} /></template>', |
| 172 | + output: null, |
| 173 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 174 | + }, |
| 175 | + ], |
| 176 | +}); |
| 177 | + |
| 178 | +const hbsRuleTester = new RuleTester({ |
| 179 | + parser: require.resolve('ember-eslint-parser/hbs'), |
| 180 | + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, |
| 181 | +}); |
| 182 | + |
| 183 | +hbsRuleTester.run('audit:no-noninteractive-tabindex (hbs)', rule, { |
| 184 | + valid: [ |
| 185 | + // Parity — interactive native element / role / tabindex="-1" exemption. |
| 186 | + '<button tabindex="0"></button>', |
| 187 | + '<div />', |
| 188 | + '<div tabindex="-1"></div>', |
| 189 | + '<div role="button" tabindex="0"></div>', |
| 190 | + '<article tabindex="-1"></article>', |
| 191 | + // Dynamic role — parity with jsx-a11y recommended (allowExpressionValues: true). |
| 192 | + '<div role={{this.role}} tabindex="0"></div>', |
| 193 | + // Non-tag-colliding component — skipped (not in aria-query dom map). |
| 194 | + // Parity with jsx-a11y's no-settings default. |
| 195 | + // (Components whose lowercased name collides with a native tag diverge; |
| 196 | + // see `<Article tabindex={{0}} />` in the gts invalid section.) |
| 197 | + '<MyButton tabindex="0" />', |
| 198 | + ], |
| 199 | + invalid: [ |
| 200 | + // Parity — neverValid cases in hbs form. |
| 201 | + { |
| 202 | + code: '<div tabindex="0"></div>', |
| 203 | + output: null, |
| 204 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 205 | + }, |
| 206 | + { |
| 207 | + code: '<div role="article" tabindex="0"></div>', |
| 208 | + output: null, |
| 209 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 210 | + }, |
| 211 | + { |
| 212 | + code: '<article tabindex="0"></article>', |
| 213 | + output: null, |
| 214 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 215 | + }, |
| 216 | + // DIVERGENCE — tabpanel strict-mode classification (see gts section). |
| 217 | + { |
| 218 | + code: '<div role="tabpanel" tabindex="0"></div>', |
| 219 | + output: null, |
| 220 | + errors: [{ messageId: 'noNonInteractiveTabindex' }], |
| 221 | + }, |
| 222 | + ], |
| 223 | +}); |
0 commit comments