accessible-kit 1.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/LICENSE +21 -0
- package/README.md +1038 -0
- package/package.json +81 -0
- package/src/README.md +2240 -0
- package/src/css/a11y-accordion.core.css +94 -0
- package/src/css/a11y-accordion.theme.css +246 -0
- package/src/css/a11y-dropdown.core.css +169 -0
- package/src/css/a11y-dropdown.theme.css +175 -0
- package/src/css/a11y-modal.core.css +136 -0
- package/src/css/a11y-modal.theme.css +218 -0
- package/src/css/a11y-offcanvas.core.css +122 -0
- package/src/css/a11y-offcanvas.theme.css +170 -0
- package/src/css/a11y-tabs.core.css +120 -0
- package/src/css/a11y-tabs.theme.css +312 -0
- package/src/js/a11y-accordion.js +447 -0
- package/src/js/a11y-dropdown.js +353 -0
- package/src/js/a11y-modal.js +290 -0
- package/src/js/a11y-offcanvas.js +298 -0
- package/src/js/a11y-tabs.js +336 -0
- package/src/js/index.js +46 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessible Dropdown Component
|
|
3
|
+
* Vanilla JS, a11y friendly, keyboard navigation support
|
|
4
|
+
* Works for navigation menus, language switchers, etc.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class AccessibleDropdown {
|
|
8
|
+
constructor(element, options = {}) {
|
|
9
|
+
this.dropdown = element;
|
|
10
|
+
this.button = element.querySelector("[data-dropdown-button]");
|
|
11
|
+
this.menu = element.querySelector("[data-dropdown-menu]");
|
|
12
|
+
this.items = Array.from(
|
|
13
|
+
this.menu.querySelectorAll("[data-dropdown-item]")
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// Options
|
|
17
|
+
this.options = {
|
|
18
|
+
closeOnSelect: options.closeOnSelect !== false,
|
|
19
|
+
closeOnOutsideClick: options.closeOnOutsideClick !== false,
|
|
20
|
+
closeOnEscape: options.closeOnEscape !== false,
|
|
21
|
+
hoverDelay: options.hoverDelay || 0,
|
|
22
|
+
onOpen: options.onOpen || null,
|
|
23
|
+
onClose: options.onClose || null,
|
|
24
|
+
onSelect: options.onSelect || null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
this.isOpen = false;
|
|
28
|
+
this.currentIndex = -1;
|
|
29
|
+
this.hoverTimeout = null;
|
|
30
|
+
|
|
31
|
+
// Register instance globally for managing multiple dropdowns
|
|
32
|
+
if (typeof window !== 'undefined') {
|
|
33
|
+
window.a11yKit = window.a11yKit || {};
|
|
34
|
+
window.a11yKit._dropdownInstances = window.a11yKit._dropdownInstances || [];
|
|
35
|
+
window.a11yKit._dropdownInstances.push(this);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.init();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
init() {
|
|
42
|
+
// Set up ARIA attributes
|
|
43
|
+
this.setupAria();
|
|
44
|
+
|
|
45
|
+
// Bind event listeners
|
|
46
|
+
this.button.addEventListener("click", (e) => this.toggle(e));
|
|
47
|
+
this.button.addEventListener("keydown", (e) =>
|
|
48
|
+
this.handleButtonKeydown(e)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Menu item events
|
|
52
|
+
this.items.forEach((item, index) => {
|
|
53
|
+
item.addEventListener("click", (e) =>
|
|
54
|
+
this.handleItemClick(e, index)
|
|
55
|
+
);
|
|
56
|
+
item.addEventListener("keydown", (e) =>
|
|
57
|
+
this.handleItemKeydown(e, index)
|
|
58
|
+
);
|
|
59
|
+
item.addEventListener("mouseenter", () =>
|
|
60
|
+
this.handleItemHover(index)
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Outside click
|
|
65
|
+
if (this.options.closeOnOutsideClick) {
|
|
66
|
+
document.addEventListener("click", (e) =>
|
|
67
|
+
this.handleOutsideClick(e)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Escape key
|
|
72
|
+
if (this.options.closeOnEscape) {
|
|
73
|
+
document.addEventListener("keydown", (e) => {
|
|
74
|
+
if (e.key === "Escape" && this.isOpen) {
|
|
75
|
+
this.close();
|
|
76
|
+
this.button.focus();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setupAria() {
|
|
83
|
+
// Generate unique IDs if not present
|
|
84
|
+
if (!this.button.id) {
|
|
85
|
+
this.button.id = `dropdown-button-${Math.random()
|
|
86
|
+
.toString(36)
|
|
87
|
+
.substr(2, 9)}`;
|
|
88
|
+
}
|
|
89
|
+
if (!this.menu.id) {
|
|
90
|
+
this.menu.id = `dropdown-menu-${Math.random()
|
|
91
|
+
.toString(36)
|
|
92
|
+
.substr(2, 9)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Button ARIA attributes
|
|
96
|
+
this.button.setAttribute("aria-haspopup", "true");
|
|
97
|
+
this.button.setAttribute("aria-expanded", "false");
|
|
98
|
+
this.button.setAttribute("aria-controls", this.menu.id);
|
|
99
|
+
|
|
100
|
+
// Menu ARIA attributes
|
|
101
|
+
this.menu.setAttribute("role", "menu");
|
|
102
|
+
this.menu.setAttribute("aria-labelledby", this.button.id);
|
|
103
|
+
|
|
104
|
+
// Menu items ARIA attributes
|
|
105
|
+
this.items.forEach((item) => {
|
|
106
|
+
item.setAttribute("role", "menuitem");
|
|
107
|
+
if (!item.hasAttribute("tabindex")) {
|
|
108
|
+
item.setAttribute("tabindex", "-1");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
toggle(e) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
e.stopPropagation();
|
|
116
|
+
this.isOpen ? this.close() : this.open();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
open() {
|
|
120
|
+
if (this.isOpen) return;
|
|
121
|
+
|
|
122
|
+
// Close other dropdowns first
|
|
123
|
+
if (typeof window !== 'undefined' && window.a11yKit._dropdownInstances) {
|
|
124
|
+
window.a11yKit._dropdownInstances.forEach(instance => {
|
|
125
|
+
if (instance !== this && instance.isOpen) {
|
|
126
|
+
instance.close();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.isOpen = true;
|
|
132
|
+
this.dropdown.classList.add("is-open");
|
|
133
|
+
this.menu.classList.add("is-open");
|
|
134
|
+
this.button.setAttribute("aria-expanded", "true");
|
|
135
|
+
this.currentIndex = -1;
|
|
136
|
+
|
|
137
|
+
// Focus first item after a short delay for screen readers
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
if (this.items.length > 0) {
|
|
140
|
+
this.setFocusedItem(0);
|
|
141
|
+
}
|
|
142
|
+
}, 100);
|
|
143
|
+
|
|
144
|
+
if (this.options.onOpen) {
|
|
145
|
+
this.options.onOpen(this);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
close() {
|
|
150
|
+
if (!this.isOpen) return;
|
|
151
|
+
|
|
152
|
+
this.isOpen = false;
|
|
153
|
+
this.dropdown.classList.remove("is-open");
|
|
154
|
+
this.menu.classList.remove("is-open");
|
|
155
|
+
this.button.setAttribute("aria-expanded", "false");
|
|
156
|
+
this.currentIndex = -1;
|
|
157
|
+
|
|
158
|
+
// Remove focus from items
|
|
159
|
+
this.items.forEach((item) => {
|
|
160
|
+
item.setAttribute("tabindex", "-1");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (this.options.onClose) {
|
|
164
|
+
this.options.onClose(this);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
handleButtonKeydown(e) {
|
|
169
|
+
switch (e.key) {
|
|
170
|
+
case "Enter":
|
|
171
|
+
case " ":
|
|
172
|
+
case "ArrowDown":
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
if (!this.isOpen) {
|
|
175
|
+
this.open();
|
|
176
|
+
} else if (e.key === "ArrowDown") {
|
|
177
|
+
this.focusNextItem();
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case "ArrowUp":
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
if (this.isOpen) {
|
|
183
|
+
this.focusPreviousItem();
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
handleItemKeydown(e, index) {
|
|
190
|
+
switch (e.key) {
|
|
191
|
+
case "Enter":
|
|
192
|
+
case " ":
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
this.selectItem(index);
|
|
195
|
+
break;
|
|
196
|
+
case "ArrowDown":
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
this.focusNextItem();
|
|
199
|
+
break;
|
|
200
|
+
case "ArrowUp":
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
this.focusPreviousItem();
|
|
203
|
+
break;
|
|
204
|
+
case "Home":
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
this.setFocusedItem(0);
|
|
207
|
+
break;
|
|
208
|
+
case "End":
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
this.setFocusedItem(this.items.length - 1);
|
|
211
|
+
break;
|
|
212
|
+
case "Tab":
|
|
213
|
+
this.close();
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
handleItemClick(e, index) {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
e.stopPropagation();
|
|
221
|
+
this.selectItem(index);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
handleItemHover(index) {
|
|
225
|
+
if (this.hoverTimeout) {
|
|
226
|
+
clearTimeout(this.hoverTimeout);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.hoverTimeout = setTimeout(() => {
|
|
230
|
+
this.setFocusedItem(index);
|
|
231
|
+
}, this.options.hoverDelay);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
handleOutsideClick(e) {
|
|
235
|
+
if (this.isOpen && !this.dropdown.contains(e.target)) {
|
|
236
|
+
this.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
selectItem(index) {
|
|
241
|
+
const item = this.items[index];
|
|
242
|
+
|
|
243
|
+
if (this.options.onSelect) {
|
|
244
|
+
this.options.onSelect(item, index, this);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Handle link items
|
|
248
|
+
const link = item.tagName === "A" ? item : item.querySelector("a");
|
|
249
|
+
if (link && link.href) {
|
|
250
|
+
window.location.href = link.href;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (this.options.closeOnSelect) {
|
|
254
|
+
this.close();
|
|
255
|
+
this.button.focus();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
setFocusedItem(index) {
|
|
260
|
+
// Remove tabindex from all items
|
|
261
|
+
this.items.forEach((item) => {
|
|
262
|
+
item.setAttribute("tabindex", "-1");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Set current item
|
|
266
|
+
if (index >= 0 && index < this.items.length) {
|
|
267
|
+
this.currentIndex = index;
|
|
268
|
+
const item = this.items[index];
|
|
269
|
+
item.setAttribute("tabindex", "0");
|
|
270
|
+
item.focus();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
focusNextItem() {
|
|
275
|
+
const nextIndex = this.currentIndex + 1;
|
|
276
|
+
if (nextIndex < this.items.length) {
|
|
277
|
+
this.setFocusedItem(nextIndex);
|
|
278
|
+
} else {
|
|
279
|
+
this.setFocusedItem(0); // Loop to first
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
focusPreviousItem() {
|
|
284
|
+
const prevIndex = this.currentIndex - 1;
|
|
285
|
+
if (prevIndex >= 0) {
|
|
286
|
+
this.setFocusedItem(prevIndex);
|
|
287
|
+
} else {
|
|
288
|
+
this.setFocusedItem(this.items.length - 1); // Loop to last
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
destroy() {
|
|
293
|
+
// Remove event listeners
|
|
294
|
+
this.button.removeEventListener("click", this.toggle);
|
|
295
|
+
this.button.removeEventListener("keydown", this.handleButtonKeydown);
|
|
296
|
+
|
|
297
|
+
this.items.forEach((item, index) => {
|
|
298
|
+
item.removeEventListener("click", (e) =>
|
|
299
|
+
this.handleItemClick(e, index)
|
|
300
|
+
);
|
|
301
|
+
item.removeEventListener("keydown", (e) =>
|
|
302
|
+
this.handleItemKeydown(e, index)
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (this.options.closeOnOutsideClick) {
|
|
307
|
+
document.removeEventListener("click", this.handleOutsideClick);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Clean up
|
|
311
|
+
this.dropdown.classList.remove("is-open");
|
|
312
|
+
this.menu.classList.remove("is-open");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Auto-initialize dropdowns
|
|
317
|
+
function initDropdowns() {
|
|
318
|
+
const dropdowns = document.querySelectorAll("[data-dropdown]");
|
|
319
|
+
const instances = [];
|
|
320
|
+
|
|
321
|
+
dropdowns.forEach((dropdown) => {
|
|
322
|
+
const options = {
|
|
323
|
+
closeOnSelect: dropdown.dataset.closeOnSelect !== "false",
|
|
324
|
+
closeOnOutsideClick:
|
|
325
|
+
dropdown.dataset.closeOnOutsideClick !== "false",
|
|
326
|
+
closeOnEscape: dropdown.dataset.closeOnEscape !== "false",
|
|
327
|
+
hoverDelay: parseInt(dropdown.dataset.hoverDelay) || 0,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
instances.push(new AccessibleDropdown(dropdown, options));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return instances;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Initialize on DOM ready (only if not using module bundler)
|
|
337
|
+
if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
|
|
338
|
+
if (document.readyState === 'loading') {
|
|
339
|
+
document.addEventListener('DOMContentLoaded', initDropdowns);
|
|
340
|
+
} else {
|
|
341
|
+
initDropdowns();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Register in global namespace for CDN usage
|
|
346
|
+
if (typeof window !== 'undefined') {
|
|
347
|
+
window.a11yKit = window.a11yKit || {};
|
|
348
|
+
window.a11yKit.Dropdown = AccessibleDropdown;
|
|
349
|
+
window.a11yKit.initDropdowns = initDropdowns;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ES6 exports with short aliases
|
|
353
|
+
export { AccessibleDropdown, AccessibleDropdown as Dropdown, initDropdowns };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessible Modal Component
|
|
3
|
+
* Vanilla JS, a11y friendly, focus trap, keyboard navigation
|
|
4
|
+
* Follows WAI-ARIA Authoring Practices Guide for Dialog Pattern
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class AccessibleModal {
|
|
8
|
+
constructor(element, options = {}) {
|
|
9
|
+
this.modal = element;
|
|
10
|
+
this.triggers = Array.from(
|
|
11
|
+
document.querySelectorAll(`[data-modal-trigger="${element.id}"]`)
|
|
12
|
+
);
|
|
13
|
+
this.dialog = element.querySelector("[data-modal-dialog]");
|
|
14
|
+
this.backdrop = element.querySelector("[data-modal-backdrop]");
|
|
15
|
+
this.closeButtons = Array.from(
|
|
16
|
+
this.dialog.querySelectorAll("[data-modal-close]")
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Options
|
|
20
|
+
this.options = {
|
|
21
|
+
closeOnBackdrop: options.closeOnBackdrop !== false,
|
|
22
|
+
closeOnEscape: options.closeOnEscape !== false,
|
|
23
|
+
trapFocus: options.trapFocus !== false,
|
|
24
|
+
scrollLock: options.scrollLock !== false,
|
|
25
|
+
onOpen: options.onOpen || null,
|
|
26
|
+
onClose: options.onClose || null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
this.isOpen = false;
|
|
30
|
+
this.lastFocusedElement = null;
|
|
31
|
+
this.focusableElements = [];
|
|
32
|
+
this.firstFocusable = null;
|
|
33
|
+
this.lastFocusable = null;
|
|
34
|
+
|
|
35
|
+
this.init();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
init() {
|
|
39
|
+
// Set up ARIA attributes
|
|
40
|
+
this.setupAria();
|
|
41
|
+
|
|
42
|
+
// Bind event listeners
|
|
43
|
+
this.triggers.forEach((trigger) => {
|
|
44
|
+
trigger.addEventListener("click", (e) => this.open(e));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
this.closeButtons.forEach((button) => {
|
|
48
|
+
button.addEventListener("click", (e) => this.close(e));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Backdrop click
|
|
52
|
+
if (this.backdrop && this.options.closeOnBackdrop) {
|
|
53
|
+
this.backdrop.addEventListener("click", (e) => this.close(e));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Escape key
|
|
57
|
+
if (this.options.closeOnEscape) {
|
|
58
|
+
document.addEventListener("keydown", (e) => {
|
|
59
|
+
if (e.key === "Escape" && this.isOpen) {
|
|
60
|
+
this.close(e);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Focus trap
|
|
66
|
+
if (this.options.trapFocus) {
|
|
67
|
+
this.dialog.addEventListener("keydown", (e) =>
|
|
68
|
+
this.handleFocusTrap(e)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setupAria() {
|
|
74
|
+
// Generate unique IDs if not present
|
|
75
|
+
if (!this.modal.id) {
|
|
76
|
+
this.modal.id = `modal-${Math.random().toString(36).substr(2, 9)}`;
|
|
77
|
+
}
|
|
78
|
+
if (!this.dialog.id) {
|
|
79
|
+
this.dialog.id = `${this.modal.id}-dialog`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Modal ARIA attributes
|
|
83
|
+
this.modal.setAttribute("role", "dialog");
|
|
84
|
+
this.modal.setAttribute("aria-modal", "true");
|
|
85
|
+
this.modal.setAttribute("aria-hidden", "true");
|
|
86
|
+
this.modal.setAttribute("tabindex", "-1");
|
|
87
|
+
|
|
88
|
+
// Find title for aria-labelledby
|
|
89
|
+
const title = this.dialog.querySelector("[data-modal-title]");
|
|
90
|
+
if (title) {
|
|
91
|
+
if (!title.id) {
|
|
92
|
+
title.id = `${this.modal.id}-title`;
|
|
93
|
+
}
|
|
94
|
+
this.modal.setAttribute("aria-labelledby", title.id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Backdrop ARIA
|
|
98
|
+
if (this.backdrop) {
|
|
99
|
+
this.backdrop.setAttribute("aria-hidden", "true");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Trigger ARIA
|
|
103
|
+
this.triggers.forEach((trigger) => {
|
|
104
|
+
trigger.setAttribute("aria-controls", this.modal.id);
|
|
105
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
open(e) {
|
|
110
|
+
if (e) {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
this.lastFocusedElement = e.target;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.isOpen) return;
|
|
116
|
+
|
|
117
|
+
this.isOpen = true;
|
|
118
|
+
this.modal.setAttribute("aria-hidden", "false");
|
|
119
|
+
|
|
120
|
+
this.triggers.forEach((trigger) => {
|
|
121
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Scroll lock
|
|
125
|
+
if (this.options.scrollLock) {
|
|
126
|
+
document.body.classList.add("modal-open");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Update focusable elements
|
|
130
|
+
this.updateFocusableElements();
|
|
131
|
+
|
|
132
|
+
// Focus first element
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
if (this.firstFocusable) {
|
|
135
|
+
this.firstFocusable.focus();
|
|
136
|
+
} else {
|
|
137
|
+
this.modal.focus();
|
|
138
|
+
}
|
|
139
|
+
}, 100);
|
|
140
|
+
|
|
141
|
+
// Callback
|
|
142
|
+
if (this.options.onOpen) {
|
|
143
|
+
this.options.onOpen(this);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
close(e) {
|
|
148
|
+
if (e) {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.isOpen) return;
|
|
153
|
+
|
|
154
|
+
this.isOpen = false;
|
|
155
|
+
this.modal.setAttribute("aria-hidden", "true");
|
|
156
|
+
|
|
157
|
+
this.triggers.forEach((trigger) => {
|
|
158
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Scroll lock
|
|
162
|
+
if (this.options.scrollLock) {
|
|
163
|
+
document.body.classList.remove("modal-open");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Return focus to trigger
|
|
167
|
+
if (this.lastFocusedElement) {
|
|
168
|
+
this.lastFocusedElement.focus();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Callback
|
|
172
|
+
if (this.options.onClose) {
|
|
173
|
+
this.options.onClose(this);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
updateFocusableElements() {
|
|
178
|
+
// Find all focusable elements
|
|
179
|
+
const focusableSelectors = [
|
|
180
|
+
'a[href]',
|
|
181
|
+
'button:not([disabled])',
|
|
182
|
+
'textarea:not([disabled])',
|
|
183
|
+
'input:not([disabled])',
|
|
184
|
+
'select:not([disabled])',
|
|
185
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
this.focusableElements = Array.from(
|
|
189
|
+
this.dialog.querySelectorAll(focusableSelectors.join(","))
|
|
190
|
+
).filter((el) => {
|
|
191
|
+
return (
|
|
192
|
+
el.offsetWidth > 0 ||
|
|
193
|
+
el.offsetHeight > 0 ||
|
|
194
|
+
el.getClientRects().length > 0
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.firstFocusable = this.focusableElements[0] || null;
|
|
199
|
+
this.lastFocusable =
|
|
200
|
+
this.focusableElements[this.focusableElements.length - 1] || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
handleFocusTrap(e) {
|
|
204
|
+
if (!this.isOpen || e.key !== "Tab") return;
|
|
205
|
+
|
|
206
|
+
// If no focusable elements, prevent default
|
|
207
|
+
if (this.focusableElements.length === 0) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Shift + Tab (backward)
|
|
213
|
+
if (e.shiftKey) {
|
|
214
|
+
if (document.activeElement === this.firstFocusable) {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
this.lastFocusable.focus();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// Tab (forward)
|
|
220
|
+
else {
|
|
221
|
+
if (document.activeElement === this.lastFocusable) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
this.firstFocusable.focus();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
destroy() {
|
|
229
|
+
// Remove event listeners
|
|
230
|
+
this.triggers.forEach((trigger) => {
|
|
231
|
+
trigger.removeEventListener("click", this.open);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
this.closeButtons.forEach((button) => {
|
|
235
|
+
button.removeEventListener("click", this.close);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (this.backdrop) {
|
|
239
|
+
this.backdrop.removeEventListener("click", this.close);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Remove scroll lock
|
|
243
|
+
if (this.options.scrollLock) {
|
|
244
|
+
document.body.classList.remove("modal-open");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Close if open
|
|
248
|
+
if (this.isOpen) {
|
|
249
|
+
this.close();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Auto-initialize modals
|
|
255
|
+
function initModals() {
|
|
256
|
+
const modalElements = document.querySelectorAll("[data-modal]");
|
|
257
|
+
const instances = [];
|
|
258
|
+
|
|
259
|
+
modalElements.forEach((element) => {
|
|
260
|
+
const options = {
|
|
261
|
+
closeOnBackdrop: element.dataset.closeOnBackdrop !== "false",
|
|
262
|
+
closeOnEscape: element.dataset.closeOnEscape !== "false",
|
|
263
|
+
trapFocus: element.dataset.trapFocus !== "false",
|
|
264
|
+
scrollLock: element.dataset.scrollLock !== "false",
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
instances.push(new AccessibleModal(element, options));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
return instances;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Initialize on DOM ready (only if not using module bundler)
|
|
274
|
+
if (typeof window !== 'undefined' && !window.a11yKitManualInit) {
|
|
275
|
+
if (document.readyState === 'loading') {
|
|
276
|
+
document.addEventListener('DOMContentLoaded', initModals);
|
|
277
|
+
} else {
|
|
278
|
+
initModals();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Register in global namespace for CDN usage
|
|
283
|
+
if (typeof window !== 'undefined') {
|
|
284
|
+
window.a11yKit = window.a11yKit || {};
|
|
285
|
+
window.a11yKit.Modal = AccessibleModal;
|
|
286
|
+
window.a11yKit.initModals = initModals;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ES6 exports with short aliases
|
|
290
|
+
export { AccessibleModal, AccessibleModal as Modal, initModals };
|