Skip to content

Commit 0f1c698

Browse files
committed
fix: flag non-interactive HTML tags with interactive roles
Augment the non-interactive tag derivation by unioning aria-query's `elementRoles` with axobject-query's `elementAXObjects` (same approach jsx-a11y's `isNonInteractiveElement` takes). The prior axobject-query-only derivation dropped every tag whose AXObject schema had any attribute constraint, producing false negatives for 18 tags whose HTML-AAM mapping is to a non-interactive role. Tags newly flagged: section, address, aside, code, del, em, fieldset, hr, html, ins, optgroup, output, strong, sub, sup, tbody, tfoot, thead `<section role="button">`, `<fieldset role="checkbox">`, etc. are now reported — matching jsx-a11y/no-noninteractive-element-to-interactive-role. `header` is explicitly excluded (its role depends on ancestry), mirroring jsx-a11y's carve-out. `math` is newly included as a side effect and is consistent with jsx-a11y's derivation. Audit fixture updated: 23 cases moved from documented false negatives to parity-invalid.
1 parent f7ee1ce commit 0f1c698

3 files changed

Lines changed: 269 additions & 84 deletions

File tree

lib/rules/template-no-noninteractive-element-to-interactive-role.js

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,85 @@
1-
const { roles } = require('aria-query');
1+
const { elementRoles, roles } = require('aria-query');
22
const { AXObjects, elementAXObjects } = require('axobject-query');
33
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
44

5-
// Elements with inherent non-interactive accessibility-tree semantics, derived
6-
// from axobject-query: tags whose AXObjects all have type "window" or
7-
// "structure" (no "widget" participants). This is the exact source jsx-a11y,
8-
// vuejs-accessibility, and @angular-eslint/template use to answer the same
9-
// question. Yields headings, landmarks, lists, tables, forms, <img>, etc.
5+
// Elements with inherent non-interactive accessibility-tree semantics. We
6+
// union two derivations to match jsx-a11y's `isNonInteractiveElement`
7+
// (src/util/isNonInteractiveElement.js), which consults both aria-query's
8+
// elementRoles and axobject-query's elementAXObjects:
9+
//
10+
// 1. aria-query elementRoles: tags that map to at least one non-interactive
11+
// role (neither a widget descendant nor `generic`) and never to an
12+
// interactive role. Covers HTML-AAM mappings that axobject-query doesn't
13+
// (e.g. `section` → `region`, `fieldset` → `group`, `code`/`em`/`strong`
14+
// → `code`/`emphasis`/`strong`, `tbody`/`tfoot`/`thead` → `rowgroup`).
15+
//
16+
// 2. axobject-query elementAXObjects: tags whose AXObjects are exclusively
17+
// `window`/`structure` (no `widget` participants). Covers tags without
18+
// an aria-query elementRoles mapping (`abbr`, `br`, `figcaption`,
19+
// `iframe`, `label`, `legend`, `marquee`, `ruby`, `tr`, etc.).
20+
//
21+
// `header` is excluded: its mapping depends on nesting context (`banner` only
22+
// as direct child of body), which this rule cannot statically verify. This
23+
// matches jsx-a11y's explicit `if (tagName === 'header') return false` carve-
24+
// out in isNonInteractiveElement.js.
1025
const NON_INTERACTIVE_TAGS = buildNonInteractiveTagSet();
1126

1227
function buildNonInteractiveTagSet() {
28+
const tags = new Set();
29+
30+
// Derivation 1 — aria-query elementRoles.
31+
const nonInteractiveAriaRoles = new Set();
32+
for (const [role, def] of roles) {
33+
if (def.abstract) {
34+
continue;
35+
}
36+
// `generic` carries no semantics (WAI-ARIA 1.2 §5.3.3), so elements whose
37+
// only role is `generic` (div/span/header/body/pre/q/samp/b/i/u/...) must
38+
// not be classified as non-interactive — giving them any role is fine.
39+
if (role === 'generic') {
40+
continue;
41+
}
42+
const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget'));
43+
if (!descendsFromWidget) {
44+
nonInteractiveAriaRoles.add(role);
45+
}
46+
}
47+
const tagsWithOnlyNonInteractiveRole = new Set();
48+
const tagsWithAnyInteractiveRole = new Set();
49+
for (const [schema, rolesSet] of elementRoles) {
50+
const roleList = [...rolesSet];
51+
if (roleList.length > 0 && roleList.every((r) => nonInteractiveAriaRoles.has(r))) {
52+
tagsWithOnlyNonInteractiveRole.add(schema.name);
53+
}
54+
if (roleList.some((r) => INTERACTIVE_ROLES.has(r))) {
55+
tagsWithAnyInteractiveRole.add(schema.name);
56+
}
57+
}
58+
for (const tag of tagsWithOnlyNonInteractiveRole) {
59+
if (!tagsWithAnyInteractiveRole.has(tag)) {
60+
tags.add(tag);
61+
}
62+
}
63+
64+
// Derivation 2 — axobject-query elementAXObjects (unconstrained only).
1365
const nonInteractiveAXObjects = new Set(
1466
[...AXObjects.keys()].filter((name) =>
1567
['window', 'structure'].includes(AXObjects.get(name).type)
1668
)
1769
);
18-
const tags = new Set();
1970
for (const [schema, axObjectsArr] of elementAXObjects) {
20-
// Only consider elements with no attribute constraints (e.g., always
21-
// non-interactive regardless of attrs). This keeps the set simple and
22-
// conservative — dependent variants (e.g., <a href>) are excluded.
2371
if (schema.attributes && schema.attributes.length > 0) {
2472
continue;
2573
}
2674
if ([...axObjectsArr].every((o) => nonInteractiveAXObjects.has(o))) {
2775
tags.add(schema.name);
2876
}
2977
}
78+
79+
// Exclude `header` — its role depends on ancestry (banner when direct child
80+
// of body, generic otherwise). Matches jsx-a11y's carve-out.
81+
tags.delete('header');
82+
3083
return tags;
3184
}
3285

tests/audit/no-noninteractive-element-to-interactive-role/peer-parity.js

Lines changed: 172 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,18 @@
1818
// Rule under test:
1919
// - lib/rules/template-no-noninteractive-element-to-interactive-role.js
2020
// (feat/template-no-noninteractive-to-interactive-role, PR #21).
21-
// Non-interactive tag set is derived from axobject-query: any element
22-
// whose AXObject mapping is exclusively {window, structure} with no
23-
// attribute constraints. Interactive role set is shared with the sibling
24-
// rule via lib/utils/interactive-roles.js (refactored in PR #27 to
25-
// descend from the `widget` superclass in aria-query, plus `toolbar`).
21+
// Non-interactive tag set is derived by unioning aria-query's
22+
// `elementRoles` (tags mapping only to non-interactive, non-`generic`
23+
// roles) with axobject-query's `elementAXObjects` (unconstrained tags
24+
// whose AXObjects are exclusively `window`/`structure`). `header` is
25+
// excluded because its role depends on ancestry. Interactive role set
26+
// is shared with the sibling rule via lib/utils/interactive-roles.js
27+
// (refactored in PR #27 to descend from the `widget` superclass in
28+
// aria-query, plus `toolbar`). This is the same approach jsx-a11y takes
29+
// in src/util/isNonInteractiveElement.js.
2630
//
27-
// jsx-a11y, by contrast, uses a manually curated element→role map
28-
// (src/util/implicitRoles + nonInteractiveMap). The two derivations diverge
29-
// on a number of tags; those divergences are captured below. jsx-a11y also
30-
// ships :recommended and :strict variants — our rule has no options and
31-
// behaves close to jsx-a11y :strict (always flags everything), with the
31+
// jsx-a11y ships :recommended and :strict variants — our rule has no options
32+
// and behaves close to jsx-a11y :strict (always flags everything), with the
3233
// exceptions documented below.
3334

3435
'use strict';
@@ -242,50 +243,12 @@ ruleTester.run('audit:no-noninteractive-element-to-interactive-role (gts)', rule
242243
// `<h1 role="BUTTON" />` is FLAGGED by our rule. Captured in invalid
243244
// list — this is a divergence (stricter than jsx-a11y).
244245

245-
// === DIVERGENCE — section has no implicit role per jsx-a11y's map ===
246-
// jsx-a11y: `<section role="button" aria-label="...">` is INVALID in jsx-a11y
247-
// (section is in nonInteractiveMap). Test exists in neverValid.
248-
// Ours: `section` is NOT in our NON_INTERACTIVE_TAGS. axobject-query's
249-
// `<section>` mapping is attribute-conditional (becomes `region` only with
250-
// an accessible name), so our unconstrained-attributes filter excludes it.
251-
// FALSE NEGATIVE. Captured below.
252-
'<template><section role="button" aria-label="Aardvark"></section></template>',
253-
254-
// === DIVERGENCE — misc tags jsx-a11y flags that we don't ===
255-
// axobject-query assigns these tags attribute-constrained AXObjects, so
256-
// our "unconstrained non-interactive" filter drops them. jsx-a11y's
257-
// nonInteractiveMap is hand-curated and includes them. Our rule does NOT
258-
// flag the following; jsx-a11y DOES:
259-
//
260-
// address, aside, code, del, em, fieldset, hr, html, ins, optgroup,
261-
// output, strong, sub, sup, tbody, tfoot, thead
262-
//
263-
// These are FALSE NEGATIVES (we are looser than jsx-a11y).
264-
'<template><address role="button"></address></template>',
265-
'<template><aside role="button"></aside></template>',
266-
'<template><code role="button"></code></template>',
267-
'<template><del role="button"></del></template>',
268-
'<template><em role="button"></em></template>',
269-
'<template><fieldset role="button"></fieldset></template>',
270-
'<template><hr role="button" /></template>',
271-
'<template><html role="button"></html></template>',
272-
'<template><ins role="button"></ins></template>',
273-
'<template><optgroup role="button"></optgroup></template>',
274-
'<template><output role="button"></output></template>',
275-
'<template><strong role="button"></strong></template>',
276-
'<template><sub role="button"></sub></template>',
277-
'<template><sup role="button"></sup></template>',
278-
'<template><tbody role="button"></tbody></template>',
279-
'<template><tfoot role="button"></tfoot></template>',
280-
'<template><thead role="button"></thead></template>',
281-
282-
// jsx-a11y flags <dd role="menuitem"> but we flag also (parity, in invalid).
283-
// jsx-a11y flags the following but we DO NOT (same false-negative family):
284-
'<template><fieldset role="menuitem"></fieldset></template>',
285-
'<template><hr role="menuitem" /></template>',
286-
'<template><tbody role="menuitem"></tbody></template>',
287-
'<template><tfoot role="menuitem"></tfoot></template>',
288-
'<template><thead role="menuitem"></thead></template>',
246+
// Previously-documented FALSE NEGATIVES for `section`, `address`, `aside`,
247+
// `code`, `del`, `em`, `fieldset`, `hr`, `html`, `ins`, `optgroup`,
248+
// `output`, `strong`, `sub`, `sup`, `tbody`, `tfoot`, `thead` were
249+
// resolved by augmenting the tag derivation with aria-query's elementRoles
250+
// (jsx-a11y does the same in isNonInteractiveElement). Those cases have
251+
// moved to the invalid list below.
289252
],
290253

291254
invalid: [
@@ -575,6 +538,128 @@ ruleTester.run('audit:no-noninteractive-element-to-interactive-role (gts)', rule
575538
errors: [{ messageId: 'mismatch' }],
576539
},
577540

541+
// === Upstream parity — HTML-AAM non-interactive tags captured via the
542+
// aria-query `elementRoles` augmentation (section → region, fieldset →
543+
// group, code/em/strong → code/emphasis/strong, tbody/tfoot/thead →
544+
// rowgroup, etc.). Prior to the elementRoles derivation these were false
545+
// negatives relative to jsx-a11y; the rule now flags them. ===
546+
{
547+
code: '<template><section role="button" aria-label="Aardvark"></section></template>',
548+
output: null,
549+
errors: [{ messageId: 'mismatch' }],
550+
},
551+
{
552+
code: '<template><address role="button"></address></template>',
553+
output: null,
554+
errors: [{ messageId: 'mismatch' }],
555+
},
556+
{
557+
code: '<template><aside role="button"></aside></template>',
558+
output: null,
559+
errors: [{ messageId: 'mismatch' }],
560+
},
561+
{
562+
code: '<template><code role="button"></code></template>',
563+
output: null,
564+
errors: [{ messageId: 'mismatch' }],
565+
},
566+
{
567+
code: '<template><del role="button"></del></template>',
568+
output: null,
569+
errors: [{ messageId: 'mismatch' }],
570+
},
571+
{
572+
code: '<template><em role="button"></em></template>',
573+
output: null,
574+
errors: [{ messageId: 'mismatch' }],
575+
},
576+
{
577+
code: '<template><fieldset role="button"></fieldset></template>',
578+
output: null,
579+
errors: [{ messageId: 'mismatch' }],
580+
},
581+
{
582+
code: '<template><hr role="button" /></template>',
583+
output: null,
584+
errors: [{ messageId: 'mismatch' }],
585+
},
586+
{
587+
code: '<template><html role="button"></html></template>',
588+
output: null,
589+
errors: [{ messageId: 'mismatch' }],
590+
},
591+
{
592+
code: '<template><ins role="button"></ins></template>',
593+
output: null,
594+
errors: [{ messageId: 'mismatch' }],
595+
},
596+
{
597+
code: '<template><optgroup role="button"></optgroup></template>',
598+
output: null,
599+
errors: [{ messageId: 'mismatch' }],
600+
},
601+
{
602+
code: '<template><output role="button"></output></template>',
603+
output: null,
604+
errors: [{ messageId: 'mismatch' }],
605+
},
606+
{
607+
code: '<template><strong role="button"></strong></template>',
608+
output: null,
609+
errors: [{ messageId: 'mismatch' }],
610+
},
611+
{
612+
code: '<template><sub role="button"></sub></template>',
613+
output: null,
614+
errors: [{ messageId: 'mismatch' }],
615+
},
616+
{
617+
code: '<template><sup role="button"></sup></template>',
618+
output: null,
619+
errors: [{ messageId: 'mismatch' }],
620+
},
621+
{
622+
code: '<template><tbody role="button"></tbody></template>',
623+
output: null,
624+
errors: [{ messageId: 'mismatch' }],
625+
},
626+
{
627+
code: '<template><tfoot role="button"></tfoot></template>',
628+
output: null,
629+
errors: [{ messageId: 'mismatch' }],
630+
},
631+
{
632+
code: '<template><thead role="button"></thead></template>',
633+
output: null,
634+
errors: [{ messageId: 'mismatch' }],
635+
},
636+
// jsx-a11y also flags role="menuitem" on several of the above.
637+
{
638+
code: '<template><fieldset role="menuitem"></fieldset></template>',
639+
output: null,
640+
errors: [{ messageId: 'mismatch' }],
641+
},
642+
{
643+
code: '<template><hr role="menuitem" /></template>',
644+
output: null,
645+
errors: [{ messageId: 'mismatch' }],
646+
},
647+
{
648+
code: '<template><tbody role="menuitem"></tbody></template>',
649+
output: null,
650+
errors: [{ messageId: 'mismatch' }],
651+
},
652+
{
653+
code: '<template><tfoot role="menuitem"></tfoot></template>',
654+
output: null,
655+
errors: [{ messageId: 'mismatch' }],
656+
},
657+
{
658+
code: '<template><thead role="menuitem"></thead></template>',
659+
output: null,
660+
errors: [{ messageId: 'mismatch' }],
661+
},
662+
578663
// === DIVERGENCE — jsx-a11y :recommended config exceptions ===
579664
// jsx-a11y :recommended has an allowedInvalidRoles config so the following
580665
// all pass :recommended but FAIL :strict:
@@ -779,26 +864,6 @@ hbsRuleTester.run('audit:no-noninteractive-element-to-interactive-role (hbs)', r
779864
'<h1 role={{this.role}}></h1>',
780865
// Empty role — ours skip; jsx-a11y doesn't cover.
781866
'<h1 role=""></h1>',
782-
783-
// DIVERGENCE: jsx-a11y flags these (per nonInteractiveMap); ours do not.
784-
'<section role="button" aria-label="A"></section>',
785-
'<address role="button"></address>',
786-
'<aside role="button"></aside>',
787-
'<fieldset role="button"></fieldset>',
788-
'<code role="button"></code>',
789-
'<em role="button"></em>',
790-
'<strong role="button"></strong>',
791-
'<tbody role="button"></tbody>',
792-
'<tfoot role="button"></tfoot>',
793-
'<thead role="button"></thead>',
794-
'<hr role="button" />',
795-
'<html role="button"></html>',
796-
'<del role="button"></del>',
797-
'<ins role="button"></ins>',
798-
'<optgroup role="button"></optgroup>',
799-
'<output role="button"></output>',
800-
'<sub role="button"></sub>',
801-
'<sup role="button"></sup>',
802867
],
803868
invalid: [
804869
// Upstream parity — non-interactive + interactive role.
@@ -833,6 +898,39 @@ hbsRuleTester.run('audit:no-noninteractive-element-to-interactive-role (hbs)', r
833898
errors: [{ messageId: 'mismatch' }],
834899
},
835900

901+
// Upstream parity — HTML-AAM non-interactive tags picked up via the
902+
// aria-query elementRoles augmentation.
903+
{
904+
code: '<section role="button" aria-label="A"></section>',
905+
output: null,
906+
errors: [{ messageId: 'mismatch' }],
907+
},
908+
{
909+
code: '<fieldset role="button"></fieldset>',
910+
output: null,
911+
errors: [{ messageId: 'mismatch' }],
912+
},
913+
{
914+
code: '<aside role="button"></aside>',
915+
output: null,
916+
errors: [{ messageId: 'mismatch' }],
917+
},
918+
{
919+
code: '<hr role="button" />',
920+
output: null,
921+
errors: [{ messageId: 'mismatch' }],
922+
},
923+
{
924+
code: '<strong role="button"></strong>',
925+
output: null,
926+
errors: [{ messageId: 'mismatch' }],
927+
},
928+
{
929+
code: '<tbody role="button"></tbody>',
930+
output: null,
931+
errors: [{ messageId: 'mismatch' }],
932+
},
933+
836934
// DIVERGENCE: :recommended allows these; :strict and ours flag.
837935
{
838936
code: '<ul role="menu"></ul>',

0 commit comments

Comments
 (0)