11const { 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
7+ //
8+ // Authority: aria-query widget taxonomy, one manual addition, one manual
9+ // exclusion. Both documented below.
810//
911// `toolbar` is added explicitly — it does not descend from `widget` per
10- // 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.
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.
1215//
1316// `tooltip` is intentionally NOT added. Per WAI-ARIA 1.2 §5.3.3 — Document
1417// Structure Roles (https://www.w3.org/TR/wai-aria-1.2/#tooltip), tooltip is
1518// a document-structure role, not a widget; the spec says "document structures
16- // are not usually interactive." jsx-a11y and lit-a11y agree.
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.
1727module . exports . INTERACTIVE_ROLES = buildInteractiveRoleSet ( ) ;
1828
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+
1941function buildInteractiveRoleSet ( ) {
2042 const result = new Set ( [ 'toolbar' ] ) ;
2143 for ( const [ role , def ] of roles ) {
@@ -29,3 +51,81 @@ function buildInteractiveRoleSet() {
2951 }
3052 return result ;
3153}
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+ closed . set ( role , out ) ;
110+ return out ;
111+ }
112+ for ( const child of kids ) {
113+ out . add ( child ) ;
114+ if ( ! visited . has ( child ) ) {
115+ // Pass a fresh branch-specific visited set so sibling branches do not
116+ // contaminate each other's traversal. A shared Set across branches
117+ // makes memoized results order-dependent, because whether `child` is
118+ // recursed into depends on which sibling ran first.
119+ for ( const grandchild of expand ( child , new Set ( [ ...visited , child ] ) ) ) {
120+ out . add ( grandchild ) ;
121+ }
122+ }
123+ }
124+ closed . set ( role , out ) ;
125+ return out ;
126+ }
127+ for ( const role of direct . keys ( ) ) {
128+ expand ( role , new Set ( [ role ] ) ) ;
129+ }
130+ return closed ;
131+ }
0 commit comments