Skip to content

Commit b7bae63

Browse files
committed
sync: interactive-roles to canonical (PR #27) for byte-identical merges
1 parent c55f67c commit b7bae63

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

lib/utils/interactive-roles.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
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)