@cocreate/aria 1.1.0 → 1.2.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/.github/workflows/automated.yml +5 -6
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/package.json +12 -12
- package/src/index.js +326 -207
|
@@ -22,13 +22,13 @@ jobs:
|
|
|
22
22
|
runs-on: ubuntu-latest
|
|
23
23
|
steps:
|
|
24
24
|
- name: Checkout
|
|
25
|
-
uses: actions/checkout@
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
26
|
- name: Setup Node.js
|
|
27
|
-
uses: actions/setup-node@
|
|
27
|
+
uses: actions/setup-node@v4
|
|
28
28
|
with:
|
|
29
|
-
node-version:
|
|
29
|
+
node-version: 22 # Required for the latest semantic-release plugins
|
|
30
30
|
- name: Semantic Release
|
|
31
|
-
uses: cycjimmy/semantic-release-action@
|
|
31
|
+
uses: cycjimmy/semantic-release-action@v4 # Update to v4 for better Node 20+ support
|
|
32
32
|
id: semantic
|
|
33
33
|
with:
|
|
34
34
|
extra_plugins: |
|
|
@@ -36,9 +36,8 @@ jobs:
|
|
|
36
36
|
@semantic-release/git
|
|
37
37
|
@semantic-release/github
|
|
38
38
|
env:
|
|
39
|
-
GITHUB_TOKEN: "${{ secrets.
|
|
39
|
+
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # Use the built-in token if possible
|
|
40
40
|
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
|
|
41
41
|
outputs:
|
|
42
42
|
new_release_published: "${{ steps.semantic.outputs.new_release_published }}"
|
|
43
43
|
new_release_version: "${{ steps.semantic.outputs.new_release_version }}"
|
|
44
|
-
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
# [1.2.0](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.1.1...v1.2.0) (2026-02-09)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* root factory variable Module ([6f35875](https://github.com/CoCreate-app/CoCreate-aria/commit/6f358757c9917ec52c397a35bc6f5e97976de6ed))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* aria-attribute-group for tab navigation grouping to deactivate state of other tabs in the group ([12822c0](https://github.com/CoCreate-app/CoCreate-aria/commit/12822c04fbe41672833852018242a58c179b9229))
|
|
12
|
+
|
|
13
|
+
## [1.1.1](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.1.0...v1.1.1) (2025-12-26)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* update worklow ([32b1cff](https://github.com/CoCreate-app/CoCreate-aria/commit/32b1cffdce4f66ab078407cae412a5583afb92a4))
|
|
19
|
+
|
|
1
20
|
# [1.1.0](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.0.0...v1.1.0) (2025-11-16)
|
|
2
21
|
|
|
3
22
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CoCreate-aria
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Orchestrate dynamic UI behavior and ARIA state with event-driven action chains, configured via HTML5 attributes or the JavaScript API. Take it for a spin in our [playground!](https://cocreate.app/docs/aria)
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|

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