@cocreate/aria 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +1 -1
- package/README.md +115 -0
- package/package.json +5 -4
- package/src/index.js +988 -353
package/src/index.js
CHANGED
|
@@ -1,353 +1,988 @@
|
|
|
1
|
-
import Observer from '@cocreate/observer';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
1
|
+
import Observer from '@cocreate/observer';
|
|
2
|
+
import State from '@cocreate/state';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Main initialization entry point for ARIA attributes.
|
|
6
|
+
* Orchestrates the setup of interactive controls and navigation states.
|
|
7
|
+
* @param {Element|Element[]|NodeList} [elements] - Optional specific elements to initialize.
|
|
8
|
+
* If omitted, the script scans the document for controls, toggles, and custom checked controls.
|
|
9
|
+
*/
|
|
10
|
+
function init(elements) {
|
|
11
|
+
// 1. Initialize aria-controls (Toggles, Popups, Tabs)
|
|
12
|
+
let controlsElements = elements;
|
|
13
|
+
if (!controlsElements) {
|
|
14
|
+
controlsElements = document.querySelectorAll(interactiveSelector);
|
|
15
|
+
}
|
|
16
|
+
initElement(controlsElements);
|
|
17
|
+
|
|
18
|
+
// A controlled element is the canonical source when it already has state.
|
|
19
|
+
// This allows restored state (for example state.aria-hidden) to correct all
|
|
20
|
+
// of its controllers during initialization.
|
|
21
|
+
document.querySelectorAll("[id][aria-hidden]").forEach(syncControlledElement);
|
|
22
|
+
|
|
23
|
+
// 2. Initialize aria-current (Navigation links)
|
|
24
|
+
// Performed after controls to ensure global scan happens last
|
|
25
|
+
setAriaCurrent();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initializes interactive elements by binding click events and handling
|
|
30
|
+
* accessibility state changes (aria-expanded, aria-hidden, aria-selected).
|
|
31
|
+
* @param {Element|Element[]|NodeList} elements - Elements to be initialized as ARIA controls.
|
|
32
|
+
*/
|
|
33
|
+
const interactiveSelector = '[aria-controls], [aria-pressed], [role="radio"], [role="checkbox"], [role="switch"], :is([role="row"][aria-selected]), :is([role="grid"][aria-multiselectable="true"] [role="row"] input[type="checkbox"])';
|
|
34
|
+
const addedInteractiveSelector = '[aria-controls], [aria-pressed], [role="radio"], [role="checkbox"], [role="switch"], [aria-selected], input[type="checkbox"]';
|
|
35
|
+
const initialized = new WeakSet();
|
|
36
|
+
|
|
37
|
+
function initElement(elements) {
|
|
38
|
+
if (
|
|
39
|
+
!Array.isArray(elements) &&
|
|
40
|
+
!(elements instanceof NodeList) &&
|
|
41
|
+
!(elements instanceof HTMLCollection)
|
|
42
|
+
) {
|
|
43
|
+
elements = [elements];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (elements.length === 0) return;
|
|
47
|
+
|
|
48
|
+
for (let control of elements) {
|
|
49
|
+
if (!control?.matches?.(interactiveSelector)) continue;
|
|
50
|
+
if (initialized.has(control)) continue;
|
|
51
|
+
initialized.add(control);
|
|
52
|
+
|
|
53
|
+
control.addEventListener("click", event => {
|
|
54
|
+
if (!isDisabled(control)) return;
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
event.stopImmediatePropagation();
|
|
57
|
+
}, true);
|
|
58
|
+
control.addEventListener("keydown", handleSelectionKey);
|
|
59
|
+
syncSelection(control);
|
|
60
|
+
control.addEventListener("click", event => {
|
|
61
|
+
const selection = getSelection(control, true);
|
|
62
|
+
if (selection?.role === "row") return;
|
|
63
|
+
if (selection?.native) {
|
|
64
|
+
syncLinkedSelection(control, selection, control.checked);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!selection || selection.attribute === "aria-selected") return;
|
|
68
|
+
const selected = selection.exclusive || control.getAttribute(selection.attribute) !== "true";
|
|
69
|
+
selectItem(control, selection, selected);
|
|
70
|
+
syncLinkedSelection(control, selection, selected);
|
|
71
|
+
});
|
|
72
|
+
if (!control.hasAttribute("aria-controls") || isSelectionControl(control)) continue;
|
|
73
|
+
initEscapeKey(control);
|
|
74
|
+
|
|
75
|
+
control.addEventListener("click", function (event) {
|
|
76
|
+
if (isSelectionControl(this)) return;
|
|
77
|
+
|
|
78
|
+
const controlledId = this.getAttribute("aria-controls");
|
|
79
|
+
const controlledElement = document.getElementById(controlledId);
|
|
80
|
+
|
|
81
|
+
if (!controlledElement) {
|
|
82
|
+
console.warn(`ARIA Controls: No element found with ID "${controlledId}" controlled by`, this);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const closeOn = controlledElement.getAttribute("aria-close-on");
|
|
87
|
+
const role = this.getAttribute("role");
|
|
88
|
+
const ariaOpenAttr = this.getAttribute("aria-open");
|
|
89
|
+
const ariaCloseAttr = this.getAttribute("aria-close");
|
|
90
|
+
// Treat these attributes as boolean-like: presence and not equal to "false"
|
|
91
|
+
const hasAriaOpen = ariaOpenAttr !== null && ariaOpenAttr !== "false";
|
|
92
|
+
const hasAriaClose = ariaCloseAttr !== null && ariaCloseAttr !== "false";
|
|
93
|
+
const hidden = controlledElement.getAttribute("aria-hidden");
|
|
94
|
+
const expanded = hidden === null
|
|
95
|
+
? this.getAttribute("aria-expanded")
|
|
96
|
+
: hidden === "true" ? "false" : "true";
|
|
97
|
+
const controlsClass = this.getAttribute("aria-controls-class") || "show";
|
|
98
|
+
const group = this.getAttribute("aria-controls-group");
|
|
99
|
+
|
|
100
|
+
// Prevent interaction if state is already in target position
|
|
101
|
+
if (hasAriaOpen && expanded === "true") return;
|
|
102
|
+
if (hasAriaClose && expanded !== "true") return;
|
|
103
|
+
|
|
104
|
+
if (role === "tab") {
|
|
105
|
+
activateTab(this);
|
|
106
|
+
} else {
|
|
107
|
+
// Toggle Pattern: Standard expand/collapse
|
|
108
|
+
if (expanded === "true") {
|
|
109
|
+
controlledElement.setAttribute("aria-hidden", "true");
|
|
110
|
+
if (controlsClass) controlledElement.classList.remove(controlsClass);
|
|
111
|
+
updateAllControls(controlledId, "false");
|
|
112
|
+
removePopupListener();
|
|
113
|
+
} else {
|
|
114
|
+
controlledElement.setAttribute("aria-hidden", "false");
|
|
115
|
+
if (controlsClass) controlledElement.classList.add(controlsClass);
|
|
116
|
+
|
|
117
|
+
// Handle grouped controls (Accordions)
|
|
118
|
+
if (group) {
|
|
119
|
+
const groupedControls = document.querySelectorAll(`[aria-controls-group="${group}"][aria-expanded="true"]`);
|
|
120
|
+
for (let groupedControl of groupedControls) {
|
|
121
|
+
if (isSelectionControl(groupedControl)) continue;
|
|
122
|
+
const groupedId = groupedControl.getAttribute("aria-controls");
|
|
123
|
+
if (!groupedId || groupedId === controlledId) continue;
|
|
124
|
+
const groupedElement = document.getElementById(groupedId);
|
|
125
|
+
if (!groupedElement) continue;
|
|
126
|
+
const gClass = groupedControl.getAttribute("aria-controls-class") || "show";
|
|
127
|
+
groupedElement.setAttribute("aria-hidden", "true");
|
|
128
|
+
if (gClass) groupedElement.classList.remove(gClass);
|
|
129
|
+
updateAllControls(groupedId, "false");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
updateAllControls(controlledId, "true");
|
|
133
|
+
if (closeOn !== "btn" && closeOn !== "button") {
|
|
134
|
+
addPopupListener();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Synchronizes aria-expanded state across all elements controlling the same ID.
|
|
144
|
+
* @param {string} controlledId - The ID of the target element.
|
|
145
|
+
* @param {string} state - The target state ('true' or 'false').
|
|
146
|
+
*/
|
|
147
|
+
function updateAllControls(controlledId, state) {
|
|
148
|
+
const allControls = getControls(controlledId);
|
|
149
|
+
allControls.forEach((ctrl) => {
|
|
150
|
+
if (isSelectionControl(ctrl)) return;
|
|
151
|
+
if (ctrl.getAttribute("role") === "tab") {
|
|
152
|
+
setSelectionState(ctrl, "aria-selected", state === "true", true);
|
|
153
|
+
} else setAttributeIfChanged(ctrl, "aria-expanded", state);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Returns every controller referencing an ID. In addition to the common
|
|
159
|
+
* single-ID form, tolerate whitespace- or comma-separated references.
|
|
160
|
+
*/
|
|
161
|
+
function getControls(controlledId) {
|
|
162
|
+
return Array.from(document.querySelectorAll("[aria-controls]")).filter(control =>
|
|
163
|
+
(control.getAttribute("aria-controls") || "")
|
|
164
|
+
.split(/[\s,]+/)
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.includes(controlledId)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Writes an attribute only when its normalized string value changes.
|
|
172
|
+
* This keeps controller/controlled-element mutation synchronization finite.
|
|
173
|
+
*/
|
|
174
|
+
function setAttributeIfChanged(element, attribute, value) {
|
|
175
|
+
const nextValue = String(value);
|
|
176
|
+
if (element.getAttribute(attribute) === nextValue) return false;
|
|
177
|
+
element.setAttribute(attribute, nextValue);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Pressed and checked controls reference their targets without owning visibility.
|
|
182
|
+
function isSelectionControl(control) {
|
|
183
|
+
return control.matches('input[type="radio"], input[type="checkbox"]') ||
|
|
184
|
+
control.hasAttribute("aria-pressed") || control.hasAttribute("aria-checked");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isDisabled(control) {
|
|
188
|
+
return control.matches(":disabled") || control.getAttribute("aria-disabled") === "true";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getGroupItems(control, role, groupRole) {
|
|
192
|
+
const group = control.closest(`[role="${groupRole}"]`);
|
|
193
|
+
return group ? Array.from(group.querySelectorAll(`[role="${role}"]`))
|
|
194
|
+
.filter(item => item.closest(`[role="${groupRole}"]`) === group &&
|
|
195
|
+
!(role === "radio" && item.matches('input[type="radio"]'))) : [control];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function getTabs(control) {
|
|
199
|
+
return getGroupItems(control, "tab", "tablist");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getSelection(control, includeNative = false) {
|
|
203
|
+
// Native inputs retain browser activation and keyboard behavior.
|
|
204
|
+
if (control.matches('input[type="radio"], input[type="checkbox"]')) {
|
|
205
|
+
return includeNative ? {
|
|
206
|
+
attribute: "aria-checked", native: true, role: control.type,
|
|
207
|
+
exclusive: control.type === "radio", groupRole: "radiogroup"
|
|
208
|
+
} : null;
|
|
209
|
+
}
|
|
210
|
+
const role = control.getAttribute("role");
|
|
211
|
+
if (role === "row" && control.hasAttribute("aria-selected") &&
|
|
212
|
+
control.closest('[role="grid"][aria-multiselectable="true"]'))
|
|
213
|
+
return { attribute: "aria-selected", role, groupRole: "grid" };
|
|
214
|
+
if (role === "tab") return { attribute: "aria-selected", exclusive: true, groupRole: "tablist", role };
|
|
215
|
+
if (role === "radio") return { attribute: "aria-checked", exclusive: true, groupRole: "radiogroup", role };
|
|
216
|
+
if (role === "checkbox" || role === "switch") return { attribute: "aria-checked" };
|
|
217
|
+
if (control.hasAttribute("aria-pressed")) return { attribute: "aria-pressed" };
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function setSelectionState(control, attribute, selected, roving = false) {
|
|
222
|
+
if (control.matches('input[type="radio"], input[type="checkbox"]')) {
|
|
223
|
+
control.checked = selected;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
setAttributeIfChanged(control, attribute, selected ? "true" : "false");
|
|
227
|
+
if (attribute !== "aria-selected") control.classList.toggle("active", selected);
|
|
228
|
+
if (roving) setAttributeIfChanged(control, "tabindex", selected ? "0" : "-1");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function availableItems(items) {
|
|
232
|
+
return items.filter(item => !isDisabled(item) &&
|
|
233
|
+
!item.closest('[aria-hidden="true"], [hidden]:not([aria-hidden="false"]), [template], template'));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setTabStop(items, entry) {
|
|
237
|
+
for (const item of items) setAttributeIfChanged(item, "tabindex", item === entry ? "0" : "-1");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function updateRadioTabStops(control, selection) {
|
|
241
|
+
const items = getGroupItems(control, selection.role, selection.groupRole);
|
|
242
|
+
const available = availableItems(items);
|
|
243
|
+
const entry = available.find(item => item.getAttribute(selection.attribute) === "true") || available[0];
|
|
244
|
+
setTabStop(items, entry);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function selectItem(control, selection, selected) {
|
|
248
|
+
const items = selection.exclusive
|
|
249
|
+
? getGroupItems(control, selection.role, selection.groupRole) : [control];
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
setSelectionState(item, selection.attribute, selected && item === control, selection.exclusive);
|
|
252
|
+
}
|
|
253
|
+
if (selection.role === "radio") updateRadioTabStops(control, selection);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const gridAnchors = new WeakMap();
|
|
257
|
+
const gridInteractive = 'a,button,input,textarea,select,label,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="checkbox"],[role="radio"],[role="switch"]';
|
|
258
|
+
|
|
259
|
+
function gridRows(row) {
|
|
260
|
+
return availableItems(getGroupItems(row, "row", "grid"))
|
|
261
|
+
.filter(item => item.hasAttribute("aria-selected") &&
|
|
262
|
+
!item.closest('[hidden],[template],template'));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function syncGridTabStops(row) {
|
|
266
|
+
const items = gridRows(row);
|
|
267
|
+
if (!items.length) return;
|
|
268
|
+
setTabStop(items, items.find(item => item.tabIndex === 0) || items[0]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function focusGridRow(row, items) {
|
|
272
|
+
setTabStop(items, row);
|
|
273
|
+
row.focus({ preventScroll: true });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function selectGridRow(row, event) {
|
|
277
|
+
const grid = row.closest('[role="grid"][aria-multiselectable="true"]');
|
|
278
|
+
const items = gridRows(row);
|
|
279
|
+
if (!grid || !items.includes(row)) return;
|
|
280
|
+
if (event.target.closest(gridInteractive)) return;
|
|
281
|
+
|
|
282
|
+
const anchor = gridAnchors.get(grid);
|
|
283
|
+
if (event.shiftKey && items.includes(anchor)) {
|
|
284
|
+
const start = items.indexOf(anchor);
|
|
285
|
+
const end = items.indexOf(row);
|
|
286
|
+
for (let index = 0; index < items.length; index++) {
|
|
287
|
+
const inRange = index >= Math.min(start, end) && index <= Math.max(start, end);
|
|
288
|
+
const selected = inRange || ((event.ctrlKey || event.metaKey) && items[index].getAttribute("aria-selected") === "true");
|
|
289
|
+
setSelectionState(items[index], "aria-selected", selected);
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
const toggle = event.ctrlKey || event.metaKey;
|
|
293
|
+
const selected = row.getAttribute("aria-selected") !== "true";
|
|
294
|
+
for (const item of items) {
|
|
295
|
+
if (!toggle || item === row)
|
|
296
|
+
setSelectionState(item, "aria-selected", item === row && (toggle ? selected : true));
|
|
297
|
+
}
|
|
298
|
+
gridAnchors.set(grid, row);
|
|
299
|
+
}
|
|
300
|
+
focusGridRow(row, items);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function selectGridCheckbox(checkbox, event) {
|
|
304
|
+
const row = checkbox.closest('[role="row"]');
|
|
305
|
+
const grid = row?.closest('[role="grid"][aria-multiselectable="true"]');
|
|
306
|
+
if (!grid) return;
|
|
307
|
+
const items = availableItems(Array.from(grid.querySelectorAll('[role="row"] input[type="checkbox"]')))
|
|
308
|
+
.filter(item => item.name === checkbox.name &&
|
|
309
|
+
item.closest('[role="grid"]') === grid &&
|
|
310
|
+
!isDisabled(item.closest('[role="row"]')) &&
|
|
311
|
+
!item.closest('[hidden],[template],template'));
|
|
312
|
+
const anchor = gridAnchors.get(grid);
|
|
313
|
+
if (!event.shiftKey || !items.includes(anchor) || !items.includes(checkbox)) {
|
|
314
|
+
gridAnchors.set(grid, checkbox);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const start = items.indexOf(anchor);
|
|
318
|
+
const end = items.indexOf(checkbox);
|
|
319
|
+
for (const item of items.slice(Math.min(start, end), Math.max(start, end) + 1)) {
|
|
320
|
+
if (item === checkbox || item.checked === checkbox.checked) continue;
|
|
321
|
+
item.checked = checkbox.checked;
|
|
322
|
+
item.dispatchEvent(new Event("input", { bubbles: true }));
|
|
323
|
+
item.dispatchEvent(new Event("change", { bubbles: true }));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Rendered cards and table rows can arrive after the initial ARIA scan.
|
|
328
|
+
// Handle their clicks at the grid so selection does not depend on binding
|
|
329
|
+
// each new record before its first interaction.
|
|
330
|
+
document.addEventListener("click", event => {
|
|
331
|
+
const checkbox = event.target.closest('input[type="checkbox"]');
|
|
332
|
+
if (checkbox?.closest('[role="row"]')?.closest('[role="grid"][aria-multiselectable="true"]')) {
|
|
333
|
+
selectGridCheckbox(checkbox, event);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const row = event.target.closest('[role="row"][aria-selected]');
|
|
338
|
+
if (row) selectGridRow(row, event);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
function handleGridRowKey(event, row) {
|
|
342
|
+
if (event.target !== row) return;
|
|
343
|
+
const items = gridRows(row);
|
|
344
|
+
if (!items.includes(row)) return;
|
|
345
|
+
const grid = row.closest('[role="grid"][aria-multiselectable="true"]');
|
|
346
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
|
|
347
|
+
event.preventDefault();
|
|
348
|
+
for (const item of items) setSelectionState(item, "aria-selected", true);
|
|
349
|
+
gridAnchors.delete(grid);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (event.key === "Escape") {
|
|
353
|
+
event.preventDefault();
|
|
354
|
+
for (const item of items) setSelectionState(item, "aria-selected", false);
|
|
355
|
+
gridAnchors.delete(grid);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (event.key === " " || event.key === "Spacebar") {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
selectGridRow(row, { target: row, shiftKey: event.shiftKey, ctrlKey: true, metaKey: event.metaKey });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const index = items.indexOf(row);
|
|
364
|
+
let next;
|
|
365
|
+
if (event.key === "Home") next = items[0];
|
|
366
|
+
else if (event.key === "End") next = items.at(-1);
|
|
367
|
+
else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
368
|
+
const rtl = getComputedStyle(grid).direction === "rtl";
|
|
369
|
+
next = items[index + ((event.key === "ArrowRight") !== rtl ? 1 : -1)];
|
|
370
|
+
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
371
|
+
const box = row.getBoundingClientRect();
|
|
372
|
+
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
373
|
+
next = items.map(item => ({ item, box: item.getBoundingClientRect() }))
|
|
374
|
+
.filter(candidate => (candidate.box.top - box.top) * direction > 1)
|
|
375
|
+
.sort((a, b) => Math.abs(a.box.top - box.top) - Math.abs(b.box.top - box.top) ||
|
|
376
|
+
Math.abs(a.box.left - box.left) - Math.abs(b.box.left - box.left))[0]?.item;
|
|
377
|
+
} else return;
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
if (!next) return;
|
|
380
|
+
if (event.shiftKey) {
|
|
381
|
+
if (!items.includes(gridAnchors.get(grid))) gridAnchors.set(grid, row);
|
|
382
|
+
selectGridRow(next, { target: next, shiftKey: true, ctrlKey: event.ctrlKey, metaKey: event.metaKey });
|
|
383
|
+
} else focusGridRow(next, items);
|
|
384
|
+
next.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
document.addEventListener("mousedown", event => {
|
|
388
|
+
if (event.shiftKey && event.target.closest('[role="grid"][aria-multiselectable="true"]') &&
|
|
389
|
+
!event.target.closest(gridInteractive)) event.preventDefault();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
function syncLinkedSelection(control, selection, selected) {
|
|
393
|
+
const ids = (control.getAttribute("aria-controls") || "").split(/[\s,]+/).filter(Boolean);
|
|
394
|
+
if (!ids.length) return;
|
|
395
|
+
const controls = new Set(ids.flatMap(getControls));
|
|
396
|
+
const radios = [];
|
|
397
|
+
const nativeChanges = [];
|
|
398
|
+
let hasSelectedToggle = false;
|
|
399
|
+
for (const item of controls) {
|
|
400
|
+
const peer = getSelection(item, true);
|
|
401
|
+
if (!peer || peer.attribute === "aria-selected") continue;
|
|
402
|
+
if (peer.role === "radio") radios.push({ item, selection: peer });
|
|
403
|
+
if (item !== control && selected && (selection.role === "radio" || peer.role === "radio")) {
|
|
404
|
+
if (peer.native && item.checked) nativeChanges.push(item);
|
|
405
|
+
setSelectionState(item, peer.attribute, false);
|
|
406
|
+
if (!peer.native && peer.role === "radio") updateRadioTabStops(item, peer);
|
|
407
|
+
}
|
|
408
|
+
if (peer.role !== "radio" && (peer.native ? item.checked : item.getAttribute(peer.attribute) !== "false")) {
|
|
409
|
+
hasSelectedToggle = true;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// "next" follows the next selected radio, never an independent toggle.
|
|
413
|
+
if (selected && selection.role === "radio") {
|
|
414
|
+
const movingDefaults = radios.filter(({ item }) => item.getAttribute("aria-default") === "next");
|
|
415
|
+
if (movingDefaults.length) {
|
|
416
|
+
for (const { item } of movingDefaults) {
|
|
417
|
+
if (item !== control) item.removeAttribute("aria-default");
|
|
418
|
+
}
|
|
419
|
+
setAttributeIfChanged(control, "aria-default", "next");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Only an explicitly declared default restores selection after the last toggle.
|
|
423
|
+
if (!selected && !hasSelectedToggle) {
|
|
424
|
+
const defaults = radios.filter(({ item }) => item.hasAttribute("aria-default") &&
|
|
425
|
+
item.getAttribute("aria-default") !== "false" && !isDisabled(item));
|
|
426
|
+
if (defaults.length === 1) {
|
|
427
|
+
const { item, selection: fallback } = defaults[0];
|
|
428
|
+
if (fallback.native) {
|
|
429
|
+
if (!item.checked) nativeChanges.push(item);
|
|
430
|
+
setSelectionState(item, fallback.attribute, true);
|
|
431
|
+
} else selectItem(item, fallback, true);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Notify value consumers after all linked inputs have their final state.
|
|
435
|
+
for (const item of nativeChanges) {
|
|
436
|
+
if (item.type === "radio" && !item.checked) continue;
|
|
437
|
+
item.dispatchEvent(new Event("input", { bubbles: true }));
|
|
438
|
+
item.dispatchEvent(new Event("change", { bubbles: true }));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function syncSelection(control) {
|
|
443
|
+
const selection = getSelection(control);
|
|
444
|
+
if (selection?.role === "row") {
|
|
445
|
+
syncGridTabStops(control);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!selection || selection.attribute === "aria-selected") return;
|
|
449
|
+
const value = control.getAttribute(selection.attribute);
|
|
450
|
+
if (selection.exclusive && value === "true") {
|
|
451
|
+
selectItem(control, selection, true);
|
|
452
|
+
} else {
|
|
453
|
+
// Preserve mixed states until the user explicitly activates the control.
|
|
454
|
+
if (value === null) setAttributeIfChanged(control, selection.attribute, "false");
|
|
455
|
+
control.classList.toggle("active", value === "true");
|
|
456
|
+
if (selection.exclusive) updateRadioTabStops(control, selection);
|
|
457
|
+
}
|
|
458
|
+
if (!selection.exclusive && !control.hasAttribute("tabindex") &&
|
|
459
|
+
!control.matches('button, input, a[href]')) setAttributeIfChanged(control, "tabindex", "0");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function activateTab(control) {
|
|
463
|
+
const targetId = control.getAttribute("aria-controls");
|
|
464
|
+
if (!document.getElementById(targetId)) return;
|
|
465
|
+
for (const tab of getTabs(control)) {
|
|
466
|
+
const id = tab.getAttribute("aria-controls");
|
|
467
|
+
const panel = document.getElementById(id);
|
|
468
|
+
if (!panel) continue;
|
|
469
|
+
const selected = id === targetId;
|
|
470
|
+
setSelectionState(tab, "aria-selected", selected, true);
|
|
471
|
+
setAttributeIfChanged(panel, "aria-hidden", selected ? "false" : "true");
|
|
472
|
+
panel.classList.toggle(tab.getAttribute("aria-controls-class") || "show", selected);
|
|
473
|
+
updateAllControls(id, selected ? "true" : "false");
|
|
474
|
+
syncControlledElementState(panel);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function handleSelectionKey(event) {
|
|
479
|
+
const control = event.currentTarget;
|
|
480
|
+
const selection = getSelection(control);
|
|
481
|
+
if (!selection) return;
|
|
482
|
+
if (isDisabled(control)) {
|
|
483
|
+
if (["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
event.stopImmediatePropagation();
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (selection.role === "row") {
|
|
490
|
+
handleGridRowKey(event, control);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
let items = selection.exclusive
|
|
494
|
+
? availableItems(getGroupItems(control, selection.role, selection.groupRole)) : [control];
|
|
495
|
+
if (selection.role === "tab") items = items.filter(item => document.getElementById(item.getAttribute("aria-controls")));
|
|
496
|
+
const index = items.indexOf(control);
|
|
497
|
+
if (index < 0) return;
|
|
498
|
+
const vertical = control.closest(`[role="${selection.groupRole}"]`)?.getAttribute("aria-orientation") === "vertical";
|
|
499
|
+
let destination;
|
|
500
|
+
if (event.key === "Enter" || event.key === " ") destination = index;
|
|
501
|
+
else if (selection.exclusive) {
|
|
502
|
+
const nextKeys = selection.role === "radio" ? ["ArrowRight", "ArrowDown"] : [vertical ? "ArrowDown" : "ArrowRight"];
|
|
503
|
+
const previousKeys = selection.role === "radio" ? ["ArrowLeft", "ArrowUp"] : [vertical ? "ArrowUp" : "ArrowLeft"];
|
|
504
|
+
if (nextKeys.includes(event.key)) destination = (index + 1) % items.length;
|
|
505
|
+
else if (previousKeys.includes(event.key)) destination = (index - 1 + items.length) % items.length;
|
|
506
|
+
else if (event.key === "Home") destination = 0;
|
|
507
|
+
else if (event.key === "End") destination = items.length - 1;
|
|
508
|
+
}
|
|
509
|
+
if (destination === undefined) return;
|
|
510
|
+
event.preventDefault();
|
|
511
|
+
if (event.repeat && !selection.exclusive) return;
|
|
512
|
+
items[destination].focus();
|
|
513
|
+
const toolbarNavigation = selection.role === "radio" && control.closest('[role="toolbar"]') &&
|
|
514
|
+
event.key !== " " && event.key !== "Enter";
|
|
515
|
+
if (toolbarNavigation) {
|
|
516
|
+
for (const item of items) setAttributeIfChanged(item, "tabindex", item === items[destination] ? "0" : "-1");
|
|
517
|
+
} else items[destination].click();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Applies a controller's aria-expanded value to its controlled element and
|
|
522
|
+
* synchronizes any other controllers that reference the same element.
|
|
523
|
+
*/
|
|
524
|
+
function syncController(control) {
|
|
525
|
+
const controlledId = control?.getAttribute?.("aria-controls");
|
|
526
|
+
if (!controlledId || isSelectionControl(control)) return;
|
|
527
|
+
|
|
528
|
+
const controlledElement = document.getElementById(controlledId);
|
|
529
|
+
if (!controlledElement) return;
|
|
530
|
+
|
|
531
|
+
const isTab = control.getAttribute("role") === "tab";
|
|
532
|
+
const expanded = control.getAttribute(isTab ? "aria-selected" : "aria-expanded") === "true";
|
|
533
|
+
if (isTab && expanded) {
|
|
534
|
+
activateTab(control);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const controlsClass = control.getAttribute("aria-controls-class") || "show";
|
|
538
|
+
|
|
539
|
+
setAttributeIfChanged(controlledElement, "aria-hidden", expanded ? "false" : "true");
|
|
540
|
+
if (controlsClass) controlledElement.classList.toggle(controlsClass, expanded);
|
|
541
|
+
updateAllControls(controlledId, expanded ? "true" : "false");
|
|
542
|
+
|
|
543
|
+
if (expanded) {
|
|
544
|
+
const group = control.getAttribute("aria-controls-group");
|
|
545
|
+
if (group) {
|
|
546
|
+
const groupedControls = document.querySelectorAll(
|
|
547
|
+
`[aria-controls-group="${group}"][aria-expanded="true"]`
|
|
548
|
+
);
|
|
549
|
+
for (const groupedControl of groupedControls) {
|
|
550
|
+
if (isSelectionControl(groupedControl)) continue;
|
|
551
|
+
const groupedId = groupedControl.getAttribute("aria-controls");
|
|
552
|
+
if (!groupedId || groupedId === controlledId) continue;
|
|
553
|
+
updateAllControls(groupedId, "false");
|
|
554
|
+
syncController(groupedControl);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const closeOn = controlledElement.getAttribute("aria-close-on");
|
|
559
|
+
if ((control.hasAttribute("aria-haspopup") || closeOn) && closeOn !== "btn" && closeOn !== "button") {
|
|
560
|
+
addPopupListener();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Treats aria-hidden on the controlled element as canonical and uses the first
|
|
567
|
+
* controller to fan that state out to every controller for the same ID.
|
|
568
|
+
*/
|
|
569
|
+
function syncControlledElement(controlledElement) {
|
|
570
|
+
const controlledId = controlledElement?.id;
|
|
571
|
+
if (!controlledId || !controlledElement.hasAttribute("aria-hidden")) return;
|
|
572
|
+
|
|
573
|
+
const expanded = controlledElement.getAttribute("aria-hidden") !== "true";
|
|
574
|
+
const expandedValue = expanded ? "true" : "false";
|
|
575
|
+
const controls = getControls(controlledId).filter(control => !isSelectionControl(control));
|
|
576
|
+
if (!controls.length) return;
|
|
577
|
+
|
|
578
|
+
// The controlled element is canonical in this direction. Update every
|
|
579
|
+
// controller directly rather than depending on one controller to fan out.
|
|
580
|
+
updateAllControls(controlledId, expandedValue);
|
|
581
|
+
if (expanded) {
|
|
582
|
+
for (const control of controls) {
|
|
583
|
+
if (control.getAttribute("role") === "tab") activateTab(control);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const controlsClass = controls[0].getAttribute("aria-controls-class") || "show";
|
|
588
|
+
if (controlsClass) controlledElement.classList.toggle(controlsClass, expanded);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Initializes a newly added controller from its controlled element when that
|
|
593
|
+
* element already has aria-hidden state. Initialization never writes state
|
|
594
|
+
* from a controller to a controlled element.
|
|
595
|
+
*/
|
|
596
|
+
function syncAddedController(control) {
|
|
597
|
+
const controlledId = control?.getAttribute?.("aria-controls");
|
|
598
|
+
if (!controlledId || isSelectionControl(control)) return;
|
|
599
|
+
|
|
600
|
+
const controlledElement = document.getElementById(controlledId);
|
|
601
|
+
if (!controlledElement) return;
|
|
602
|
+
|
|
603
|
+
if (controlledElement.hasAttribute("aria-hidden")) {
|
|
604
|
+
syncControlledElement(controlledElement);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Persists a controlled element's ARIA state when it explicitly opts into
|
|
610
|
+
* state persistence with state-id and state.aria-hidden.
|
|
611
|
+
*/
|
|
612
|
+
const pendingStateElements = new Set();
|
|
613
|
+
let stateTimer;
|
|
614
|
+
function syncControlledElementState(controlledElement) {
|
|
615
|
+
if (!controlledElement?.hasAttribute?.("state.aria-hidden") ||
|
|
616
|
+
!controlledElement.hasAttribute("state-id")) return;
|
|
617
|
+
pendingStateElements.add(controlledElement);
|
|
618
|
+
clearTimeout(stateTimer);
|
|
619
|
+
// Let all panel/controller mutations settle before recording a tab switch.
|
|
620
|
+
stateTimer = setTimeout(() => {
|
|
621
|
+
const groups = { session: {}, local: {} };
|
|
622
|
+
for (const element of pendingStateElements) {
|
|
623
|
+
if (!element.isConnected) continue;
|
|
624
|
+
// A tab can be hidden before its replacement is opened (including
|
|
625
|
+
// direct attribute edits). Do not save that intermediate, empty
|
|
626
|
+
// selection as a destination for browser Back/Forward.
|
|
627
|
+
const tabControls = getControls(element.id).filter(control =>
|
|
628
|
+
control.getAttribute("role") === "tab" && control.closest('[role="tablist"]')
|
|
629
|
+
);
|
|
630
|
+
if (tabControls.some(control => !getTabs(control).some(tab => {
|
|
631
|
+
const panel = document.getElementById(tab.getAttribute("aria-controls"));
|
|
632
|
+
return panel && panel.getAttribute("aria-hidden") !== "true";
|
|
633
|
+
}))) continue;
|
|
634
|
+
const value = element.getAttribute("aria-hidden");
|
|
635
|
+
if (value === null) continue;
|
|
636
|
+
const group = element.hasAttribute("state-localstorage") ||
|
|
637
|
+
element.hasAttribute("state-onstoragechange") ? groups.local : groups.session;
|
|
638
|
+
for (const id of element.getAttribute("state-id").split(',').map(id => id.trim()).filter(Boolean)) {
|
|
639
|
+
group[id] = { "aria-hidden": value };
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
pendingStateElements.clear();
|
|
643
|
+
for (const [storage, state] of Object.entries(groups)) {
|
|
644
|
+
if (!Object.keys(state).length) continue;
|
|
645
|
+
let snapshot = {};
|
|
646
|
+
try { snapshot = JSON.parse(window.history.state?.[`${storage}State`] || '{}'); } catch {}
|
|
647
|
+
// State restoration already owns this history entry. Do not push
|
|
648
|
+
// it again when ARIA observes the restored attributes.
|
|
649
|
+
if (Object.entries(state).every(([id, value]) => snapshot?.[id]?.["aria-hidden"] === value["aria-hidden"])) continue;
|
|
650
|
+
State.setState(state, { localStorage: storage === "local" });
|
|
651
|
+
}
|
|
652
|
+
}, 0);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Binds a global click listener to handle "click-outside" behavior for popups.
|
|
657
|
+
* Conditions are defined via 'aria-close-on' attribute (outside, inside, anywhere).
|
|
658
|
+
*/
|
|
659
|
+
let popupListener = null;
|
|
660
|
+
|
|
661
|
+
function getOpenPopupControls() {
|
|
662
|
+
return Array.from(document.querySelectorAll('[aria-controls][aria-expanded="true"]')).filter(control =>
|
|
663
|
+
!isSelectionControl(control) && (control.hasAttribute("aria-haspopup") ||
|
|
664
|
+
document.getElementById(control.getAttribute("aria-controls"))?.hasAttribute("aria-close-on"))
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function isInteractionInside(element, target) {
|
|
669
|
+
if (element.contains(target)) return true;
|
|
670
|
+
|
|
671
|
+
// Activating <label for="…"> dispatches a second native click on its
|
|
672
|
+
// associated control. The control may live outside a visual popup even
|
|
673
|
+
// though the label that initiated the interaction is inside it.
|
|
674
|
+
return Array.from(target?.labels || []).some(label =>
|
|
675
|
+
element.contains(label)
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function addPopupListener() {
|
|
680
|
+
if (!popupListener) {
|
|
681
|
+
popupListener = function (event) {
|
|
682
|
+
const hasPopUps = getOpenPopupControls();
|
|
683
|
+
|
|
684
|
+
let skipControlledId = null;
|
|
685
|
+
for (let hasPopUp of hasPopUps) {
|
|
686
|
+
const controlledId = hasPopUp.getAttribute("aria-controls");
|
|
687
|
+
if (skipControlledId === controlledId) continue;
|
|
688
|
+
// Any control for this target counts as its trigger, regardless
|
|
689
|
+
// of controller order or whether it declares aria-haspopup.
|
|
690
|
+
if (getControls(controlledId).some(control => !isSelectionControl(control) && control.contains(event.target))) {
|
|
691
|
+
skipControlledId = controlledId;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const controlledElement = document.getElementById(controlledId);
|
|
696
|
+
if (!controlledElement) continue;
|
|
697
|
+
|
|
698
|
+
// Check if the click occurred on an item explicitly marked to exclude closing
|
|
699
|
+
const excludeElement = event.target.closest('[aria-close="false"]');
|
|
700
|
+
if (excludeElement && controlledElement.contains(excludeElement)) {
|
|
701
|
+
skipControlledId = controlledId;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
let closeOn = controlledElement.getAttribute("aria-close-on");
|
|
706
|
+
let closeOnEl = controlledElement;
|
|
707
|
+
if (!closeOn) {
|
|
708
|
+
closeOnEl = event.target.closest(`#${controlledId} [aria-close-on]`);
|
|
709
|
+
// A nested popup owns its close rule; it must not close the parent panel.
|
|
710
|
+
if (closeOnEl && getControls(closeOnEl.id).length) continue;
|
|
711
|
+
if (closeOnEl) closeOn = closeOnEl.getAttribute("aria-close-on");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// A popup trigger can provide aria-haspopup for accessibility
|
|
715
|
+
// without opting into automatic click-based closing.
|
|
716
|
+
if (!closeOn || !closeOnEl) continue;
|
|
717
|
+
|
|
718
|
+
let closeOnConditions = closeOn.split(",").map(c => c.trim());
|
|
719
|
+
let shouldClose = false;
|
|
720
|
+
const interactionInside = isInteractionInside(closeOnEl, event.target);
|
|
721
|
+
|
|
722
|
+
for (let condition of closeOnConditions) {
|
|
723
|
+
if (condition === "outside" && !interactionInside) {
|
|
724
|
+
shouldClose = true;
|
|
725
|
+
} else if (condition === "inside" && interactionInside) {
|
|
726
|
+
shouldClose = true;
|
|
727
|
+
} else if (condition === "anywhere") {
|
|
728
|
+
shouldClose = true;
|
|
729
|
+
}
|
|
730
|
+
if (shouldClose) break;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!shouldClose) continue;
|
|
734
|
+
|
|
735
|
+
controlledElement.classList.remove("show");
|
|
736
|
+
controlledElement.setAttribute("aria-hidden", "true");
|
|
737
|
+
updateAllControls(controlledId, "false");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!getOpenPopupControls().length) {
|
|
741
|
+
document.removeEventListener("click", popupListener, true);
|
|
742
|
+
popupListener = null;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
document.addEventListener("click", popupListener, true);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Removes the global popup click listener.
|
|
751
|
+
*/
|
|
752
|
+
function removePopupListener() {
|
|
753
|
+
if (popupListener) {
|
|
754
|
+
document.removeEventListener("click", popupListener, true);
|
|
755
|
+
popupListener = null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Binds the Escape key to close the controlled element.
|
|
761
|
+
* @param {Element} control - The trigger element that controls the target.
|
|
762
|
+
*/
|
|
763
|
+
function initEscapeKey(control) {
|
|
764
|
+
const controlledId = control.getAttribute("aria-controls");
|
|
765
|
+
const controlledElement = document.getElementById(controlledId);
|
|
766
|
+
if (controlledElement) {
|
|
767
|
+
control.addEventListener("keydown", handleEscapeKey);
|
|
768
|
+
controlledElement.addEventListener("keydown", handleEscapeKey);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Global handler for the Escape key to close open interactive elements.
|
|
774
|
+
* @param {KeyboardEvent} event - The keyboard event object.
|
|
775
|
+
*/
|
|
776
|
+
function handleEscapeKey(event) {
|
|
777
|
+
if (event.key === "Escape") {
|
|
778
|
+
const toggleButton = event.currentTarget.matches?.("[aria-controls]")
|
|
779
|
+
? event.currentTarget
|
|
780
|
+
: getControls(event.currentTarget.id).find(control => !isSelectionControl(control));
|
|
781
|
+
|
|
782
|
+
if (toggleButton && !isSelectionControl(toggleButton)) {
|
|
783
|
+
toggleButton.click();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Manages navigation state attributes by scanning links and matching them against
|
|
790
|
+
* the current URL and hash. Assigns aria-current="page" or "location".
|
|
791
|
+
* @param {Element|Element[]|NodeList} [elements] - Links or containers to scan.
|
|
792
|
+
* Defaults to all a[href] if not provided.
|
|
793
|
+
*/
|
|
794
|
+
function setAriaCurrent(elements) {
|
|
795
|
+
if (!elements) {
|
|
796
|
+
elements = document.querySelectorAll("a[href]");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (
|
|
800
|
+
!Array.isArray(elements) &&
|
|
801
|
+
!(elements instanceof NodeList) &&
|
|
802
|
+
!(elements instanceof HTMLCollection)
|
|
803
|
+
) {
|
|
804
|
+
elements = [elements];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const currentUrl = new URL(window.location.href);
|
|
808
|
+
const currentPage = `${currentUrl.origin}${normalizePath(currentUrl.pathname)}`;
|
|
809
|
+
const currentHash = currentUrl.hash;
|
|
810
|
+
|
|
811
|
+
for (const item of elements) {
|
|
812
|
+
const links = (item.nodeName === 'A' && item.hasAttribute('href'))
|
|
813
|
+
? [item]
|
|
814
|
+
: (item.querySelectorAll ? item.querySelectorAll("a[href]") : []);
|
|
815
|
+
|
|
816
|
+
for (const link of links) {
|
|
817
|
+
const href = link.getAttribute("href") || "";
|
|
818
|
+
|
|
819
|
+
if (!href || href === "#") {
|
|
820
|
+
link.removeAttribute("aria-current");
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let linkUrl;
|
|
825
|
+
try {
|
|
826
|
+
linkUrl = new URL(href, window.location.href);
|
|
827
|
+
} catch {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const linkPage = `${linkUrl.origin}${normalizePath(linkUrl.pathname)}`;
|
|
832
|
+
const isSamePage = linkPage === currentPage;
|
|
833
|
+
const linkHasHash = Boolean(linkUrl.hash);
|
|
834
|
+
|
|
835
|
+
let ariaValue = null;
|
|
836
|
+
|
|
837
|
+
if (isSamePage) {
|
|
838
|
+
// Match exact location (hash) or general page
|
|
839
|
+
if (linkHasHash && linkUrl.hash === currentHash) {
|
|
840
|
+
ariaValue = "location";
|
|
841
|
+
} else if (!linkHasHash) {
|
|
842
|
+
ariaValue = "page";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (ariaValue) {
|
|
847
|
+
link.setAttribute("aria-current", ariaValue);
|
|
848
|
+
} else {
|
|
849
|
+
link.removeAttribute("aria-current");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Sanitizes and normalizes URL paths to prevent trailing slash mismatches.
|
|
857
|
+
* @param {string} path - The raw URL path.
|
|
858
|
+
* @returns {string} The normalized path string.
|
|
859
|
+
*/
|
|
860
|
+
const normalizePath = (path) => path.replace(/\/$/, "") || "/";
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Event Listeners and Observers
|
|
864
|
+
* Handles browser navigation events and dynamic DOM mutations.
|
|
865
|
+
*/
|
|
866
|
+
window.addEventListener("hashchange", () => setAriaCurrent());
|
|
867
|
+
window.addEventListener("popstate", () => setAriaCurrent());
|
|
868
|
+
document.addEventListener("keydown", handleEscapeKey);
|
|
869
|
+
|
|
870
|
+
// Observer: Interactive controls
|
|
871
|
+
Observer.init({
|
|
872
|
+
name: "aria-controls",
|
|
873
|
+
types: ["addedNodes"],
|
|
874
|
+
selector: addedInteractiveSelector,
|
|
875
|
+
callback: function (mutation) {
|
|
876
|
+
initElement(mutation.target);
|
|
877
|
+
syncAddedController(mutation.target);
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
Observer.init({
|
|
882
|
+
name: "aria-grid-row-removed",
|
|
883
|
+
types: ["removedNodes"],
|
|
884
|
+
selector: ':is([role="row"][aria-selected])',
|
|
885
|
+
callback: function (mutation) {
|
|
886
|
+
const grid = mutation.mutationOrigin.closest('[role="grid"][aria-multiselectable="true"]');
|
|
887
|
+
const row = grid?.querySelector('[role="row"][aria-selected]');
|
|
888
|
+
if (row) syncGridTabStops(row);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// Selection changes from state restoration or other modules use the same logic.
|
|
893
|
+
Observer.init({
|
|
894
|
+
name: "aria-selection-state",
|
|
895
|
+
types: ["attributes"],
|
|
896
|
+
selector: interactiveSelector,
|
|
897
|
+
attributeFilter: ["aria-pressed", "aria-checked", "aria-selected", "aria-disabled", "disabled", "role"],
|
|
898
|
+
callback: function (mutation) {
|
|
899
|
+
if (mutation.oldValue === mutation.target.getAttribute(mutation.attributeName)) return;
|
|
900
|
+
initElement(mutation.target);
|
|
901
|
+
// Selection changes do not alter the grid's roving tab stop.
|
|
902
|
+
if (mutation.attributeName === "aria-selected" && mutation.target.getAttribute("role") === "row") return;
|
|
903
|
+
syncSelection(mutation.target);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// A disclosure may reveal or hide radios within a larger group.
|
|
908
|
+
Observer.init({
|
|
909
|
+
name: "aria-selection-visibility",
|
|
910
|
+
types: ["attributes"],
|
|
911
|
+
selector: "*",
|
|
912
|
+
attributeFilter: ["aria-hidden", "hidden"],
|
|
913
|
+
callback: function (mutation) {
|
|
914
|
+
for (const radio of mutation.target.querySelectorAll('[role="radio"]')) {
|
|
915
|
+
const selection = getSelection(radio);
|
|
916
|
+
if (selection) updateRadioTabStops(radio, selection);
|
|
917
|
+
}
|
|
918
|
+
const grids = new Set(mutation.target.querySelectorAll('[role="grid"][aria-multiselectable="true"]'));
|
|
919
|
+
const grid = mutation.target.closest('[role="grid"][aria-multiselectable="true"]');
|
|
920
|
+
if (grid) grids.add(grid);
|
|
921
|
+
for (const item of grids) {
|
|
922
|
+
const row = item.querySelector('[role="row"][aria-selected]');
|
|
923
|
+
if (row) syncGridTabStops(row);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// Observer: Apply externally changed controller state to its controlled element.
|
|
929
|
+
Observer.init({
|
|
930
|
+
name: "aria-controller-state",
|
|
931
|
+
types: ["attributes"],
|
|
932
|
+
selector: "[aria-controls]",
|
|
933
|
+
attributeFilter: ["aria-expanded", "aria-selected"],
|
|
934
|
+
callback: function (mutation) {
|
|
935
|
+
const element = mutation.target;
|
|
936
|
+
const currentValue = element.getAttribute(mutation.attributeName);
|
|
937
|
+
if (mutation.oldValue === currentValue) return;
|
|
938
|
+
const isTab = element.getAttribute("role") === "tab";
|
|
939
|
+
if (mutation.attributeName !== (isTab ? "aria-selected" : "aria-expanded")) return;
|
|
940
|
+
syncController(element);
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// Observer: Treat externally changed controlled-element state as canonical and
|
|
945
|
+
// synchronize every controller that references it.
|
|
946
|
+
Observer.init({
|
|
947
|
+
name: "aria-controlled-state",
|
|
948
|
+
types: ["addedNodes", "attributes"],
|
|
949
|
+
selector: "[aria-hidden]",
|
|
950
|
+
attributeFilter: ["aria-hidden"],
|
|
951
|
+
callback: function (mutation) {
|
|
952
|
+
const element = mutation.target;
|
|
953
|
+
if (!element.id) return;
|
|
954
|
+
|
|
955
|
+
if (mutation.type === "attributes") {
|
|
956
|
+
const currentValue = element.getAttribute(mutation.attributeName);
|
|
957
|
+
if (mutation.oldValue === currentValue) return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
syncControlledElement(element);
|
|
961
|
+
if (mutation.type === "attributes") syncControlledElementState(element);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Observer: Navigation links
|
|
966
|
+
Observer.init({
|
|
967
|
+
name: "aria-current",
|
|
968
|
+
types: ["addedNodes", "attributes"],
|
|
969
|
+
selector: "a[href]",
|
|
970
|
+
attributeFilters: ["href"],
|
|
971
|
+
callback: function (mutation) {
|
|
972
|
+
setAriaCurrent(mutation.target);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// Initial execution
|
|
977
|
+
init();
|
|
978
|
+
|
|
979
|
+
export {
|
|
980
|
+
init,
|
|
981
|
+
initElement,
|
|
982
|
+
setAriaCurrent,
|
|
983
|
+
syncController,
|
|
984
|
+
syncAddedController,
|
|
985
|
+
syncControlledElement,
|
|
986
|
+
syncControlledElementState,
|
|
987
|
+
getControls
|
|
988
|
+
};
|