|
| 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