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