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.
@@ -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 };