Skip to content

Commit 338b36c

Browse files
committed
fix: widen composite-widget nesting exception for canonical ARIA patterns
Deriving INTERACTIVE_ROLES from aria-query's widget taxonomy added roles (`listbox`, `tablist`, `tree`, `treegrid`, `grid`, `radiogroup`, `row`, and their children) to `template-no-nested-interactive`'s interactive set. Without a corresponding exception, canonical WAI-ARIA APG composite widgets would trip the rule: <div role="listbox"><div role="option">A</div></div> <div role="tablist"><div role="tab">…</div></div> <div role="tree"><div role="treeitem">…</div></div> <div role="grid"><div role="row"><div role="gridcell">…</div></div></div> <div role="radiogroup"><div role="radio">…</div></div> Widen the existing menu-pattern exception into a general composite-widget exception driven by aria-query's `requiredOwnedElements` data. For each parent role, inherit `requiredOwnedElements` from ancestor roles in `superClass` (so `treegrid` picks up both `row` via `grid` and `treeitem` via `tree`) and close transitively over intermediate composite roles (so `grid → row → gridcell` is allowed at arbitrary depth in the hierarchy). The submenu pattern `menuitem → menu`, which aria-query does not express via `requiredOwnedElements`, is kept as an explicit special case. Also add an invalid-interactive test for `role="tooltip"` — now that tooltip is no longer in INTERACTIVE_ROLES (per WAI-ARIA 1.2 §5.3.3, it is a Document Structure role), a click handler on a tooltip is an invalid interactive handler on a non-interactive element.
1 parent 187c89b commit 338b36c

4 files changed

Lines changed: 152 additions & 21 deletions

File tree

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

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
1+
const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles');
22

33
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
44
'button',
@@ -24,19 +24,28 @@ function getTextAttr(node, name) {
2424
return undefined;
2525
}
2626

27-
const MENU_CONTAINER_ROLES = new Set(['menu', 'menubar', 'menuitem']);
28-
const MENU_ITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']);
27+
// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup`
28+
// may own a nested `menu`. aria-query's `requiredOwnedElements` does not
29+
// express this "menu-inside-menuitem" direction, so it is handled explicitly.
30+
const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']);
2931

3032
function getRole(node) {
3133
return getTextAttr(node, 'role');
3234
}
3335

34-
function isMenuContainer(node) {
35-
return MENU_CONTAINER_ROLES.has(getRole(node));
36-
}
37-
38-
function isMenuItem(node) {
39-
return MENU_ITEM_ROLES.has(getRole(node));
36+
function isCompositeWidgetPattern(parentRole, childRole) {
37+
if (!parentRole || !childRole) {
38+
return false;
39+
}
40+
const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole);
41+
if (allowedChildren && allowedChildren.has(childRole)) {
42+
return true;
43+
}
44+
// Submenu: <… role="menuitem"><… role="menu"> …
45+
if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') {
46+
return true;
47+
}
48+
return false;
4049
}
4150

4251
function isAllowedDetailsChild(childNode, parentEntry) {
@@ -208,13 +217,14 @@ module.exports = {
208217
parentEntry.interactiveChildCount++;
209218
} else if (isSummaryFirstChildOfDetails(node, parentEntry)) {
210219
// <summary> as first non-whitespace child of <details> is allowed
211-
} else if (
212-
isMenuContainer(parentEntry.node) &&
213-
(isMenuItem(node) || getRole(node) === 'menu')
214-
) {
215-
// Menu patterns — menuitem / menuitemcheckbox / menuitemradio
216-
// inside menu / menubar / menuitem, or menu inside menuitem (for
217-
// submenus), is the standard WAI-ARIA menu hierarchy.
220+
} else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) {
221+
// Canonical ARIA composite-widget hierarchies — e.g. option inside
222+
// listbox, tab inside tablist, treeitem inside tree, row inside
223+
// grid/treegrid, gridcell/columnheader/rowheader inside row,
224+
// menuitem inside menu/menubar, radio inside radiogroup, and
225+
// menu inside menuitem (submenu). Derived from aria-query's
226+
// `requiredOwnedElements` (+ superClass inheritance for
227+
// treegrid, etc.). See lib/utils/interactive-roles.js.
218228
} else {
219229
context.report({
220230
node,

lib/utils/interactive-roles.js

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
const { roles } = require('aria-query');
22

33
// Interactive ARIA roles — concrete roles whose taxonomy descends from `widget`
4-
// in aria-query. This is the same derivation jsx-a11y and lit-a11y use (they
5-
// define the canonical peer-plugin behavior here):
4+
// in aria-query. This is the same derivation jsx-a11y uses (the canonical
5+
// peer-plugin behavior here):
66
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js
7-
// https://github.com/open-wc/open-wc/blob/main/packages/eslint-plugin-lit-a11y/lib/utils/isInteractiveElement.js
87
//
98
// `toolbar` is added explicitly — it does not descend from `widget` per
109
// aria-query's taxonomy, but supports `aria-activedescendant` and is widget-
11-
// like in practice. jsx-a11y and lit-a11y add it for the same reason.
10+
// like in practice. jsx-a11y adds it for the same reason.
1211
//
1312
// `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document
1413
// Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is
1514
// a document-structure role, not a widget; the spec says "document structures
16-
// are not usually interactive." jsx-a11y and lit-a11y agree.
15+
// are not usually interactive." jsx-a11y agrees.
1716
module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet();
1817

18+
// Composite-widget child map — for each composite-widget parent role, the
19+
// set of child roles that are legitimately nested inside it per ARIA's
20+
// "Required Owned Elements" (aria-query's `requiredOwnedElements`). Closed
21+
// transitively over chains of composite widgets (e.g. `grid` owns `row`,
22+
// `row` owns `gridcell` / `columnheader` / `rowheader`, so `grid` transitively
23+
// allows all of them).
24+
//
25+
// This drives the nested-interactive exception so canonical composite-widget
26+
// patterns (`listbox > option`, `tablist > tab`, `tree > treeitem`,
27+
// `grid > row > gridcell`, `radiogroup > radio`, etc.) are not flagged.
28+
module.exports.COMPOSITE_WIDGET_CHILDREN = buildCompositeWidgetChildren();
29+
1930
function buildInteractiveRoleSet() {
2031
const result = new Set(['toolbar']);
2132
for (const [role, def] of roles) {
@@ -29,3 +40,77 @@ function buildInteractiveRoleSet() {
2940
}
3041
return result;
3142
}
43+
44+
function buildCompositeWidgetChildren() {
45+
// Collect each role's own `requiredOwnedElements` first.
46+
const own = new Map();
47+
for (const [role, def] of roles) {
48+
if (def.abstract) {
49+
continue;
50+
}
51+
const owned = def.requiredOwnedElements;
52+
if (!owned || owned.length === 0) {
53+
continue;
54+
}
55+
const kids = new Set();
56+
for (const chain of owned) {
57+
for (const child of chain) {
58+
kids.add(child);
59+
}
60+
}
61+
own.set(role, kids);
62+
}
63+
64+
// A role also legitimately owns whatever its ancestor roles in `superClass`
65+
// own. This lets e.g. `treegrid` (superClass chains include `grid` and
66+
// `tree`) inherit `row` from `grid` and `treeitem` from `tree`, matching
67+
// the APG treegrid pattern.
68+
const direct = new Map();
69+
for (const [role, def] of roles) {
70+
if (def.abstract) {
71+
continue;
72+
}
73+
const merged = new Set(own.get(role) || []);
74+
for (const chain of def.superClass || []) {
75+
for (const ancestor of chain) {
76+
const inherited = own.get(ancestor);
77+
if (inherited) {
78+
for (const child of inherited) {
79+
merged.add(child);
80+
}
81+
}
82+
}
83+
}
84+
if (merged.size > 0) {
85+
direct.set(role, merged);
86+
}
87+
}
88+
89+
// Transitive closure: parent -> all roles reachable through owned-element chains.
90+
const closed = new Map();
91+
function expand(role, visited) {
92+
if (closed.has(role)) {
93+
return closed.get(role);
94+
}
95+
const out = new Set();
96+
const kids = direct.get(role);
97+
if (!kids) {
98+
return out;
99+
}
100+
for (const child of kids) {
101+
out.add(child);
102+
if (!visited.has(child)) {
103+
visited.add(child);
104+
for (const grandchild of expand(child, visited)) {
105+
out.add(grandchild);
106+
}
107+
}
108+
}
109+
closed.set(role, out);
110+
return out;
111+
}
112+
for (const role of direct.keys()) {
113+
expand(role, new Set([role]));
114+
}
115+
return closed;
116+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,5 +212,19 @@ ruleTester.run('template-no-invalid-interactive', rule, {
212212
},
213213
],
214214
},
215+
{
216+
// `tooltip` is a Document Structure role per WAI-ARIA 1.2 §5.3.3, not a
217+
// widget. Click handlers on a tooltip are therefore an invalid
218+
// interactive handler on a non-interactive element.
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+
},
215229
],
216230
});

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ ruleTester.run('template-no-nested-interactive', rule, {
9797
{{/if}}
9898
</label>
9999
</template>`,
100+
101+
// Canonical ARIA composite-widget hierarchies — derived from aria-query's
102+
// `requiredOwnedElements`. These are standard WAI-ARIA APG patterns and
103+
// must not be flagged.
104+
'<template><div role="listbox"><div role="option">A</div><div role="option">B</div></div></template>',
105+
'<template><div role="tablist"><div role="tab">Tab 1</div><div role="tab">Tab 2</div></div></template>',
106+
'<template><div role="tree"><div role="treeitem">Node</div></div></template>',
107+
'<template><div role="treegrid"><div role="row"><div role="gridcell">Cell</div></div></div></template>',
108+
'<template><div role="grid"><div role="row"><div role="gridcell">Cell</div></div></div></template>',
109+
'<template><div role="grid"><div role="row"><div role="rowheader">Header</div></div></div></template>',
110+
'<template><div role="grid"><div role="row"><div role="columnheader">Header</div></div></div></template>',
111+
'<template><div role="radiogroup"><div role="radio">Opt 1</div></div></template>',
100112
],
101113

102114
invalid: [
@@ -307,6 +319,16 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
307319
code: '<button><img usemap=""></button>',
308320
options: [{ ignoreUsemapAttribute: true }],
309321
},
322+
323+
// Canonical ARIA composite-widget hierarchies.
324+
'<div role="listbox"><div role="option">A</div><div role="option">B</div></div>',
325+
'<div role="tablist"><div role="tab">Tab 1</div><div role="tab">Tab 2</div></div>',
326+
'<div role="tree"><div role="treeitem">Node</div></div>',
327+
'<div role="treegrid"><div role="row"><div role="gridcell">Cell</div></div></div>',
328+
'<div role="grid"><div role="row"><div role="gridcell">Cell</div></div></div>',
329+
'<div role="grid"><div role="row"><div role="rowheader">Header</div></div></div>',
330+
'<div role="grid"><div role="row"><div role="columnheader">Header</div></div></div>',
331+
'<div role="radiogroup"><div role="radio">Opt 1</div></div>',
310332
],
311333
invalid: [
312334
{

0 commit comments

Comments
 (0)