From 74dc09a035b6eb8486cb757af298fa84b1065464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 08:56:32 +0200 Subject: [PATCH 01/10] refactor: derive INTERACTIVE_ROLES from aria-query taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates two hand-maintained INTERACTIVE_ROLES sets (in template-no-invalid-interactive and template-no-nested-interactive) into a single derivation sourced from aria-query's role taxonomy. lib/utils/interactive-roles.js filters concrete roles whose superClass chain includes widget / command / composite / input / range, then adds tooltip and toolbar explicitly (same rationale as jsx-a11y: ARIA 1.2 categorizes tooltip as a widget but aria-query's superClass chain doesn't reflect that; toolbar supports aria-activedescendant and is widget-like in practice). Behavioral change: Adds 18 roles to the interactive set: columnheader, doc-backlink, doc-biblioref, doc-glossref, doc-noteref, grid, listbox, menu, menubar, meter, progressbar, radiogroup, row, rowheader, tablist, toolbar, tree, treegrid. These were silently treated as non-interactive — a composite widget (role="tree") could contain nested interactive elements without being flagged; an inside a role="menu" was accepted but nested-interactive per ARIA. No removals — tooltip was in the hand list and is preserved explicitly. Follow-up: template-no-nested-interactive's menu-pattern exception broadened from "menuitem inside menuitem" to "menu patterns per WAI-ARIA" — menuitem/menuitemcheckbox/menuitemradio inside menu/menubar/menuitem, and menu inside menuitem (for submenus), are the standard menu hierarchy. --- lib/rules/template-no-invalid-interactive.js | 15 +- lib/rules/template-no-nested-interactive.js | 68 ++++----- lib/utils/interactive-roles.js | 141 ++++--------------- 3 files changed, 63 insertions(+), 161 deletions(-) diff --git a/lib/rules/template-no-invalid-interactive.js b/lib/rules/template-no-invalid-interactive.js index 8498cc6bc2..d17ee22fb2 100644 --- a/lib/rules/template-no-invalid-interactive.js +++ b/lib/rules/template-no-invalid-interactive.js @@ -1,7 +1,6 @@ 'use strict'; const { isNativeElement } = require('../utils/is-native-element'); -const { isHtmlInteractiveContent } = require('../utils/html-interactive-content'); const { INTERACTIVE_ROLES } = require('../utils/interactive-roles'); function hasAttr(node, name) { @@ -78,6 +77,20 @@ module.exports = { const ignoreTabindex = options.ignoreTabindex || false; const ignoreUsemap = options.ignoreUsemap || false; + const NATIVE_INTERACTIVE_ELEMENTS = new Set([ + 'button', + 'canvas', + 'details', + 'embed', + 'iframe', + 'input', + 'label', + 'select', + 'summary', + 'textarea', + ]); + + // eslint-disable-next-line complexity function isInteractive(node) { const tag = node.tag?.toLowerCase(); if (!tag) { diff --git a/lib/rules/template-no-nested-interactive.js b/lib/rules/template-no-nested-interactive.js index f82bf602d8..4d0d27a361 100644 --- a/lib/rules/template-no-nested-interactive.js +++ b/lib/rules/template-no-nested-interactive.js @@ -1,7 +1,16 @@ -'use strict'; +const { INTERACTIVE_ROLES } = require('../utils/interactive-roles'); -const { isHtmlInteractiveContent } = require('../utils/html-interactive-content'); -const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles'); +const NATIVE_INTERACTIVE_ELEMENTS = new Set([ + 'button', + 'details', + 'embed', + 'iframe', + 'input', + 'label', + 'select', + 'summary', + 'textarea', +]); function hasAttr(node, name) { return node.attributes?.some((a) => a.name === name); @@ -15,38 +24,19 @@ function getTextAttr(node, name) { return undefined; } +const MENU_CONTAINER_ROLES = new Set(['menu', 'menubar', 'menuitem']); +const MENU_ITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']); + function getRole(node) { return getTextAttr(node, 'role'); } -// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup` -// may own a nested `menu`. aria-query's `requiredOwnedElements` does not -// express this "menu-inside-menuitem" direction, so it is handled explicitly. -const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']); - -function isCompositeWidgetPattern(parentRole, childRole) { - if (!parentRole || !childRole) { - return false; - } - const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole); - if (allowedChildren && allowedChildren.has(childRole)) { - return true; - } - // Submenu: <… role="menuitem"><… role="menu"> … - if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') { - return true; - } - return false; +function isMenuContainer(node) { + return MENU_CONTAINER_ROLES.has(getRole(node)); } -function isMenuItemNode(node) { - // Match all three menu-item role variants per ARIA taxonomy. `menuitem`, - // `menuitemcheckbox`, and `menuitemradio` are all "menu items" — they can - // carry submenus (via MENUITEM_ROLES in isCompositeWidgetPattern) and nest - // each other (via the nested-menuitem compat exception). Keeping both - // predicates symmetric avoids false positives on APG Menu Button / - // Menubar patterns that mix the variants. - return MENUITEM_ROLES.has(getTextAttr(node, 'role')); +function isMenuItem(node) { + return MENU_ITEM_ROLES.has(getRole(node)); } function isAllowedDetailsChild(childNode, parentEntry) { @@ -226,20 +216,12 @@ module.exports = { }); } parentEntry.interactiveChildCount++; - } else if (isAllowedDetailsChild(node, parentEntry)) { - // flow content in the disclosed panel, or as first child - } else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) { - // Canonical ARIA composite-widget hierarchies — e.g. option inside - // listbox, tab inside tablist, treeitem inside tree, row inside - // grid/treegrid, gridcell/columnheader/rowheader inside row, - // radio inside radiogroup, menu inside menuitem (submenu). - // Derived from aria-query's requiredOwnedElements with superClass - // inheritance — see lib/utils/interactive-roles.js. - } else if (isMenuItemNode(parentEntry.node) && isMenuItemNode(node)) { - // Nested menu-item nodes (any combination of menuitem / - // menuitemcheckbox / menuitemradio) are valid — menu/sub-menu - // pattern per WAI-ARIA APG. Kept for historical compat since - // aria-query doesn't encode this via requiredOwnedElements. + } else if (isSummaryFirstChildOfDetails(node, parentEntry)) { + // as first non-whitespace child of
is allowed + } else if (isMenuContainer(parentEntry.node) && (isMenuItem(node) || getRole(node) === 'menu')) { + // Menu patterns — menuitem / menuitemcheckbox / menuitemradio + // inside menu / menubar / menuitem, or menu inside menuitem (for + // submenus), is the standard WAI-ARIA menu hierarchy. } else { context.report({ node, diff --git a/lib/utils/interactive-roles.js b/lib/utils/interactive-roles.js index 3814164154..8213e662da 100644 --- a/lib/utils/interactive-roles.js +++ b/lib/utils/interactive-roles.js @@ -1,133 +1,40 @@ -'use strict'; - const { roles } = require('aria-query'); -// Interactive ARIA roles — concrete roles whose taxonomy descends from `widget` -// in aria-query. This is the same derivation jsx-a11y uses (the canonical -// peer-plugin behavior here): -// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js -// -// Authority: aria-query widget taxonomy, one manual addition, one manual -// exclusion. Both documented below. -// -// `toolbar` is added explicitly — it does not descend from `widget` per -// aria-query's taxonomy (its superClass is structure-based), but supports -// `aria-activedescendant` and is widget-like in practice per WAI-ARIA APG's -// toolbar pattern. jsx-a11y adds it for the same reason. +// Interactive ARIA roles — the set of concrete roles whose taxonomy in WAI-ARIA +// includes a widget / command / composite / input / range ancestor. Derived from +// aria-query so the list stays current with ARIA spec updates (including +// DPUB-ARIA and Graphics-ARIA additions) without hand maintenance. // -// `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document -// Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is -// a document-structure role, not a widget; the spec says "document structures -// are not usually interactive." jsx-a11y agrees. -// -// `progressbar` IS included (it descends from widget → range per aria-query). -// lit-a11y explicitly excludes it on the grounds that `progressbar.value` is -// always readonly, so the role isn't operable by the user. This is a real -// design disagreement; we side with aria-query's taxonomy (and jsx-a11y) to -// keep the authority single-sourced. Authors wanting lit-a11y behavior can -// layer a rule-level exclusion if needed. +// `tooltip` and `toolbar` are added explicitly: +// - `tooltip`: ARIA 1.2 §5.4 lists tooltip among widget roles, but aria-query's +// superClass chain doesn't include `widget` for it. Practitioner convention +// (and jsx-a11y/vuejs-accessibility) treats it as interactive. +// - `toolbar`: does not descend from `widget` in aria-query, but supports +// `aria-activedescendant` and is widget-like in practice. jsx-a11y adds it +// with the same rationale. module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet(); -// Composite-widget child map — for each composite-widget parent role, the -// set of child roles that are legitimately nested inside it per ARIA's -// "Required Owned Elements" (aria-query's `requiredOwnedElements`). Closed -// transitively over chains of composite widgets (e.g. `grid` owns `row`, -// `row` owns `gridcell` / `columnheader` / `rowheader`, so `grid` transitively -// allows all of them). -// -// This drives the nested-interactive exception so canonical composite-widget -// patterns (`listbox > option`, `tablist > tab`, `tree > treeitem`, -// `grid > row > gridcell`, `radiogroup > radio`, etc.) are not flagged. -module.exports.COMPOSITE_WIDGET_CHILDREN = buildCompositeWidgetChildren(); - function buildInteractiveRoleSet() { - const result = new Set(['toolbar']); - for (const [role, def] of roles) { - if (def.abstract) { - continue; - } - const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget')); - if (descendsFromWidget) { - result.add(role); - } - } - return result; -} - -function buildCompositeWidgetChildren() { - // Collect each role's own `requiredOwnedElements` first. - const own = new Map(); + const result = new Set(['tooltip', 'toolbar']); for (const [role, def] of roles) { if (def.abstract) { continue; } - const owned = def.requiredOwnedElements; - if (!owned || owned.length === 0) { - continue; - } - const kids = new Set(); - for (const chain of owned) { - for (const child of chain) { - kids.add(child); - } - } - own.set(role, kids); - } - - // A role also legitimately owns whatever its ancestor roles in `superClass` - // own. This lets e.g. `treegrid` (superClass chains include `grid` and - // `tree`) inherit `row` from `grid` and `treeitem` from `tree`, matching - // the APG treegrid pattern. - const direct = new Map(); - for (const [role, def] of roles) { - if (def.abstract) { - continue; - } - const merged = new Set(own.get(role) || []); + const ancestors = new Set(); for (const chain of def.superClass || []) { - for (const ancestor of chain) { - const inherited = own.get(ancestor); - if (inherited) { - for (const child of inherited) { - merged.add(child); - } - } + for (const cls of chain) { + ancestors.add(cls); } } - if (merged.size > 0) { - direct.set(role, merged); - } - } - - // Transitive closure: parent -> all roles reachable through owned-element chains. - const closed = new Map(); - function expand(role, visited) { - if (closed.has(role)) { - return closed.get(role); - } - const out = new Set(); - const kids = direct.get(role); - if (!kids) { - closed.set(role, out); - return out; - } - for (const child of kids) { - out.add(child); - if (!visited.has(child)) { - // Pass a fresh branch-specific visited set so sibling branches do not - // contaminate each other's traversal. A shared Set across branches - // makes memoized results order-dependent, because whether `child` is - // recursed into depends on which sibling ran first. - for (const grandchild of expand(child, new Set([...visited, child]))) { - out.add(grandchild); - } - } + if ( + ancestors.has('widget') || + ancestors.has('command') || + ancestors.has('composite') || + ancestors.has('input') || + ancestors.has('range') + ) { + result.add(role); } - closed.set(role, out); - return out; } - for (const role of direct.keys()) { - expand(role, new Set([role])); - } - return closed; + return result; } From e739c0d985a64a3a28433e060ee51113e1a3c4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 09:10:20 +0200 Subject: [PATCH 02/10] refactor: narrow INTERACTIVE_ROLES derivation to match jsx-a11y/lit-a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our earlier derivation was broader (widget|command|composite|input|range ancestors). jsx-a11y and lit-a11y both use the narrower 'widget ancestor' check. The only role our broader check added was 'meter' — spec-readonly and not interactive in peer-plugin terms. Narrow to match peer behavior exactly: - derivation: def.superClass.some(chain => chain.includes('widget')) - + 'toolbar' (doesn't descend from widget but supports activedescendant) - + 'tooltip' (ARIA 1.2 categorization is ambiguous; our existing tests treat it as interactive) No test changes — the new set is a subset of the previous except for meter (not covered by any existing test). --- lib/utils/interactive-roles.js | 43 ++++++++++++++-------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/lib/utils/interactive-roles.js b/lib/utils/interactive-roles.js index 8213e662da..4f167e1f4f 100644 --- a/lib/utils/interactive-roles.js +++ b/lib/utils/interactive-roles.js @@ -1,38 +1,31 @@ const { roles } = require('aria-query'); -// Interactive ARIA roles — the set of concrete roles whose taxonomy in WAI-ARIA -// includes a widget / command / composite / input / range ancestor. Derived from -// aria-query so the list stays current with ARIA spec updates (including -// DPUB-ARIA and Graphics-ARIA additions) without hand maintenance. +// Interactive ARIA roles — concrete roles whose taxonomy descends from `widget` +// in aria-query. This is the same derivation jsx-a11y and lit-a11y use (they +// define the canonical peer-plugin behavior here): +// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js +// https://github.com/open-wc/open-wc/blob/main/packages/eslint-plugin-lit-a11y/lib/utils/isInteractiveElement.js // -// `tooltip` and `toolbar` are added explicitly: -// - `tooltip`: ARIA 1.2 §5.4 lists tooltip among widget roles, but aria-query's -// superClass chain doesn't include `widget` for it. Practitioner convention -// (and jsx-a11y/vuejs-accessibility) treats it as interactive. -// - `toolbar`: does not descend from `widget` in aria-query, but supports -// `aria-activedescendant` and is widget-like in practice. jsx-a11y adds it -// with the same rationale. +// `toolbar` is added explicitly — it does not descend from `widget` per +// aria-query's taxonomy, but supports `aria-activedescendant` and is widget- +// like in practice. jsx-a11y and lit-a11y add it for the same reason. +// +// `tooltip` is also added — ARIA 1.2 doesn't cleanly categorize tooltip under +// the widget taxonomy (aria-query's superClass is `structure/section`), but +// tooltips with interactive content (close buttons, links) are common and our +// existing test suite treats them as interactive. module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet(); function buildInteractiveRoleSet() { - const result = new Set(['tooltip', 'toolbar']); + const result = new Set(['toolbar', 'tooltip']); for (const [role, def] of roles) { if (def.abstract) { continue; } - const ancestors = new Set(); - for (const chain of def.superClass || []) { - for (const cls of chain) { - ancestors.add(cls); - } - } - if ( - ancestors.has('widget') || - ancestors.has('command') || - ancestors.has('composite') || - ancestors.has('input') || - ancestors.has('range') - ) { + const descendsFromWidget = (def.superClass || []).some((chain) => + chain.includes('widget') + ); + if (descendsFromWidget) { result.add(role); } } From 33c426b32df30c95af7418b9ff757c3088214fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 09:15:26 +0200 Subject: [PATCH 03/10] lint: prettier format --- lib/rules/template-no-nested-interactive.js | 5 ++++- lib/utils/interactive-roles.js | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rules/template-no-nested-interactive.js b/lib/rules/template-no-nested-interactive.js index 4d0d27a361..0c75f653c5 100644 --- a/lib/rules/template-no-nested-interactive.js +++ b/lib/rules/template-no-nested-interactive.js @@ -218,7 +218,10 @@ module.exports = { parentEntry.interactiveChildCount++; } else if (isSummaryFirstChildOfDetails(node, parentEntry)) { // as first non-whitespace child of
is allowed - } else if (isMenuContainer(parentEntry.node) && (isMenuItem(node) || getRole(node) === 'menu')) { + } else if ( + isMenuContainer(parentEntry.node) && + (isMenuItem(node) || getRole(node) === 'menu') + ) { // Menu patterns — menuitem / menuitemcheckbox / menuitemradio // inside menu / menubar / menuitem, or menu inside menuitem (for // submenus), is the standard WAI-ARIA menu hierarchy. diff --git a/lib/utils/interactive-roles.js b/lib/utils/interactive-roles.js index 4f167e1f4f..2e63376232 100644 --- a/lib/utils/interactive-roles.js +++ b/lib/utils/interactive-roles.js @@ -22,9 +22,7 @@ function buildInteractiveRoleSet() { if (def.abstract) { continue; } - const descendsFromWidget = (def.superClass || []).some((chain) => - chain.includes('widget') - ); + const descendsFromWidget = (def.superClass || []).some((chain) => chain.includes('widget')); if (descendsFromWidget) { result.add(role); } From d1a328e3ee514e60f29b3959a0e2a54dd527f45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 09:17:58 +0200 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20remove=20tooltip=20from=20INT?= =?UTF-8?q?ERACTIVE=5FROLES=20per=20WAI-ARIA=201.2=20=C2=A75.3.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per WAI-ARIA 1.2 §5.3.3 — Document Structure Roles: https://www.w3.org/TR/wai-aria-1.2/#tooltip Tooltip is explicitly listed under document-structure roles (alongside img, note, paragraph, etc.), not widget roles. The spec notes: 'Document structures are not usually interactive.' jsx-a11y and lit-a11y agree — neither adds tooltip to their interactive- role set (both add only toolbar). The one test case expecting tooltip to be treated as interactive moves accordingly. aria-query's superClass for tooltip is structure/section, which the narrow widget-ancestor filter correctly excludes. --- lib/utils/interactive-roles.js | 10 +++++----- tests/lib/rules/template-no-invalid-interactive.js | 6 ++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/utils/interactive-roles.js b/lib/utils/interactive-roles.js index 2e63376232..3d0458303f 100644 --- a/lib/utils/interactive-roles.js +++ b/lib/utils/interactive-roles.js @@ -10,14 +10,14 @@ const { roles } = require('aria-query'); // aria-query's taxonomy, but supports `aria-activedescendant` and is widget- // like in practice. jsx-a11y and lit-a11y add it for the same reason. // -// `tooltip` is also added — ARIA 1.2 doesn't cleanly categorize tooltip under -// the widget taxonomy (aria-query's superClass is `structure/section`), but -// tooltips with interactive content (close buttons, links) are common and our -// existing test suite treats them as interactive. +// `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document +// Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is +// a document-structure role, not a widget; the spec says "document structures +// are not usually interactive." jsx-a11y and lit-a11y agree. module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet(); function buildInteractiveRoleSet() { - const result = new Set(['toolbar', 'tooltip']); + const result = new Set(['toolbar']); for (const [role, def] of roles) { if (def.abstract) { continue; diff --git a/tests/lib/rules/template-no-invalid-interactive.js b/tests/lib/rules/template-no-invalid-interactive.js index 85937d4639..999058a383 100644 --- a/tests/lib/rules/template-no-invalid-interactive.js +++ b/tests/lib/rules/template-no-invalid-interactive.js @@ -66,10 +66,8 @@ ruleTester.run('template-no-invalid-interactive', rule, { // is natively interactive '', - // ARIA widget roles: scrollbar, treeitem (+ many others from the shared - // interactive-roles util). tooltip is intentionally NOT in the widget - // set (per WAI-ARIA 1.2 §5.3.3 it's a document-structure role) and so - // handlers on `role="tooltip"` should be flagged — see invalid cases. + // ARIA widget roles: scrollbar, treeitem + // (tooltip is not a widget per WAI-ARIA 1.2 §5.3.3 — document-structure role) '', '', From 02516ec63179e63e9bf92d8f32b5635d8fb0e72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 10:53:25 +0200 Subject: [PATCH 05/10] fix: widen composite-widget nesting exception for canonical ARIA patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:
A
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. --- lib/rules/template-no-nested-interactive.js | 42 ++++---- lib/utils/interactive-roles.js | 95 ++++++++++++++++++- .../rules/template-no-invalid-interactive.js | 5 +- .../rules/template-no-nested-interactive.js | 25 ++++- 4 files changed, 141 insertions(+), 26 deletions(-) diff --git a/lib/rules/template-no-nested-interactive.js b/lib/rules/template-no-nested-interactive.js index 0c75f653c5..95558ce8e1 100644 --- a/lib/rules/template-no-nested-interactive.js +++ b/lib/rules/template-no-nested-interactive.js @@ -1,4 +1,4 @@ -const { INTERACTIVE_ROLES } = require('../utils/interactive-roles'); +const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles'); const NATIVE_INTERACTIVE_ELEMENTS = new Set([ 'button', @@ -24,19 +24,28 @@ function getTextAttr(node, name) { return undefined; } -const MENU_CONTAINER_ROLES = new Set(['menu', 'menubar', 'menuitem']); -const MENU_ITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']); +// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup` +// may own a nested `menu`. aria-query's `requiredOwnedElements` does not +// express this "menu-inside-menuitem" direction, so it is handled explicitly. +const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']); function getRole(node) { return getTextAttr(node, 'role'); } -function isMenuContainer(node) { - return MENU_CONTAINER_ROLES.has(getRole(node)); -} - -function isMenuItem(node) { - return MENU_ITEM_ROLES.has(getRole(node)); +function isCompositeWidgetPattern(parentRole, childRole) { + if (!parentRole || !childRole) { + return false; + } + const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole); + if (allowedChildren && allowedChildren.has(childRole)) { + return true; + } + // Submenu: <… role="menuitem"><… role="menu"> … + if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') { + return true; + } + return false; } function isAllowedDetailsChild(childNode, parentEntry) { @@ -218,13 +227,14 @@ module.exports = { parentEntry.interactiveChildCount++; } else if (isSummaryFirstChildOfDetails(node, parentEntry)) { // as first non-whitespace child of
is allowed - } else if ( - isMenuContainer(parentEntry.node) && - (isMenuItem(node) || getRole(node) === 'menu') - ) { - // Menu patterns — menuitem / menuitemcheckbox / menuitemradio - // inside menu / menubar / menuitem, or menu inside menuitem (for - // submenus), is the standard WAI-ARIA menu hierarchy. + } else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) { + // Canonical ARIA composite-widget hierarchies — e.g. option inside + // listbox, tab inside tablist, treeitem inside tree, row inside + // grid/treegrid, gridcell/columnheader/rowheader inside row, + // menuitem inside menu/menubar, radio inside radiogroup, and + // menu inside menuitem (submenu). Derived from aria-query's + // `requiredOwnedElements` (+ superClass inheritance for + // treegrid, etc.). See lib/utils/interactive-roles.js. } else { context.report({ node, diff --git a/lib/utils/interactive-roles.js b/lib/utils/interactive-roles.js index 3d0458303f..82260f5703 100644 --- a/lib/utils/interactive-roles.js +++ b/lib/utils/interactive-roles.js @@ -1,21 +1,32 @@ const { roles } = require('aria-query'); // Interactive ARIA roles — concrete roles whose taxonomy descends from `widget` -// in aria-query. This is the same derivation jsx-a11y and lit-a11y use (they -// define the canonical peer-plugin behavior here): +// in aria-query. This is the same derivation jsx-a11y uses (the canonical +// peer-plugin behavior here): // https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js -// https://github.com/open-wc/open-wc/blob/main/packages/eslint-plugin-lit-a11y/lib/utils/isInteractiveElement.js // // `toolbar` is added explicitly — it does not descend from `widget` per // aria-query's taxonomy, but supports `aria-activedescendant` and is widget- -// like in practice. jsx-a11y and lit-a11y add it for the same reason. +// like in practice. jsx-a11y adds it for the same reason. // // `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document // Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is // a document-structure role, not a widget; the spec says "document structures -// are not usually interactive." jsx-a11y and lit-a11y agree. +// are not usually interactive." jsx-a11y agrees. module.exports.INTERACTIVE_ROLES = buildInteractiveRoleSet(); +// Composite-widget child map — for each composite-widget parent role, the +// set of child roles that are legitimately nested inside it per ARIA's +// "Required Owned Elements" (aria-query's `requiredOwnedElements`). Closed +// transitively over chains of composite widgets (e.g. `grid` owns `row`, +// `row` owns `gridcell` / `columnheader` / `rowheader`, so `grid` transitively +// allows all of them). +// +// This drives the nested-interactive exception so canonical composite-widget +// patterns (`listbox > option`, `tablist > tab`, `tree > treeitem`, +// `grid > row > gridcell`, `radiogroup > radio`, etc.) are not flagged. +module.exports.COMPOSITE_WIDGET_CHILDREN = buildCompositeWidgetChildren(); + function buildInteractiveRoleSet() { const result = new Set(['toolbar']); for (const [role, def] of roles) { @@ -29,3 +40,77 @@ function buildInteractiveRoleSet() { } return result; } + +function buildCompositeWidgetChildren() { + // Collect each role's own `requiredOwnedElements` first. + const own = new Map(); + for (const [role, def] of roles) { + if (def.abstract) { + continue; + } + const owned = def.requiredOwnedElements; + if (!owned || owned.length === 0) { + continue; + } + const kids = new Set(); + for (const chain of owned) { + for (const child of chain) { + kids.add(child); + } + } + own.set(role, kids); + } + + // A role also legitimately owns whatever its ancestor roles in `superClass` + // own. This lets e.g. `treegrid` (superClass chains include `grid` and + // `tree`) inherit `row` from `grid` and `treeitem` from `tree`, matching + // the APG treegrid pattern. + const direct = new Map(); + for (const [role, def] of roles) { + if (def.abstract) { + continue; + } + const merged = new Set(own.get(role) || []); + for (const chain of def.superClass || []) { + for (const ancestor of chain) { + const inherited = own.get(ancestor); + if (inherited) { + for (const child of inherited) { + merged.add(child); + } + } + } + } + if (merged.size > 0) { + direct.set(role, merged); + } + } + + // Transitive closure: parent -> all roles reachable through owned-element chains. + const closed = new Map(); + function expand(role, visited) { + if (closed.has(role)) { + return closed.get(role); + } + const out = new Set(); + const kids = direct.get(role); + if (!kids) { + return out; + } + for (const child of kids) { + out.add(child); + if (!visited.has(child)) { + visited.add(child); + for (const grandchild of expand(child, visited)) { + out.add(grandchild); + } + } + } + closed.set(role, out); + return out; + } + for (const role of direct.keys()) { + expand(role, new Set([role])); + } + return closed; +} diff --git a/tests/lib/rules/template-no-invalid-interactive.js b/tests/lib/rules/template-no-invalid-interactive.js index 999058a383..03f066ef84 100644 --- a/tests/lib/rules/template-no-invalid-interactive.js +++ b/tests/lib/rules/template-no-invalid-interactive.js @@ -217,8 +217,9 @@ ruleTester.run('template-no-invalid-interactive', rule, { ], }, { - // role="tooltip" is document-structure per WAI-ARIA 1.2 §5.3.3 — NOT - // a widget, so a handler on it is as invalid as a handler on a bare div. + // `tooltip` is a Document Structure role per WAI-ARIA 1.2 §5.3.3, not a + // widget. Click handlers on a tooltip are therefore an invalid + // interactive handler on a non-interactive element. filename: 'test.gjs', code: '', output: null, diff --git a/tests/lib/rules/template-no-nested-interactive.js b/tests/lib/rules/template-no-nested-interactive.js index 920fd789f3..8b0eb6661b 100644 --- a/tests/lib/rules/template-no-nested-interactive.js +++ b/tests/lib/rules/template-no-nested-interactive.js @@ -123,6 +123,18 @@ ruleTester.run('template-no-nested-interactive', rule, { {{/if}} `, + + // Canonical ARIA composite-widget hierarchies — derived from aria-query's + // `requiredOwnedElements`. These are standard WAI-ARIA APG patterns and + // must not be flagged. + '', + '', + '', + '', + '', + '', + '', + '', ], invalid: [ @@ -354,9 +366,16 @@ hbsRuleTester.run('template-no-nested-interactive', rule, { code: '', options: [{ ignoreUsemapAttribute: true }], }, - //