Skip to content

Commit 74511fc

Browse files
committed
refactor: consume #27's aria-query-derived INTERACTIVE_ROLES
Import INTERACTIVE_ROLES and COMPOSITE_WIDGET_CHILDREN from the shared lib/utils/interactive-roles.js util (introduced in #27 — byte-identical copy here so either PR can land first without conflict). Drop the hardcoded 19-role set previously duplicated inline in each rule. Behavior changes: - ARIA widget role set expands from 19 to 35 roles — picks up menubar, menu, listbox, tree, tablist, grid, treegrid, radiogroup, alertdialog, progressbar, and other widget-descended roles in aria-query's taxonomy that the hardcoded list missed. - tooltip is no longer treated as interactive. Per WAI-ARIA 1.2 §5.3.3, tooltip is a document-structure role, not a widget. #27's util reflects this (tooltip explicitly excluded). Old <div role="tooltip" onclick> test moves from valid to invalid — spec-correct. - Composite-widget nesting exception expanded via COMPOSITE_WIDGET_CHILDREN. Canonical APG patterns (<ul role="menubar"><li role="menuitem">..., <ul role="listbox"><li role="option">..., grid/row/gridcell, treegrid, radiogroup/radio) no longer flag as nested-interactive. Previously the rule only handled menuitem-in-menuitem explicitly. Pairs with #37's HTML-content-model authority split to complete the two-authority architecture: HTML §3.2.5.2.7 via html-interactive-content, ARIA widget taxonomy via interactive-roles. Each util cites one authority honestly; rules compose both.
1 parent 8afaf89 commit 74511fc

5 files changed

Lines changed: 315 additions & 44 deletions

File tree

lib/rules/template-no-invalid-interactive.js

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
2+
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
23

34
function hasAttr(node, name) {
45
return node.attributes?.some((a) => a.name === name);
@@ -73,28 +74,6 @@ module.exports = {
7374
const ignoreTabindex = options.ignoreTabindex || false;
7475
const ignoreUsemap = options.ignoreUsemap || false;
7576

76-
const INTERACTIVE_ROLES = new Set([
77-
'button',
78-
'checkbox',
79-
'link',
80-
'menuitem',
81-
'menuitemcheckbox',
82-
'menuitemradio',
83-
'option',
84-
'radio',
85-
'scrollbar',
86-
'searchbox',
87-
'slider',
88-
'spinbutton',
89-
'switch',
90-
'tab',
91-
'textbox',
92-
'tooltip',
93-
'treeitem',
94-
'combobox',
95-
'gridcell',
96-
]);
97-
9877
function isInteractive(node) {
9978
const tag = node.tag?.toLowerCase();
10079
if (!tag) {

lib/rules/template-no-nested-interactive.js

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
2-
3-
const INTERACTIVE_ROLES = new Set([
4-
'button',
5-
'checkbox',
6-
'link',
7-
'searchbox',
8-
'spinbutton',
9-
'switch',
10-
'textbox',
11-
'radio',
12-
'slider',
13-
'tab',
14-
'menuitem',
15-
'menuitemcheckbox',
16-
'menuitemradio',
17-
'option',
18-
'combobox',
19-
'gridcell',
20-
]);
2+
const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles');
213

224
function hasAttr(node, name) {
235
return node.attributes?.some((a) => a.name === name);
@@ -31,6 +13,30 @@ function getTextAttr(node, name) {
3113
return undefined;
3214
}
3315

16+
function getRole(node) {
17+
return getTextAttr(node, 'role');
18+
}
19+
20+
// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup`
21+
// may own a nested `menu`. aria-query's `requiredOwnedElements` does not
22+
// express this "menu-inside-menuitem" direction, so it is handled explicitly.
23+
const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']);
24+
25+
function isCompositeWidgetPattern(parentRole, childRole) {
26+
if (!parentRole || !childRole) {
27+
return false;
28+
}
29+
const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole);
30+
if (allowedChildren && allowedChildren.has(childRole)) {
31+
return true;
32+
}
33+
// Submenu: <… role="menuitem"><… role="menu"> …
34+
if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') {
35+
return true;
36+
}
37+
return false;
38+
}
39+
3440
function isMenuItemNode(node) {
3541
return getTextAttr(node, 'role') === 'menuitem';
3642
}
@@ -198,8 +204,17 @@ module.exports = {
198204
parentEntry.interactiveChildCount++;
199205
} else if (isSummaryFirstChildOfDetails(node, parentEntry)) {
200206
// <summary> as first non-whitespace child of <details> is allowed
207+
} else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) {
208+
// Canonical ARIA composite-widget hierarchies — e.g. option inside
209+
// listbox, tab inside tablist, treeitem inside tree, row inside
210+
// grid/treegrid, gridcell/columnheader/rowheader inside row,
211+
// radio inside radiogroup, menu inside menuitem (submenu).
212+
// Derived from aria-query's requiredOwnedElements with superClass
213+
// inheritance — see lib/utils/interactive-roles.js.
201214
} else if (isMenuItemNode(parentEntry.node) && isMenuItemNode(node)) {
202-
// Nested menuitem nodes are valid (menu/sub-menu pattern)
215+
// Nested menuitem nodes are valid (menu/sub-menu pattern) — kept
216+
// for historical compat since aria-query doesn't encode this via
217+
// requiredOwnedElements.
203218
} else {
204219
context.report({
205220
node,

lib/utils/interactive-roles.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
const { roles } = require('aria-query');
2+
3+
// Interactive ARIA roles — concrete roles whose taxonomy descends from `widget`
4+
// in aria-query. This is the same derivation jsx-a11y uses (the canonical
5+
// peer-plugin behavior here):
6+
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js
7+
//
8+
// Authority: aria-query widget taxonomy, one manual addition, one manual
9+
// exclusion. Both documented below.
10+
//
11+
// `toolbar` is added explicitly — it does not descend from `widget` per
12+
// aria-query's taxonomy (its superClass is structure-based), but supports
13+
// `aria-activedescendant` and is widget-like in practice per WAI-ARIA APG's
14+
// toolbar pattern. jsx-a11y adds it for the same reason.
15+
//
16+
// `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document
17+
// Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is
18+
// a document-structure role, not a widget; the spec says "document structures
19+
// are not usually interactive." jsx-a11y agrees.
20+
//
21+
// `progressbar` IS included (it descends from widget → range per aria-query).
22+
// lit-a11y explicitly excludes it on the grounds that `progressbar.value` is
23+
// always readonly, so the role isn't operable by the user. This is a real
24+
// design disagreement; we side with aria-query's taxonomy (and jsx-a11y) to
25+
// keep the authority single-sourced. Authors wanting lit-a11y behavior can
26+
// layer a rule-level exclusion if needed.
27+
module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet();
28+
29+
// Composite-widget child map — for each composite-widget parent role, the
30+
// set of child roles that are legitimately nested inside it per ARIA's
31+
// "Required Owned Elements" (aria-query's `requiredOwnedElements`). Closed
32+
// transitively over chains of composite widgets (e.g. `grid` owns `row`,
33+
// `row` owns `gridcell` / `columnheader` / `rowheader`, so `grid` transitively
34+
// allows all of them).
35+
//
36+
// This drives the nested-interactive exception so canonical composite-widget
37+
// patterns (`listbox > option`, `tablist > tab`, `tree > treeitem`,
38+
// `grid > row > gridcell`, `radiogroup > radio`, etc.) are not flagged.
39+
module.exports.COMPOSITE_WIDGET_CHILDREN = buildCompositeWidgetChildren();
40+
41+
function buildInteractiveRoleSet() {
42+
const result = new Set(['toolbar']);
43+
for (const [role, def] of roles) {
44+
if (def.abstract) {
45+
continue;
46+
}
47+
const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget'));
48+
if (descendsFromWidget) {
49+
result.add(role);
50+
}
51+
}
52+
return result;
53+
}
54+
55+
function buildCompositeWidgetChildren() {
56+
// Collect each role's own `requiredOwnedElements` first.
57+
const own = new Map();
58+
for (const [role, def] of roles) {
59+
if (def.abstract) {
60+
continue;
61+
}
62+
const owned = def.requiredOwnedElements;
63+
if (!owned || owned.length === 0) {
64+
continue;
65+
}
66+
const kids = new Set();
67+
for (const chain of owned) {
68+
for (const child of chain) {
69+
kids.add(child);
70+
}
71+
}
72+
own.set(role, kids);
73+
}
74+
75+
// A role also legitimately owns whatever its ancestor roles in `superClass`
76+
// own. This lets e.g. `treegrid` (superClass chains include `grid` and
77+
// `tree`) inherit `row` from `grid` and `treeitem` from `tree`, matching
78+
// the APG treegrid pattern.
79+
const direct = new Map();
80+
for (const [role, def] of roles) {
81+
if (def.abstract) {
82+
continue;
83+
}
84+
const merged = new Set(own.get(role) || []);
85+
for (const chain of def.superClass || []) {
86+
for (const ancestor of chain) {
87+
const inherited = own.get(ancestor);
88+
if (inherited) {
89+
for (const child of inherited) {
90+
merged.add(child);
91+
}
92+
}
93+
}
94+
}
95+
if (merged.size > 0) {
96+
direct.set(role, merged);
97+
}
98+
}
99+
100+
// Transitive closure: parent -> all roles reachable through owned-element chains.
101+
const closed = new Map();
102+
function expand(role, visited) {
103+
if (closed.has(role)) {
104+
return closed.get(role);
105+
}
106+
const out = new Set();
107+
const kids = direct.get(role);
108+
if (!kids) {
109+
return out;
110+
}
111+
for (const child of kids) {
112+
out.add(child);
113+
if (!visited.has(child)) {
114+
visited.add(child);
115+
for (const grandchild of expand(child, visited)) {
116+
out.add(grandchild);
117+
}
118+
}
119+
}
120+
closed.set(role, out);
121+
return out;
122+
}
123+
for (const role of direct.keys()) {
124+
expand(role, new Set([role]));
125+
}
126+
return closed;
127+
}

tests/lib/rules/template-no-invalid-interactive.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,11 @@ ruleTester.run('template-no-invalid-interactive', rule, {
6666
// <summary> is natively interactive
6767
'<template><summary onclick={{this.toggle}}>Details</summary></template>',
6868

69-
// ARIA widget roles: scrollbar, tooltip, treeitem
69+
// ARIA widget roles: scrollbar, treeitem (+ many others from the shared
70+
// interactive-roles util). tooltip is intentionally NOT in the widget
71+
// set (per WAI-ARIA 1.2 §5.3.3 it's a document-structure role) and so
72+
// handlers on `role="tooltip"` should be flagged — see invalid cases.
7073
'<template><div role="scrollbar" onclick={{this.scroll}}>Scroll</div></template>',
71-
'<template><div role="tooltip" onclick={{this.show}}>Tip</div></template>',
7274
'<template><div role="treeitem" onclick={{this.select}}>Node</div></template>',
7375

7476
// audio/video with controls are interactive
@@ -211,5 +213,18 @@ ruleTester.run('template-no-invalid-interactive', rule, {
211213
},
212214
],
213215
},
216+
{
217+
// role="tooltip" is document-structure per WAI-ARIA 1.2 §5.3.3 — NOT
218+
// a widget, so a handler on it is as invalid as a handler on a bare div.
219+
filename: 'test.gjs',
220+
code: '<template><div role="tooltip" onclick={{this.show}}>Tip</div></template>',
221+
output: null,
222+
errors: [
223+
{
224+
messageId: 'noInvalidInteractive',
225+
data: { tagName: 'div', handler: 'onclick' },
226+
},
227+
],
228+
},
214229
],
215230
});

0 commit comments

Comments
 (0)