Skip to content

Commit 5e06961

Browse files
committed
sync: interactive-roles to canonical (PR #27) for byte-identical merges
1 parent 962b695 commit 5e06961

1 file changed

Lines changed: 106 additions & 6 deletions

File tree

lib/utils/interactive-roles.js

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,43 @@
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
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.
1727
module.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+
1941
function 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

Comments
 (0)