@cocreate/aria 1.5.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/src/index.js CHANGED
@@ -1,353 +1,988 @@
1
- import Observer from '@cocreate/observer';
2
-
3
- /**
4
- * Main initialization entry point for ARIA attributes.
5
- * Orchestrates the setup of interactive controls and navigation states.
6
- * @param {Element|Element[]|NodeList} [elements] - Optional specific elements to initialize.
7
- * If omitted, the script scans the entire document for [aria-controls].
8
- */
9
- function init(elements) {
10
- // 1. Initialize aria-controls (Toggles, Popups, Tabs)
11
- let controlsElements = elements;
12
- if (!controlsElements) {
13
- controlsElements = document.querySelectorAll("[aria-controls]");
14
- }
15
- initElement(controlsElements);
16
-
17
- // 2. Initialize aria-current (Navigation links)
18
- // Performed after controls to ensure global scan happens last
19
- setAriaCurrent();
20
- }
21
-
22
- /**
23
- * Initializes interactive elements by binding click events and handling
24
- * accessibility state changes (aria-expanded, aria-hidden, aria-selected).
25
- * @param {Element|Element[]|NodeList} elements - Elements to be initialized as ARIA controls.
26
- */
27
- const initialized = new Set();
28
-
29
- function initElement(elements) {
30
- if (
31
- !Array.isArray(elements) &&
32
- !(elements instanceof NodeList) &&
33
- !(elements instanceof HTMLCollection)
34
- ) {
35
- elements = [elements];
36
- }
37
-
38
- if (elements.length === 0) return;
39
-
40
- for (let control of elements) {
41
- if (!control.hasAttribute('aria-controls')) continue;
42
- if (initialized.has(control)) continue;
43
- initialized.add(control);
44
-
45
- initEscapeKey(control);
46
-
47
- control.addEventListener("click", function (event) {
48
- // Only prevent default if it's not a standard link leading to another page
49
- const href = this.getAttribute("href");
50
- if (!href || href.startsWith("#") || this.hasAttribute("aria-haspopup")) {
51
- event.preventDefault();
52
- }
53
-
54
- const controlledId = this.getAttribute("aria-controls");
55
- const controlledElement = document.getElementById(controlledId);
56
-
57
- if (!controlledElement) {
58
- console.warn(`ARIA Controls: No element found with ID "${controlledId}" controlled by`, this);
59
- return;
60
- }
61
-
62
- const closeOn = controlledElement.getAttribute("aria-close-on");
63
- const role = this.getAttribute("role");
64
- const ariaOpenAttr = this.getAttribute("aria-open");
65
- const ariaCloseAttr = this.getAttribute("aria-close");
66
- // Treat these attributes as boolean-like: presence and not equal to "false"
67
- const hasAriaOpen = ariaOpenAttr !== null && ariaOpenAttr !== "false";
68
- const hasAriaClose = ariaCloseAttr !== null && ariaCloseAttr !== "false";
69
- const expanded = this.getAttribute("aria-expanded");
70
- const controlsClass = this.getAttribute("aria-controls-class") || "show";
71
- const group = this.getAttribute("aria-controls-group");
72
-
73
- // Prevent interaction if state is already in target position
74
- if (hasAriaOpen && expanded === "true") return;
75
- if (hasAriaClose && expanded !== "true") return;
76
-
77
- if (role === "tab") {
78
- // Tab Pattern: Mutual exclusivity within a tablist
79
- const tablist = this.closest("[role='tablist']");
80
- const tabs = tablist.querySelectorAll('[role="tab"]');
81
- for (let tab of tabs) {
82
- const tabControlledId = tab.getAttribute("aria-controls");
83
- const tabControlledEl = document.getElementById(tabControlledId);
84
- if (this === tab) {
85
- tab.setAttribute("aria-selected", "true");
86
- tabControlledEl.setAttribute("aria-hidden", "false");
87
- if (controlsClass) tabControlledEl.classList.add(controlsClass);
88
- } else {
89
- tab.setAttribute("aria-selected", "false");
90
- tabControlledEl.setAttribute("aria-hidden", "true");
91
- if (controlsClass) tabControlledEl.classList.remove(controlsClass);
92
- }
93
- }
94
- } else {
95
- // Toggle Pattern: Standard expand/collapse
96
- if (expanded === "true") {
97
- controlledElement.setAttribute("aria-hidden", "true");
98
- if (controlsClass) controlledElement.classList.remove(controlsClass);
99
- updateAllControls(controlledId, "false");
100
- removePopupListener();
101
- } else {
102
- controlledElement.setAttribute("aria-hidden", "false");
103
- if (controlsClass) controlledElement.classList.add(controlsClass);
104
-
105
- // Handle grouped controls (Accordions)
106
- if (group) {
107
- const groupedControls = document.querySelectorAll(`[aria-controls-group="${group}"][aria-expanded="true"]`);
108
- for (let groupedControl of groupedControls) {
109
- const groupedId = groupedControl.getAttribute("aria-controls");
110
- if (!groupedId || groupedId === controlledId) continue;
111
- const groupedElement = document.getElementById(groupedId);
112
- if (!groupedElement) continue;
113
- const gClass = groupedControl.getAttribute("aria-controls-class") || "show";
114
- groupedElement.setAttribute("aria-hidden", "true");
115
- if (gClass) groupedElement.classList.remove(gClass);
116
- updateAllControls(groupedId, "false");
117
- }
118
- }
119
- updateAllControls(controlledId, "true");
120
- if (closeOn !== "btn" && closeOn !== "button") {
121
- addPopupListener();
122
- }
123
- }
124
- }
125
- });
126
- }
127
- }
128
-
129
- /**
130
- * Synchronizes aria-expanded state across all elements controlling the same ID.
131
- * @param {string} controlledId - The ID of the target element.
132
- * @param {string} state - The target state ('true' or 'false').
133
- */
134
- function updateAllControls(controlledId, state) {
135
- const allControls = document.querySelectorAll(`[aria-controls="${controlledId}"]`);
136
- allControls.forEach((ctrl) => ctrl.setAttribute("aria-expanded", state));
137
- }
138
-
139
- /**
140
- * Binds a global click listener to handle "click-outside" behavior for popups.
141
- * Conditions are defined via 'aria-close-on' attribute (outside, inside, anywhere).
142
- */
143
- let popupListener = null;
144
-
145
- function addPopupListener() {
146
- if (!popupListener) {
147
- popupListener = function (event) {
148
- const hasPopUps = document.querySelectorAll(
149
- '[aria-controls][aria-haspopup][aria-expanded="true"]'
150
- );
151
-
152
- let skipControlledId = null;
153
- for (let hasPopUp of hasPopUps) {
154
- const controlledId = hasPopUp.getAttribute("aria-controls");
155
- if (skipControlledId === controlledId) continue;
156
- if (hasPopUp.contains(event.target)) {
157
- skipControlledId = controlledId;
158
- continue;
159
- }
160
-
161
- const controlledElement = document.getElementById(controlledId);
162
- if (!controlledElement) continue;
163
-
164
- // Check if the click occurred on an item explicitly marked to exclude closing
165
- const excludeElement = event.target.closest('[aria-close="false"]');
166
- if (excludeElement && controlledElement.contains(excludeElement)) {
167
- skipControlledId = controlledId;
168
- continue;
169
- }
170
-
171
- let closeOn = controlledElement.getAttribute("aria-close-on");
172
- let closeOnEl = controlledElement;
173
- if (!closeOn) {
174
- closeOnEl = event.target.closest(`#${controlledId} [aria-close-on]`);
175
- if (closeOnEl) closeOn = closeOnEl.getAttribute("aria-close-on");
176
- }
177
-
178
- let closeOnConditions = closeOn ? closeOn.split(",").map(c => c.trim()) : [];
179
- let shouldClose = false;
180
-
181
- for (let condition of closeOnConditions) {
182
- if (condition === "outside" && !closeOnEl.contains(event.target)) {
183
- shouldClose = true;
184
- } else if (condition === "inside" && closeOnEl.contains(event.target)) {
185
- shouldClose = true;
186
- } else if (condition === "anywhere") {
187
- shouldClose = true;
188
- }
189
- if (shouldClose) break;
190
- }
191
-
192
- if (!shouldClose) continue;
193
-
194
- controlledElement.classList.remove("show");
195
- controlledElement.setAttribute("aria-hidden", "true");
196
- updateAllControls(controlledId, "false");
197
- }
198
-
199
- if (!document.querySelector('[aria-controls][aria-haspopup][aria-expanded="true"]')) {
200
- document.removeEventListener("click", popupListener, true);
201
- popupListener = null;
202
- }
203
- };
204
- document.addEventListener("click", popupListener, true);
205
- }
206
- }
207
-
208
- /**
209
- * Removes the global popup click listener.
210
- */
211
- function removePopupListener() {
212
- if (popupListener) {
213
- document.removeEventListener("click", popupListener, true);
214
- popupListener = null;
215
- }
216
- }
217
-
218
- /**
219
- * Binds the Escape key to close the controlled element.
220
- * @param {Element} control - The trigger element that controls the target.
221
- */
222
- function initEscapeKey(control) {
223
- const controlledId = control.getAttribute("aria-controls");
224
- const controlledElement = document.getElementById(controlledId);
225
- if (controlledElement) {
226
- control.addEventListener("keydown", handleEscapeKey);
227
- controlledElement.addEventListener("keydown", handleEscapeKey);
228
- }
229
- }
230
-
231
- /**
232
- * Global handler for the Escape key to close open interactive elements.
233
- * @param {KeyboardEvent} event - The keyboard event object.
234
- */
235
- function handleEscapeKey(event) {
236
- if (event.key === "Escape") {
237
- const toggleButton = event.currentTarget.matches("[aria-controls]")
238
- ? event.currentTarget
239
- : document.querySelector(`[aria-controls="${event.currentTarget.id}"]`);
240
-
241
- if (toggleButton) {
242
- toggleButton.click();
243
- }
244
- }
245
- }
246
-
247
- /**
248
- * Manages navigation state attributes by scanning links and matching them against
249
- * the current URL and hash. Assigns aria-current="page" or "location".
250
- * @param {Element|Element[]|NodeList} [elements] - Links or containers to scan.
251
- * Defaults to all a[href] if not provided.
252
- */
253
- function setAriaCurrent(elements) {
254
- if (!elements) {
255
- elements = document.querySelectorAll("a[href]");
256
- }
257
-
258
- if (
259
- !Array.isArray(elements) &&
260
- !(elements instanceof NodeList) &&
261
- !(elements instanceof HTMLCollection)
262
- ) {
263
- elements = [elements];
264
- }
265
-
266
- const currentUrl = new URL(window.location.href);
267
- const currentPage = `${currentUrl.origin}${normalizePath(currentUrl.pathname)}`;
268
- const currentHash = currentUrl.hash;
269
-
270
- for (const item of elements) {
271
- const links = (item.nodeName === 'A' && item.hasAttribute('href'))
272
- ? [item]
273
- : (item.querySelectorAll ? item.querySelectorAll("a[href]") : []);
274
-
275
- for (const link of links) {
276
- const href = link.getAttribute("href") || "";
277
-
278
- if (!href || href === "#") {
279
- link.removeAttribute("aria-current");
280
- continue;
281
- }
282
-
283
- let linkUrl;
284
- try {
285
- linkUrl = new URL(href, window.location.href);
286
- } catch {
287
- continue;
288
- }
289
-
290
- const linkPage = `${linkUrl.origin}${normalizePath(linkUrl.pathname)}`;
291
- const isSamePage = linkPage === currentPage;
292
- const linkHasHash = Boolean(linkUrl.hash);
293
-
294
- let ariaValue = null;
295
-
296
- if (isSamePage) {
297
- // Match exact location (hash) or general page
298
- if (linkHasHash && linkUrl.hash === currentHash) {
299
- ariaValue = "location";
300
- } else if (!linkHasHash) {
301
- ariaValue = "page";
302
- }
303
- }
304
-
305
- if (ariaValue) {
306
- link.setAttribute("aria-current", ariaValue);
307
- } else {
308
- link.removeAttribute("aria-current");
309
- }
310
- }
311
- }
312
- }
313
-
314
- /**
315
- * Sanitizes and normalizes URL paths to prevent trailing slash mismatches.
316
- * @param {string} path - The raw URL path.
317
- * @returns {string} The normalized path string.
318
- */
319
- const normalizePath = (path) => path.replace(/\/$/, "") || "/";
320
-
321
- /**
322
- * Event Listeners and Observers
323
- * Handles browser navigation events and dynamic DOM mutations.
324
- */
325
- window.addEventListener("hashchange", () => setAriaCurrent());
326
- window.addEventListener("popstate", () => setAriaCurrent());
327
- document.addEventListener("keydown", handleEscapeKey);
328
-
329
- // Observer: Interactive controls
330
- Observer.init({
331
- name: "aria-controls",
332
- types: ["addedNodes"],
333
- selector: "[aria-controls]",
334
- callback: function (mutation) {
335
- initElement(mutation.target);
336
- }
337
- });
338
-
339
- // Observer: Navigation links
340
- Observer.init({
341
- name: "aria-current",
342
- types: ["addedNodes", "attributes"],
343
- selector: "a[href]",
344
- attributeFilters: ["href"],
345
- callback: function (mutation) {
346
- setAriaCurrent(mutation.target);
347
- }
348
- });
349
-
350
- // Initial execution
351
- init();
352
-
353
- export { init, initElement, setAriaCurrent };
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
+ };