@cocreate/aria 1.1.1 → 1.3.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 +23 -10
- package/.github/workflows/manual.yml +1 -2
- package/CHANGELOG.md +29 -0
- package/README.md +1 -1
- package/package.json +20 -22
- package/release.config.js +11 -3
- package/src/index.js +329 -207
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
name: Automated Workflow
|
|
2
|
+
|
|
2
3
|
on:
|
|
3
4
|
push:
|
|
4
5
|
branches:
|
|
5
6
|
- master
|
|
7
|
+
|
|
6
8
|
jobs:
|
|
7
9
|
about:
|
|
8
10
|
runs-on: ubuntu-latest
|
|
@@ -18,26 +20,37 @@ jobs:
|
|
|
18
20
|
with:
|
|
19
21
|
direction: overwrite-github
|
|
20
22
|
githubToken: "${{ secrets.GITHUB }}"
|
|
23
|
+
|
|
21
24
|
release:
|
|
22
25
|
runs-on: ubuntu-latest
|
|
23
26
|
steps:
|
|
24
27
|
- name: Checkout
|
|
25
28
|
uses: actions/checkout@v4
|
|
29
|
+
with:
|
|
30
|
+
fetch-depth: 0 # Required so semantic-release can trace git tags/history
|
|
31
|
+
|
|
26
32
|
- name: Setup Node.js
|
|
27
33
|
uses: actions/setup-node@v4
|
|
28
34
|
with:
|
|
29
|
-
node-version: 22
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@semantic-release/changelog
|
|
35
|
+
node-version: 22
|
|
36
|
+
|
|
37
|
+
- name: Install Semantic Release & Plugins
|
|
38
|
+
# Installs semantic-release and its plugins on the runner
|
|
39
|
+
run: |
|
|
40
|
+
npm install -g semantic-release \
|
|
41
|
+
@semantic-release/changelog \
|
|
42
|
+
@semantic-release/npm \
|
|
43
|
+
@semantic-release/github \
|
|
36
44
|
@semantic-release/git
|
|
37
|
-
|
|
45
|
+
|
|
46
|
+
- name: Run Semantic Release (Native)
|
|
47
|
+
id: semantic
|
|
48
|
+
# This will automatically pick up your export default config file in the repository root
|
|
49
|
+
run: npx semantic-release
|
|
38
50
|
env:
|
|
39
|
-
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
51
|
+
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
|
40
52
|
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
|
|
53
|
+
|
|
41
54
|
outputs:
|
|
42
55
|
new_release_published: "${{ steps.semantic.outputs.new_release_published }}"
|
|
43
|
-
new_release_version: "${{ steps.semantic.outputs.new_release_version }}"
|
|
56
|
+
new_release_version: "${{ steps.semantic.outputs.new_release_version }}"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
# [1.3.0](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.2.1...v1.3.0) (2026-07-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* update automated workflow and release configuration for improved semantic release handling ([496feb1](https://github.com/CoCreate-app/CoCreate-aria/commit/496feb179258d215ed33d644e0e412712bf9b2a7))
|
|
7
|
+
|
|
8
|
+
## [1.2.1](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.2.0...v1.2.1) (2026-07-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* bump package versions ([2ad08d0](https://github.com/CoCreate-app/CoCreate-aria/commit/2ad08d0434fd16624190902a3da18d3e1aabdee2))
|
|
14
|
+
* removed post install and add add packages to apprved list ([100c02b](https://github.com/CoCreate-app/CoCreate-aria/commit/100c02b93154001d03f2cb9ce6da41ea9c337df2))
|
|
15
|
+
* semantic version handling ([a64a089](https://github.com/CoCreate-app/CoCreate-aria/commit/a64a089292f5d7dd86d6439066081f8d701c8404))
|
|
16
|
+
* update aria-open and aria-close attribute handling to treat them as boolean-like ([608bb59](https://github.com/CoCreate-app/CoCreate-aria/commit/608bb59372d3fc0dabbcda96f8be4ef4e34d8405))
|
|
17
|
+
|
|
18
|
+
# [1.2.0](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.1.1...v1.2.0) (2026-02-09)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* root factory variable Module ([6f35875](https://github.com/CoCreate-app/CoCreate-aria/commit/6f358757c9917ec52c397a35bc6f5e97976de6ed))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* 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))
|
|
29
|
+
|
|
1
30
|
## [1.1.1](https://github.com/CoCreate-app/CoCreate-aria/compare/v1.1.0...v1.1.1) (2025-12-26)
|
|
2
31
|
|
|
3
32
|
|
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.3.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"
|
|
@@ -19,8 +21,7 @@
|
|
|
19
21
|
"scripts": {
|
|
20
22
|
"start": "npx webpack --config webpack.config.js",
|
|
21
23
|
"build": "npx webpack --mode=production --config webpack.config.js",
|
|
22
|
-
"dev": "npx webpack --config webpack.config.js --watch"
|
|
23
|
-
"postinstall": "node -e \"const { execSync } = require('child_process'); try { execSync('coc --version', { stdio: 'ignore' }); } catch (error) { try { execSync('npm install -g @cocreate/cli', { stdio: 'inherit' }); console.log('Installed \"@cocreate/cli\" globally.'); } catch (error) { console.error('Failed to install \"@cocreate/cli\" globally:', error); } }\""
|
|
24
|
+
"dev": "npx webpack --config webpack.config.js --watch"
|
|
24
25
|
},
|
|
25
26
|
"repository": {
|
|
26
27
|
"type": "git",
|
|
@@ -37,18 +38,15 @@
|
|
|
37
38
|
"url": "https://github.com/sponsors/CoCreate-app"
|
|
38
39
|
},
|
|
39
40
|
"main": "./src/index.js",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@cocreate/observer": "^1.19.0"
|
|
43
|
+
},
|
|
40
44
|
"devDependencies": {
|
|
41
|
-
"
|
|
42
|
-
"esbuild": "^0.25.2",
|
|
43
|
-
"esbuild-loader": "^4.3.0",
|
|
44
|
-
"mini-css-extract-plugin": "^1.5.0",
|
|
45
|
-
"webpack": "^5.24.4",
|
|
46
|
-
"webpack-cli": "^4.5.0",
|
|
47
|
-
"webpack-log": "^3.0.1"
|
|
45
|
+
"@cocreate/webpack": "^1.4.3"
|
|
48
46
|
},
|
|
49
|
-
"
|
|
50
|
-
"@cocreate/observer
|
|
51
|
-
"@cocreate/
|
|
52
|
-
"@cocreate/
|
|
47
|
+
"allowScripts": {
|
|
48
|
+
"@cocreate/observer@1.19.0": true,
|
|
49
|
+
"@cocreate/utils@1.42.2": true,
|
|
50
|
+
"@cocreate/webpack@1.4.3": true
|
|
53
51
|
}
|
|
54
52
|
}
|
package/release.config.js
CHANGED
|
@@ -10,12 +10,20 @@ module.exports = {
|
|
|
10
10
|
changelogFile: "CHANGELOG.md",
|
|
11
11
|
},
|
|
12
12
|
],
|
|
13
|
-
"@semantic-release/npm",
|
|
14
|
-
"@semantic-release/github",
|
|
13
|
+
"@semantic-release/npm",
|
|
15
14
|
[
|
|
16
|
-
"@semantic-release/
|
|
15
|
+
"@semantic-release/github",
|
|
17
16
|
{
|
|
17
|
+
successComment: false,
|
|
18
|
+
failTitle: false,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
"@semantic-release/git",
|
|
23
|
+
{
|
|
24
|
+
// Only stage and commit the changelog and package.json
|
|
18
25
|
assets: ["CHANGELOG.md", "package.json"],
|
|
26
|
+
message: "chore(release): ${nextRelease.version} [skip ci]",
|
|
19
27
|
},
|
|
20
28
|
],
|
|
21
29
|
],
|
package/src/index.js
CHANGED
|
@@ -1,231 +1,353 @@
|
|
|
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 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
|
+
}
|
|
76
127
|
}
|
|
77
128
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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));
|
|
83
137
|
}
|
|
84
138
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
177
206
|
}
|
|
178
207
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
}
|
|
184
216
|
}
|
|
185
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Binds the Escape key to close the controlled element.
|
|
220
|
+
* @param {Element} control - The trigger element that controls the target.
|
|
221
|
+
*/
|
|
186
222
|
function initEscapeKey(control) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
|
193
229
|
}
|
|
194
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Global handler for the Escape key to close open interactive elements.
|
|
233
|
+
* @param {KeyboardEvent} event - The keyboard event object.
|
|
234
|
+
*/
|
|
195
235
|
function handleEscapeKey(event) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
toggleButton.click();
|
|
206
|
-
}
|
|
207
|
-
}
|
|
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
|
+
}
|
|
208
245
|
}
|
|
209
246
|
|
|
210
|
-
|
|
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());
|
|
211
327
|
document.addEventListener("keydown", handleEscapeKey);
|
|
212
328
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
}
|
|
220
337
|
});
|
|
221
338
|
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
});
|
|
230
349
|
|
|
350
|
+
// Initial execution
|
|
231
351
|
init();
|
|
352
|
+
|
|
353
|
+
export { init, initElement, setAriaCurrent };
|